Desktop
- Settings → Credential Vault gains a "Password managers" section: per-manager
toggle (disabled with a hint when the CLI isn't installed), Locked/Unlocked
pill, Unlock (masked master-password dialog → vault.unlock) and Lock.
Items from a manager show a source badge instead of a delete button.
- Mid-turn vault.unlock.request renders a masked card in the chat (same
contract as the secret/sudo cards: dismiss = keep locked, late answers
tolerated, blocks the composer, badges background sessions).
- i18n parity en/ar/ja/zh/zh-hant.
Ink TUI (hermes --tui): vault.unlock.request/expire overlay via MaskedPrompt;
Esc keeps the manager locked.
CLI: `hermes vault sources [--enable|--disable NAME]`; `hermes vault list`
shows the source column and names enabled-but-locked managers.
Docs: credential-vault.md covers managers, per-session unlock, and the
headless (cron/webhook/API/-q) no-prompt posture.
Consolidated re-apply of #96988 onto current main. Ported from
Merit-Systems/OpenInstinct (MIT) opaque-handle autofill design: the model
sees vault handles + login metadata, the password is resolved and filled
server-side over the supervised CDP socket, and filled values are scrubbed
from every browser tool result by an unconditional redaction registry.
Rebase adaptations to the Sep-2026 facade/sibling layout:
- toolsets: one _HERMES_CORE_TOOLS entry (the browser toolset derives from it)
- hermes_cli/main.py: vault parser registered via the subcommand owner table
- file_safety: vault/ joins the _READ_DENIED_DIRS credential-dir table
- redact: registry scrub runs before the redact_secrets early-return
- browser_vault_tool: _run_browser_command now lives in browser_tool_session
With a main provider selected, an unusable main route (expired xAI/Codex
OAuth token, 401/402/429 mid-session) fell through the built-in discovery
chain (OpenRouter -> Nous -> custom -> api-key) and quietly ran every
compression, title and memory-flush call on whichever OTHER account was
still logged in. Reported as "using Grok on my Premium+ sub, my Nous
Portal balance kept draining" — the chat visibly stayed on Grok while the
side tasks were billed elsewhere, and re-logging into X did not help
because the aux side never consulted the selected provider.
The discovery chain is now reserved for installs with no selected main
provider (`model.provider: auto` / unset). Otherwise the ladder is
main -> auxiliary.<task>.fallback_chain -> fallback_providers -> refuse
with a warning naming the dead provider and the fix. Both entry points
gate on the same predicate: the resolve-time route and the mid-request
payment/auth hop (_try_payment_fallback).
Existing chain tests that asserted the hop now pin `provider=auto`, the
one case where discovery is still the contract.
tt-a1i/archify (MIT, 57k stars) turns a typed JSON spec into validated,
self-contained interactive HTML diagrams (architecture / workflow /
sequence / dataflow / lifecycle) with a 9-check validate/deliver receipt,
Mermaid import and PNG/SVG/WebM export. The upstream repo already ships a
complete skill directory (archify/), so this follows the impeccable
pattern: a catalog stub whose metadata.hermes.upstream pointer makes
`hermes skills install official/creative/archify` pull the live tree
through OptionalSkillSource._fetch_from_upstream. Nothing executable is
vendored and the copy can never go stale.
Docs: generated page, one catalog row, one sidebar line.
Credit: tt-a1i (upstream author). Supersedes the vendored-references
port in #106442.
Capabilities → Plugins is now a single table: one row per PACKAGE, with a
Desktop column (this app) and an Agent column (the selected profile). A
package with both halves is one row, never two; the kind badge is inferred
from what it ships (plugin.yaml → agent, plugin.js → desktop).
The desktop half of a unified agent+desktop package no longer loads from the
profile-shaped `plugins/<name>/desktop/` folder. Electron copies that half
into `~/.hermes/desktop-plugins/<name>/` beside a `.hermes-package.json`
marker (package name, source, origin repo/sha) and keeps it in sync: newer
source → re-copy, package uninstalled → copy removed, hand-installed
standalone folder of the same name → never overwritten. The renderer scans
exactly one root, so a pane can never appear, disappear, or re-scope when the
user switches profiles — the same switch reads the same value everywhere.
Why a copy rather than scanning every profile: two profiles can carry the
same package at different SHAs; a scan has to pick one silently. One copy,
one source of truth, stamped with where it came from.
- plugin-packages.ts: pure merge of desktop records + agent rows → rows
- plugins.manage list reports `has_desktop_half` so the pairing is explicit
- Install dialog (local backend): the desktop half is materialised from the
installed package instead of cloning a second standalone copy; remote
backends keep the separate clone. Target path now names the real profile
folder for non-default profiles.
- "Install here": a desktop half whose agent half is missing in the selected
profile pre-fills the dialog from the marker's origin; disabled with an
explanation for hand-copied folders with no origin.
- Profile selector moves into the Agent column header; hidden with 1 profile
- Rescan/Update reconcile the copies BEFORE rescanning (ordering bug)
- Drop the dead `agentPluginsRoot` IPC; docs updated (desktop.md, SDK,
bot-mode.md, hermes-desktop-plugins reference)
Live-dogfooded on a headless Electron with two profiles and a real
file:// git package: install both halves, profile switch ×3, Install here
into the second profile, v2 update via `hermes plugins update` → chip text
changes on Rescan, uninstall from both profiles → copy and row gone, broken
plugin row, cold restart, sash drag/reset, legacy Settings → Plugins
redirect.
Three things Teknium hit on the merged Plugins page:
1. Desktop plugins vanished on profile switch. `fs-ipc.ts::localPluginsRoot`
resolved `<HERMES_HOME>/profiles/<active>/desktop-plugins` for a named
Desktop profile, so a disk-installed plugin only existed under the profile
it was installed from. Desktop plugins extend the app, not an agent; the
root is now `<HERMES_HOME>/desktop-plugins` regardless of profile, gateway
or remote machine (`electron/desktop-plugins-root.ts`), with a one-time
migration that lifts any per-profile folders into the app root (root copy
wins on collision). Agent-plugin and logs roots stay profile-scoped.
On the page, the profile selector now renders INSIDE the framed Agent
plugins block as its header, and Desktop plugins sit outside that frame,
so the scope boundary is visible without reading the blurb.
2. The catalog viewport could not be resized. It gets the same top-edge drag
sash as the Skills hub picker (persisted height, double-click resets,
clamped so the lists above keep real height; iframe pointer-events off
while dragging).
3. Accent Picker, a theme-authoring toy shipped off by default, is removed
from the bundled set and published as a standalone desktop plugin at
https://github.com/NousResearch/hermes-desktop-accent-picker (same source,
esbuild-bundled plugin.js importing only the three loader-resolvable
specifiers). Docs point there.
Symptom (Discord/X report, macOS): exit HUD mode and the small HUD window
stays on screen while the main window never regains focus; the toggle now
reads the wrong state, so there is no in-app way out.
Three defects in the HUD lifecycle, all in the main process:
- closeHudWindow() detached the window's 'closed' listener before calling
win.close(). close() is a REQUEST that waits on the renderer's unload
handlers, so a HUD renderer that never answered left an always-on-top
window with no teardown owner and no close broadcast. The 'closed'
handler now stays attached and is the single teardown path; a bounded
requestHudClose() (electron/hud-close.ts) destroys the window after a
1.5 s grace if the renderer never lets go.
- ⌘W inside the HUD was routed to the MAIN renderer's close-active-tab
(installPreviewShortcut is wired on every chat window), so it closed a
tab in the hidden app window and left the HUD up. ⌘W in the HUD now
runs closeHudWindow(), the same path as the exit button.
- restoreMainWindowFromHud() was a bare show() and only armed when the
main window was isVisible() at HUD-open time. On macOS isVisible() is
false for an occluded window (Electron folds occlusionState in), so
the app never came back when the HUD was opened over another app, and
show() alone does not make the window key. The restore now arms on a
live main window and routes through focusWindow() (restore → show →
focus). Closes#88513 (credit: PR #90489's diagnosis).
Renderer: the handoff now also requests composer focus on the close
broadcast, so the app window gets the keyboard, not just the session.
Docs: ⌘/Ctrl+W added to the HUD exit list.
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the
Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install`
method and the dashboard `/agent-plugins/install` endpoint all called
`_install_plugin_core` without a ref, so a team that wanted everyone on the
same private-plugin commit had to leave the app for a terminal.
- `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`;
the 40-hex validation and HEAD verification are unchanged. The gateway
method and dashboard body accept `ref`.
- Desktop dialog gains an optional "Pin to commit" field (custom sources only,
client-side 40-hex check disables Install on anything shorter).
- `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows
a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
in Source, so a team can eyeball that everyone runs the same commit.
- `_target_matches_origin` docstring still claimed fan-out targets are
"deliberately NOT mirrored" and mirroring is origin-only; that has been
false since origin_fallback landed and is doubly so with the home lane.
Point to `_target_mirror_eligible` for the policy instead.
- cron.md DM-only bullet said "origin DM session"; the config comment in
the same change already says "target DM". Align.
- Remove two `_expand_routing_tokens` unit assertions bolted into the
eligibility test; the `" ALL , slack "` parametrize covers expansion
end-to-end through `_resolve_delivery_targets`.
A managed cron with `deliver: "slack"` (no captured origin) delivers to the
Slack home channel, but the brief was never mirrored into that session and
the in_channel seed never fired: bare-platform targets resolved with no
`_resolved_from` provenance, so `_target_mirror_eligible` treated them like
`all` broadcast expansions.
- `_resolve_delivery_targets` derives `from_broadcast` from the raw token
and passes it to `_resolve_single_delivery_target`; a user-written bare
platform token is tagged `_resolved_from: "home"`, `all` expansions stay
untagged (fan-out is never continuable).
- `_target_mirror_eligible`: `home` eligible under the same flags as
`origin_fallback` (per-job attach_to_session wins, else cron.mirror_delivery).
- `_MIRROR_PROVENANCE_RANK`: `home` == `origin_fallback` so token order
through dedup cannot strip eligibility.
- Docs, tool schema text, cron/gateway AGENTS.md updated; existing
exact-dict pins carry the new key.
Squashed from PR #101819 (commits 5fe612c9, 65e36926, 10e0af1d, f2cc4639):
they predate the cron/scheduler.py -> scheduler_delivery.py split and do
not cherry-pick individually onto current main.
Plugins were split across two pages that each showed half the picture:
Settings → Plugins listed desktop plugins plus "Install from Git" and a
pointer saying agent plugins live elsewhere; Capabilities → Plugins listed
agent plugins plus the catalog picker but knew nothing about desktop
plugins. A user asking "what extends my Hermes and where do I add more?"
had to visit both and still could not see the whole set in one place.
Capabilities → Plugins is now THE plugins page:
- Agent plugins section (scoped to the profile selector) with the
"Install from Git" button in its header — installs target the scoped
profile, not whichever one is active.
- Desktop plugins section beneath it (same for every profile), with the
folder/rescan controls and the "agent half missing here" drift chip,
whose repair also lands in the SCOPED profile.
- The catalog picker underneath, unchanged.
Settings → Plugins is removed. `/settings?tab=plugins[&plugin=…]` and the
existing `?tab=mcp` redirect share one table (`settings/moved-tabs.ts`) so
old bookmarks and palette links land on the same row on the new page.
Command palette: plugins moved from the Settings group to the Capabilities
group; installed-plugin rows deep-link to `/skills?tab=plugins&plugin=…`.
Dead `settings.plugins.agent.*` and `settings.nav.plugins` i18n keys dropped;
docs and in-code pointers say Capabilities → Plugins.
`hermes plugins install owner/private-repo` failed for every private repository
even when the same user could `git clone` it from their shell: the hardened
`noninteractive_git_env` disables credential helpers, askpass and global config
(so a hostile repo can't make our plumbing prompt or hang), which also blocks
the user's own stored credential. The result was "could not read Username" or a
60s hang on a GUI askpass, with no hint about how to authenticate.
New `hermes_cli/git_credentials.py` resolves a credential up front from sources
the user already owns — GITHUB_TOKEN/GH_TOKEN (profile-scoped), `gh auth token`,
then `git credential fill` against their configured helpers with prompting
disabled (any host) — and hands it to git as a one-shot
`http.<origin>/.extraheader` via the GIT_CONFIG_* env block. Nothing lands in
the URL, `.git/config` or install metadata. The same path covers
`plugins update`, catalog MCP git installs and profile-distribution staging,
which share the same hardened env and the same failure.
A private-repo clone with no credential now fails fast with an actionable hint.
* feat: add session-scoped connector access for onboarding
* fix(connectors): availability is the config flag AND the portal entitlement — no free-tier leg
The port carried a third availability leg from hermes-magic: a stored guest
(free-tier) identity short-circuits the managed-tool entitlement check. That
leg reads hermes_cli.anon_auth, which does not exist on hermes-agent main, so
connectors_available() raised ImportError inside its fail-closed try and the
whole connector surface was silently dark on a plain upstream checkout.
On this tree availability is the two-leg AND the design started with:
tools.connectors.enabled AND managed_nous_tools_enabled(). The free-tier leg
is a hermes-magic concern and belongs in hermes-magic's own delta over this
branch, next to the identity it depends on. Its integration test goes with it.
* docs(tool-search): connectors section — remote tools through the bridge
The squashed port carried the code but not the user-facing docs. Restores the
Connectors section of the Tool Search page and the connector-gateway host /
CONNECTOR_GATEWAY_URL override on the Tool Gateway page, updated for the
manage_connections tool and the pure-connector batch rule.
* fix(tool-search): connector tools rank with local tools in one pass instead of taking leftover slots
dispatch_tool_search ran BM25 over the local catalog, filled `limit` slots,
then appended connector hits only into slots left empty. On a 300-tool
catalog no slot was ever empty, so with Gmail and Google Calendar connected
"send gmail email" returned five betterstack tools and zero connector tools.
The gateway's hits for a query now become catalog entries (connector name,
slug words, description as the search text) and join the local catalog for
that query's BM25 pass. One ranking, one rarest-token admission rule for both
sources, `limit` as the total per query. The merge loop and the separate
record builder for connector hits are gone; `_shared_tool_record` serves both
sources.
The gateway search timeout rises from 8 s to 30 s. One request with six
use_cases measured 7 s, so 8 s sat on the edge and cut real answers off; the
failure path is unchanged (local-only results, no error to the model).
Live, 311 local tools + gateway, before -> after:
"send gmail email": 5 betterstack tools -> gmail SEND_EMAIL, CREATE_EMAIL_DRAFT
"read google calendar events": 5 betterstack tools -> googlecalendar EVENTS_LIST_ALL_CALENDARS
"linear create issue", "betterstack incident": unchanged
Benchmark (25 labelled queries): connector recall 0.09 -> 0.82, precision@5
0.18 -> 0.59, false positives on absent intents 17 -> 2.
* refactor(tool-search): connector leg into tools/connector_search.py
tools/tool_search.py is a facade. The connector leg (gateway hits as catalog
entries for tool_search, remote schemas for tool_describe, the
connections_in_scope gate) was appended to it by the port. It now lives in
its own sibling, tools/connector_search.py, and the facade imports the three
entry points: connections_in_scope, connector_entries_by_group,
remote_schemas_for.
No behaviour change. The tool_describe remote block became
remote_schemas_for(names, current_tool_defs, connector_describe) with the
same inputs, the same silent-degradation contract and the same injection
seam the tests already use.
* fix(tool-search): at most 7 queries per call, the gateway's search limit
One tool_search call sends all its queries to the connector gateway as one
search request. The gateway answers 7 use_cases per request and returns
HTTP 502 for 8 or more (measured 2026-09-09, re-measured with one-word
use_cases: it is a count limit, not a size limit). With the client cap at
10, a model sending 8 to 10 queries lost every connector hit for that call
and saw local-only results with no error.
The shared constant splits: _MAX_QUERIES_PER_CALL = 7 for search,
_MAX_DESCRIBE_NAMES_PER_CALL = 10 for describe, which has no remote count
limit. Eight or more queries now get the existing "too many queries" retry
hint before any request is made. No chunking: one call, one request.
* fix(tool-search): the model is told that connectors__ names are manage_connections accounts
tool_search results carry names like connectors__gmail__CREATE_EMAIL_DRAFT and
manage_connections is the tool that checks and connects those accounts, but
nothing told the model the two are the same thing. A model that hit
CONNECTION_REQUIRED had to infer the fix on its own.
The tool_search description gains one sentence making the link, added at
assembly only when manage_connections is in the session's tools. Signed out
or with connectors off the tool is absent and the description is unchanged,
so it never names a tool the model cannot call. This follows the existing
rule for cross-tool references (tools/AGENTS.md): they are added dynamically
from the session's actual tool set, never hardcoded in a schema.
Tool defs are fixed for the life of a conversation, so the description is
byte-stable per conversation; this is a one-time prefix change.
Live, real get_tool_definitions() against a signed-in home: sentence present.
Same home with auth.json removed: manage_connections absent, sentence absent.
* fix(connectors): /stop halts a connector batch before the next remote call
dispatch_connector_batch runs every remote entry of a tool_call batch in
sequence. The executor only checks the interrupt flag between tools, and
the whole batch is one tool to it, so a /stop landing during entry 1 of
20 still sent the other 19 to the gateway.
The loop now reads tools.interrupt.is_interrupted before each dispatch.
Once set, it stops calling handle_function_call and fills every unstarted
slot with the loop's existing error-slot shape, code INTERRUPTED and the
message "Stopped by the user before this call was made.", so the result
envelope stays valid and the counts stay honest. Entries already
dispatched keep their real results.
Test: three connector calls where the fake client sets the interrupt on
the first execute. The client sees exactly one call and slots 2 and 3
carry INTERRUPTED. Red on the base branch, green with the fix.
* test(connections): schema assertions become dispatch contracts
test_schema_documents_wait_and_its_timeout froze description fragments
("REQUIRED", "can NOT disconnect", "Nous Portal"). A wording edit fails
it while a real regression (a disconnect that reaches the gateway) does
not. That is a snapshot of prose, not a behaviour contract.
Delete it. The requirement that wait needs connectors is already covered
by test_wait_requires_connectors. The user-only disconnect boundary is
now asserted as behaviour: action disconnect with a connector returns an
error and the fake client records no call. That replaces the earlier
de-authenticate test, which only checked that the word "dashboard"
appeared in the error text.
Test count in the file goes from 26 to 25.
* docs(tool-search): connector batches are one gateway request per entry
The user guide said a connector batch travels as one gateway request. It
does not: model_tools_connectors.dispatch_connector_batch re-enters core
dispatch per entry, and each entry becomes its own execute request in
bridge._run_remote (plus at most one literal-slug retry when the gateway
reports TOOL_NOT_FOUND under the conventional slug). The docstrings in
tools/tool_gateway/bridge.py and tools/tool_gateway/__init__.py still
described the abandoned V1 plan and claimed nothing outside the package
imports it.
Rewrite those sentences to match the code: one request per entry, in
input order, dispatched from model_tools_connectors.py, with the per-entry
approval and interrupt behaviour that motivated the split. The guide also
still showed the single-call shape tool_call(name, arguments); both
places now show the `calls: [{name, arguments}]` array the schema
advertises and note that a single local call is an array of one.
Docs only, no test.
* fix(tools): the between-turns refresh never rewrites the bridge tools
The per-turn MCP refresh folds a fresh tool snapshot into the live array
with preserve_prefix: order and membership stay, but a name present in both
takes the fresh schema. That is right for ordinary tools, whose schema is a
constant. tool_search is the one tool whose description is derived from the
session: the deferred-tool count, the embedded listing, and, on this branch,
whether manage_connections was present. A late MCP server or one failed
portal lookup (manage_connections' check_fn fails closed) changed those bytes
on the next turn, and every byte after tool_search in the cached prefix was
re-prefilled. The array also contradicted itself in that case: the flapping
manage_connections was carried forward while the description lost its hint.
The bridge entries now keep the bytes they were built with for the life of
the conversation. Nothing is lost: tool_search reads the live catalog at
dispatch, so tools that arrived late are still found; connector availability
is checked at dispatch too. The compaction-boundary rebuild (content_aware,
the one sanctioned cache break) still refreshes the description.
Consequence: connector exposure in the prompt is decided once, at agent
build, by whether the user was signed in then. That is the intended
contract.
* refactor(tool-search): normalize_tool_call_entries lives with the other argument validation
The port appended the tool_call argument parser to the tool_search facade.
The family already has tools/tool_search_validation.py for exactly this
work (schema validation of deferred call arguments), so the parser moves
there and the facade imports it. No behaviour change; the one test that
imported it now imports from the defining module.
* refactor(connectors): delete the unused batch dispatcher; _run_remote becomes run_remote
bridge.dispatch_calls and its helpers (_dispatch_calls_inner, _run_pre_dispatch,
_run_local, _error_slot, _maybe_parse_json) and the LocalDispatch / PreDispatch
seams had no production caller. Connector dispatch runs through
model_tools_connectors: dispatch_connector_batch re-enters handle_function_call
once per entry, so scope, hook, approval and middleware policy fire against each
composed name inside core dispatch, and dispatch_connector_call hands the single
planned entry to the bridge's transport function. Only tests called the batch
dispatcher, and they exercised policy seams that production never wires.
The transport function is the module's real entry point, so it drops the
underscore: _run_remote becomes run_remote, body unchanged. The module
docstring now describes the two legs that exist (availability with D32 silent
degradation, and run_remote) instead of the injected seams. Imports that only
the deleted code used are gone; merge.py is untouched because every export
still has a caller.
Tests that drove dispatch_calls are deleted where they covered the removed
seams (pre_dispatch blocks and rewrites, local_dispatch classification, mixed
batches). The literal-slug fallback, the per-entry transport failure, and the
hook rewrite reaching the gateway request body are re-targeted at
handle_function_call('tool_call', ...) with the fake client swapped in at
bridge._default_client_factory, the same seam test_connector_dispatch_policy
uses. Each re-targeted test fails when the retry is disabled in run_remote.
* fix(connectors): search keeps the twin a colliding name reaches, and says so
format_connector_name strips the toolkit prefix, so GMAIL_FETCH_PROFILE and a
literal FETCH_PROFILE on gmail both compose to connectors__gmail__FETCH_PROFILE.
describe and execute decode that name to the prefixed slug first, so the
literal twin is unreachable under it. If a vendor ever shipped both, search
could describe the literal under a name that runs the prefixed tool.
Search is the one place that sees both twins in one response. It now keeps
the twin the name reaches and drops the other with a WARNING that names both
slugs, whichever the gateway listed first. Short names stay; no marker, no
per-process map, no change to describe or execute. No such pair exists in the
live catalog today; the guard turns a silent alias into a logged one.
search_catalog admitted every document with BM25 score > 0 and then padded
to `limit`. BM25 sums over the tokens a document shares with the query, so
on a 300-tool catalog "send gmail email" returned five incident tools that
shared only "email", and the discriminating word ("gmail", in no document)
had no say. The model read those as the answer.
Admission is now the query's rarest token: a document is a result only if it
contains the query token with the highest IDF, the one that names the intent.
Common verbs ("send", "read", "create") sit in dozens of documents and never
gate; vendor and object words ("gmail", "github", "incident") do. A token no
document carries admits nothing, and the existing empty-group hint tells the
model to retry without it. The name-substring fallback is deleted: it admitted
tools that matched no query token at all.
Result descriptions are clipped at 500 characters instead of 400. Over 353
vendor tool descriptions, 500 keeps 91% whole and every first sentence
(first-sentence max 329); 400 kept 82%.
Measured on the live 311-tool catalog with 25 hand-labelled queries:
precision@5 0.18 -> 0.43, wrong names returned 102 -> 66, false positives on
absent intents 17 -> 13. Live before/after: "send gmail email" went from five
betterstack tools to an empty group with the retry hint; "linear create issue"
and "betterstack incident" are unchanged.
A flat 450-char cap fits 512-token embedders (bge-small-zh-v1.5,
all-minilm) but stores only ~5% of the window on 8192-token models
(text-embedding-3-small, jina-embeddings-v3, bge-m3), degrading memory
quality for users those models served fine before truncation existed.
Read `sync_max_chars` from mem0.json once in initialize() (450 default)
and pass it to _truncate_for_sync(). Config over auto-detection: the
Ollama /api/show probe + known-model table proposed in #37427 adds a
network call and a curated list for a number the operator already knows
from their embedder choice; the setup wizard's mem0.json is the plugin's
behavioral-settings surface (no new HERMES_* env var). Documented in the
plugin README and the memory-providers docs page.
Dynamic-cap requirement and measurements (450 OK / 600 -> HTTP 500 on
bge-small-zh-v1.5:f16) by @szicely in #106235.
Refs #37421#106235
Co-authored-by: szicely <140148567+szicely@users.noreply.github.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Move the WSL / /dev/dxg / d3d12_dri.so probes into _prefer_wsl_d3d12 with
the probed paths as module constants, so the launcher call site is a single
line and a test can lay out a fake WSLg host without touching real
/dev or /usr/lib. The two tests now run the real _desktop_launch_env end to
end (selected under WSL+dxg+driver; untouched with an explicit Mesa
override, off WSL, without /dev/dxg, or without the driver file) instead of
unit-testing the helper with a precomputed boolean.
Docs: one paragraph in the Desktop guide on the automatic selection and
the env vars that keep an explicit choice authoritative.
Follow-up to Xipong's fix for #106117 (salvaged from #106118).
Fold the helper into _npm_lifecycle_env itself: the whole fix is one
is_file() check plus a setdefault, so a separate function, the
try/except around get_hermes_home() (it never raises) and the
dict-returning indirection were shape-gate violations. Build the path
with os.fspath so it is correct on Windows too.
Tests: keep the two invariants (file present -> NPM_CONFIG_USERCONFIG
points at it; explicit process/caller value wins and a missing file
sets nothing), fold the other two into the negative test.
Docs: one paragraph in the desktop troubleshooting page next to
ELECTRON_MIRROR describing $HERMES_HOME/npmrc.
Refs #106373
- _ssl_interop_hint: also match ssl.SSLError instances (and one level of
__cause__/__context__) plus the bare UNEXPECTED_EOF marker, so an
SSLEOFError whose text httpx did not repeat still gets the hint. The
hint now names the TLS 1.2 diagnostic and links the providers docs
note instead of an issue number.
- tests: 3 -> 2 invariants (parametrized login_post/poll SSL case keeps
the raw text + hint + cause; a plain httpx timeout gets no hint).
- docs: providers.md Codex note carries the reporter's exact openssl.cnf
classic-groups snippet (EN + existing zh-Hans copy).
Refs #106384. The TLS max-version cap itself stays PR #44392's scope.
- hermes_cli/plugins_cmd_catalog.py: new sibling owning resolution, the
.hermes-catalog.json provenance sidecar, search/info/validate, re-pin on
update, and the dashboard/TUI payload builders. plugins_cmd.py only
gains the hooks (cmd_install catalog branch, cmd_update / dashboard
update re-pin, dashboard_install_plugin catalog_name + kill list,
dispatch entries); the community index (plugin_index.py) is gone.
- hermes_cli/plugin_catalog.py: catalog_dir parameter replaces the
test-only HERMES_PLUGIN_CATALOG_DIR env var; live refresh reads ONE
published document (/docs/api/plugin-catalog.json, 6h cache, in-tree
fallback) instead of the unauthenticated GitHub contents API (60 req/h,
1 request per entry); in-tree and live removals are unioned so a stale
cache can never un-block.
- Catalog route lives in web_routers/dashboard_ui.py (the facade is off
limits); _plugin_runtime_status shared from web_server_dashboard.py;
hub rows carry removed_reason. TUI plugins.manage gains catalog_name
install, catalog row fields and an update action.
- plugin_validate: the probe context honours ctx.get_config defaults
(real plugins do int(ctx.get_config("timeout", 180)) in register()).
- Installed-state merge matches through the sidecar's catalog_name
first — catalog names rarely equal manifest names.
- extract-plugins.py emits plugin-catalog.json; deploy-site triggers on
plugin-catalog/** so entry merges republish it.
The user guide said an unpinned job "fails closed" on a global model change
and documented cron.model_drift_guard; both are gone. It now explains that the
job keeps running on its creation snapshot and how to move it (per-job pin or
cron.model).
Cron jobs are scheduled work bound to the source profile and its origin
channel. A clone that inherited cron/jobs.json fired every job twice: two
gateways with identical job ids running the same weekly jobs in parallel
(double spend, duplicate deliveries) until one gateway died.
Root cause: `cron` was not in _CLONE_ALL_HISTORY_EXCLUDE_ROOT, so the
copytree in _clone_all_into carried jobs.json along. Add it to the
per-profile history exclude set (applies to any source, CLI, dashboard
and TUI/desktop RPC all funnel through create_profile), recreate the
_PROFILE_DIRS skeleton after the copy so the clone still has an empty
cron/ (and sessions/), and say so in the CLI summary line and docs.
--clone (config-only) never copied cron; export/backup keep cron as
before (an archive is a portable snapshot, not a second live profile).
Python plugin CLI/loader/web/tui files taken from main wholesale; the
catalog layer is re-ported onto main's decomposed shapes in the
following commits. plugin_index.py removed (catalog is the sole
discovery system).
Four writers each dropped their own uniquely-named copy of config.yaml next to
the real file and none of them ever deleted anything: hermes setup
(config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the
corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai
(config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration
(config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants
with no way to tell which mattered.
hermes_cli/config_backups.py::backup_config is now the single writer:
backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest
copy for that reason is byte-identical, rotated to the newest five per reason.
backups/ is already excluded from full backups so nothing nests. Legacy
siblings written by the old schemes are moved into the dir on first use;
hand-named copies (config.yaml.bak-my-note) are left alone.
Live: three `hermes setup --non-interactive` runs against an unchanged config
went from three .bak files in HERMES_HOME to one pre-setup copy under
backups/config/; repeated loads of broken YAML produce one corrupt copy
instead of one per process (deduped by content).
The ledger in verification_evidence.db exists only to feed the verify-on-stop
guard, but the recorder kept running on every foreground terminal command and
every file edit after #53552 turned the guard off by default. Users who never
opted in still accumulated a multi-MB database (7 MB / 4.6k rows on one install).
Every ledger entry point (record_terminal_result, record_verify_run,
mark_workspace_edited, verification_status) now checks verify_on_stop_enabled()
first and returns without opening or creating the database when the guard is
off. verification_status reports {"status": "disabled"} in that case; no client
consumes the verification.status RPC yet, so nothing downstream changes.
Existing ledger tests pin HERMES_VERIFY_ON_STOP=1 since they exercise the ledger
itself; the new test proves the off path never creates the file (red on base).
Move the six surface-switch helpers out of the conversation_loop facade
into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical
sibling), and fold the review findings on #104494:
- MoA and codex_app_server turns never stamp the api_content sidecar, so
the staged note could not be read back from the transcript and was
re-sent on every turn after a switch. Those modes now skip the note
(stored prompt still reused).
- The announced surface was parsed with split(".") — a plugin platform
with a dot in its name would never compare equal and re-stage the note
every turn. The note now closes the name with a fixed terminator.
- One identity-line parser (identity_line_value) shared by
_stored_prompt_matches_runtime and the switch detector instead of two
copies of the runtime-boundary/rpartition logic; tool names via the
existing tools.mcp_tool_agent._def_name; the transcript scan is bounded
to the last 200 rows (it ran every turn over the whole history).
- consume_surface_switch_note reduced to a plain pop; developer-guide
prompt-assembly.md updated (Platform is no longer an identity field);
17 new tests trimmed to 10 (same-shape pin/retire variants folded).
Restoring Platform as an identity field still turns 5 tests red.
Port Adolanium's focused-turn pose from Hermes-Bot-Mode#101 and
hermes-agent#88134 to the current typed Bot Mode implementation.
Match the busy signal's connection-qualified focused owner rather than
the gateway socket, retain worker activity, and ease transitions in
elapsed time on the existing shared face clock.
Includes owner-isolation and animated-pose invariants, both proven red
on origin/main, and native Electron before/after verification against
a real temporary Hermes backend with held loopback inference.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Add Move up/down controls for actual rooms without changing bot or folder
ordering. Preserve default pin/activity ordering until an explicit move,
retain hidden room slots, and persist Desktop-local order through room
updates, mirror merges, and hydration. No membership or routing writes.
Adapted narrowly from the group ordering idea in archived
NousResearch/Hermes-Bot-Mode#105 by @onuraycicek; rename already exists.
Co-authored-by: Onur Aycicek <onur.m.aycicek@gmail.com>
Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.
Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576
Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>
The importer stays in the command palette. The labeled nav row was
clutter next to New session / Capabilities / Messaging.
Co-authored-by: Cursor <cursoragent@cursor.com>
Complete the PR #101452 salvage, preserving sprmn24 authorship. Credit @kokhlo PR #101591 for independently diagnosing the in-flight rename race; use scoped authoritative room bindings rather than persistent aliases. Keep mention attention independent and retire late work after disband.