Commit Graph

33401 Commits

Author SHA1 Message Date
Teknium ac33da3c73 fix(tui): hide the composer while the password-manager unlock card is open
Live Ink TUI repro: with the unlock card mounted, keystrokes reached BOTH the
masked prompt and the still-focused composer, so the master password echoed
in clear text in the composer row and was queued as a message. `$isBlocked`
(which unmounts the composer for approval/sudo/secret cards) did not list the
new overlay; the pet's awaiting-input predicate had the same gap. After the
fix the raw PTY stream no longer contains the typed password.

Also tightens the classic-CLI panel copy to fit an 80-column box.
2026-09-10 10:35:07 -07:00
Teknium e9f7d44f3f fix(vault): carry the unlock prompt onto tool worker threads; honour binary_path in install checks
Live Desktop repro: the fixture model called browser_vault_unlock and got
unlock_unavailable although the renderer was interactive. tool_executor runs
handlers on a propagated worker thread; thread_context only copied the
approval and sudo thread-local callbacks, so the unlock prompt registered by
_wire_callbacks was invisible there and can_prompt_here() said nobody could
answer. The callback table in thread_context now lists every per-thread
prompt (approval, sudo, vault unlock) so a new one cannot silently drop off
worker threads again. After the fix the same turn shows the masked card and
completes.

vault.sources / `hermes vault sources` report a manager as installed when
its configured binary_path exists, not only when it is on PATH.
2026-09-10 10:35:07 -07:00
Teknium 92e0de0ac4 feat(vault): Desktop, TUI and CLI surfaces for password-manager unlock
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.
2026-09-10 10:35:07 -07:00
Teknium 8e7e9be217 feat(vault): sign in with 1Password or Bitwarden logins, unlocked per session
The browser vault now draws from three login sources behind one handle
shape: the local encrypted vault (vault_…), 1Password Login items (op:…)
and Bitwarden Password Manager logins (bw:…). browser_vault_list aggregates
metadata across them; browser_vault_fill routes by prefix and resolves the
password at fill time only, through the manager CLI.

External managers are locked until the user unlocks them for the current
session. The new browser_vault_unlock tool (and the fill path, implicitly)
asks the surface to show a masked master-password prompt — CLI panel
(reuses the sudo panel state), TUI/Desktop via a vault.unlock.request
blocking card. The password goes to `op signin --raw` / `bw unlock --raw`
on stdin, never argv or env; only the session token is kept, in memory,
with a 30-minute idle TTL, cleared on session close or `vault.lock`.

Headless contexts (cron, webhook, api_server, -q) can never prompt: the
manager is reported as locked with unlock=unavailable_in_this_session and
fill refuses — the same posture approvals take where nobody can answer.

Config: vault.onepassword / vault.bitwarden {enabled, binary_path, …};
a 1Password service-account token skips the prompt for headless use.
RPC: vault.sources, vault.source.set, vault.unlock, vault.lock for Settings.

Tests (2, real subprocess against a fake bw; each proven red by sabotage):
headless never prompts or spawns; unlock feeds stdin only, token never
enters os.environ, fill routes by prefix and the password only reaches the
fill script.
2026-09-10 10:35:07 -07:00
Teknium cde0ad0edd feat: agent signs into sites from an encrypted local vault (CLI, browser fill, Desktop Settings)
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
2026-09-10 10:35:07 -07:00
Teknium 94f77dfa0d fix(agent): thread turn_author through conversation_loop.run_conversation
The facade forwarded turn_author= to the loop's public entry point, which did not
declare it: every real AIAgent.run_conversation() turn raised TypeError (16 CI
failures across provider, sidecar, cron and finite-chat suites). The PR's tests
only exercised build_turn_context directly, so the missing hop was invisible.
Adds one facade-through-loop test that goes red when the kwarg is dropped.
2026-09-10 10:27:07 -07:00
Teknium 103fe14cb4 docs(gateway): document gateway.bot_loop_guard where ALLOW_BOTS is explained
The Discord page said there was no circuit breaker for bot ack-loops; there is one now.
Also strips two trailing blank lines left by the test trim.
2026-09-10 10:27:07 -07:00
Teknium ff90eb28ff test(bot-mode): trim the author and loop-guard suites to their invariants
Keep one or two behaviour tests per seam (author reset on a cached agent, forged
_turn_author refused, guard trips and cools, single charge on the busy path) and
drop the parser/setting enumerations. a2a_key goes with them: nothing in this PR
reads it; the honcho follow-up that does can bring it back with its consumer.
2026-09-10 10:27:07 -07:00
Erosika 4f12985cd2 fix(gateway): refuse relay sender fields from a logged-in client and say what the author trusts
bot_relay.deliver accepted from_profile, from_handle and from_connection from any admitted JSON-RPC client. The handler now refuses them with error 4095 when the calling transport carries a browser login identity, since a logged-in browser never relays for another connection. The DeliveryAuthor docstring now says the author is trusted because an admitted client relays it, not because the sender is verified.
2026-09-10 10:27:07 -07:00
Erosika 091ac34ba9 fix(bot-mode): qualify the peer-dm author id with the sender's hostname
message_agent sent a bare bot:<profile> id through hermes peer dm, so a remote coder and the recipient's own coder shared one author id. The peer branch now sends bot:<hostname>/<profile>, with the hostname cleaned like any author field and slashes dropped. The direct local path keeps the bare id.
2026-09-10 10:27:07 -07:00
Erosika 2954fc7c55 fix(desktop): keep the relay deliver timeout beside its call
relay-deliver-budget.test.ts reads relay.ts and expects RELAY_DELIVER_TIMEOUT_MS within 400 characters of the bot_relay.deliver call. The comment above the new from_connection parameter pushed it past that window. The parameter names say what the gateway does with them.
2026-09-10 10:27:07 -07:00
Erosika 5ad6225174 test(bot-mode): a human prompt after a relayed dm carries no author
The author travels on the queued entry and the _run_prompt_submit call, and turn_context resets agent._turn_author at every turn start. The test pins that the human prompt following a drained relayed dm reaches run_conversation without a turn_author.
2026-09-10 10:27:07 -07:00
Erosika 3db4defcc1 fix(bot-mode): qualify a relayed author from the local connection too
delivery_turn_author kept the bare bot:<profile> id when the sender's connection was the Desktop's own "local", so a DM relayed from that machine collided with the recipient's profile of the same name. A relayed DM always crosses gateways, so the connection id is now part of the id whenever the Desktop sends one, and only the direct message_agent path in tools/bot_mode_dm.py stays bare. The relay.ts and session_auto_continue.py comments added earlier are cut to one line each.
2026-09-10 10:27:07 -07:00
Erosika d3c8bacbe3 fix(bot-mode): qualify relayed authors with the sender's connection id
A relayed DM stamped bot:<profile> on the recipient turn, so an ops profile on another machine and the local ops profile shared one author id. The Desktop now forwards from_connection with each bot_relay.deliver, and delivery_turn_author builds bot:<connection>/<profile> for it while the Desktop's own gateway ("local") keeps the bare id. An api author object accepts an optional origin string that yields the same shape.
2026-09-10 10:27:07 -07:00
Erosika b41b036e64 fix(memory): pass on_turn_start kwargs only to providers that accept them
MemoryManager.on_turn_start forwarded the author kwargs to every provider and _each_provider swallowed the TypeError, so a provider with the two-positional on_turn_start(n, text) stopped running. The kwargs are now filtered against the provider's signature the way _provider_sync_accepts filters sync_turn.
2026-09-10 10:27:07 -07:00
Erosika b30898cb65 fix(bot-mode): keep authored queued DMs out of the inflight dedup
_enqueue_prompt dropped a queued entry whose text matched the live prompt without checking turn_author, and _sanitize_queued_entry_vs_inflight_user stripped the inflight prefix from any text entry. A relayed DM carries its sender, so an entry with turn_author is never treated as a self-duplicate and never prefix-stripped.
2026-09-10 10:27:07 -07:00
Erosika 445113dffa fix(gateway): count bot messages under the transport profile
The loop guard peeked under the transport profile stamped on the source but counted under the routed profile's scope, so the two read different gateway.bot_loop_guard blocks. _admit_bot_message_for_source runs the count under the same _authorization_profile_home as _is_user_authorized_for_source, and both the ingress gate and the busy path call it.
2026-09-10 10:27:07 -07:00
Erosika a7bc3c7b4b fix(gateway): charge the bot loop budget on the busy path
_handle_active_session_busy_message only peeked at the guard, so a bot steering a running turn was never counted and the budget never tripped. The busy handler now admits the message once after the authorization verdict and marks the event. _hm_admit_event skips a marked event, so a follow-up that was queued on the busy path and drains later is not charged twice.
2026-09-10 10:27:07 -07:00
Erosika 6881e4d3fc fix(bot-mode): carry the relay sender into a live Bot Chat turn
When the target Bot Chat is already open on this gateway, the relay handler delivers through
`prompt.submit` with `queued: true`, and that branch dropped the envelope's sender. The model
still saw the text prefix, but the turn reached the agent unattributed, the exact case the
subprocess branch fixes.

The relay handler now stamps the author on the submit as a `DeliveryAuthor`, an in-process object
a JSON client cannot build, so `prompt.submit` accepts it the way it accepts a hosted-room callback
and refuses a dict with error 4124. The busy queue keeps an authored envelope in its own slot, the
drain hands the author to the turn runner, and the runner passes it to an agent that declares the
keyword. A plain prompt after an authored dm carries no author.

Local deliveries to a desktop-owned Bot Chat take the live-owner mailbox instead. The admission
intent and the mailbox record now carry the author, a retry under the same id with a different
author is refused, and the owner gateway hands the author to the turn it runs. Isolated compute
turns still run unattributed, because the compute-host frame has no author field.
2026-09-10 10:27:07 -07:00
Erosika 366f9446c9 fix(agent): pass turn_author only to a run_conversation that accepts it
The gateway turn runner and the quiet one-shot passed `turn_author=` unconditionally. Every
test double and wrapper with an older `run_conversation` signature raised TypeError, which CI
caught across twenty gateway tests. Both call sites now check the callee with the existing
`_accepts_keyword` helper. A human `-Q` turn keeps today's call shape with no author keyword.
2026-09-10 10:27:07 -07:00
Erosika 55b3ea0b11 fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it
again, and the busy path asks a third time. Each call counted one loop-guard event, so a
Telegram bot tripped the budget after a third of the configured messages. The verdict now only
refuses a chat that is cooling down. The ingress gate counts an admitted bot message once.

`parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag,
and returns None for an author with neither id nor name. Names keep format characters and
non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR
before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive
number. Issue numbers move out of code comments.
2026-09-10 10:27:07 -07:00
Erosika 5bf69e963c fix(gateway): loop guard judges the final authz verdict and reads config.yaml
#91483 added the guard inside the ALLOW_BOTS block. on today's authz layout
an admitted bot returns from an earlier rule before that block runs, so the
guard never tripped (its own ping-pong test passes turn 21 on this tree).

_is_user_authorized now computes the allowlist verdict first and runs every
admitted bot-authored message through the guard, so bots admitted by a chat
allowlist, allow-all, pairing or delegation are metered too. the budget stays
per conversation. settings move from HERMES_BOT_LOOP_* environment variables
to config.yaml gateway.bot_loop_guard (defaults added to config_defaults),
re-read on every call. an unauthorized bot dm no longer gets a pairing code,
so a cooldown produces no outbound traffic. tests rebuilt against real
SessionSource objects.
2026-09-10 10:27:07 -07:00
69k4xmdfm2-blip 3a37785f7f fix(gateway): add bot-to-bot loop guard for Telegram
Telegram Bot API 10.0 (2026-05-08) lets bots receive messages from other
bots, and the platform ships no loop guard of its own. core.telegram.org
/api/bots/bot-to-bot ("Loop prevention") requires the BOT to make
bot-message handling terminate predictably via dedupe, per-chat rate
limits and maximum interaction depth, and warns that "failure to handle
loops properly may lead to degraded performance or platform
restrictions".

Hermes had no brake at all: TELEGRAM_ALLOW_BOTS in gateway/authz_mixin.py
was a bare `return True` with no counter, window or cooldown.
TELEGRAM_ALLOW_BOTS=mentions does not help, because when bot A replies to
bot B the reply itself satisfies the mention test, so every turn re-arms
the peer. Observed 2026-08-21: two Hermes bots exchanged 132 messages in
one group before a human intervened.

This adds gateway/bot_loop_guard.py, a thread-safe sliding-window budget
with cooldown and sweeping of stale buckets, wired into
_is_user_authorized. Defaults (20 events / 60 s window / 60 s cooldown)
match the OpenClaw reference implementation.

Two placement details that are easy to get wrong:

- The guard runs BEFORE the TELEGRAM_GROUP_ALLOWED_CHATS shortcut. That
  env var returns True for any sender in an allowlisted chat, bots
  included, ~32 lines before the is_bot block, so a guard placed in the
  is_bot block never executes for the one configuration that needs it.

- The budget is keyed per CONVERSATION, not per (sender, receiver). This
  process only sees inbound messages, so the receiver is constant; keying
  on the pair would give each sender its own budget and N bots would need
  N x budget messages to trip a guard meant to cap the whole exchange.

Installations without ALLOW_BOTS are byte-identical in behaviour: the
guard only runs on the source.is_bot branch with ALLOW_BOTS set to
mentions or all. HERMES_BOT_LOOP_PROTECTION=off is a full kill switch.

Adds tests/gateway/test_bot_loop_guard.py (7 cases), each verified
failing against the tree without this patch.
2026-09-10 10:27:07 -07:00
Erosika 0ac631b6c0 feat(api): accept a turn author on session chat and runs; peer dm forwards it
POST /api/sessions/{id}/chat, /chat/stream and /v1/runs take an optional
"author" object. absent or null keeps today's behavior; a non-object is a 400
invalid_author. the value reaches run_conversation as turn_author and nothing
else: it is a claim by an API-key holder and grants no capability.

hermes peer dm and peer run add the author to the request body when the
message_agent runner launched them with HERMES_TURN_AUTHOR set, so a
cross-machine dm is attributed the same way a local one is.
2026-09-10 10:27:07 -07:00
Erosika 16d15869ba feat(bot-mode): carry the sender through local and relay deliveries
a bot dm arrived as an ordinary user message. the only trace of the sender
was the "Message from" text prefix, which the model reads and nothing else
does. the recipient's memory provider saw its own configured user.

message_agent now passes the sender as {"id": "bot:<profile>", "name":
<handle>, "is_bot": true} to the delivery runner (--author <json>), which sets
HERMES_TURN_AUTHOR on the recipient one-shot only. the -Q turn reads it and
passes turn_author into run_conversation. the desktop relay forwards the
envelope's from_profile/from_handle to bot_relay.deliver, which sets the same
variable on its delivery turn. the runner drops any inherited author first so
a delivery without one stays unattributed. the text prefix is unchanged.
2026-09-10 10:27:07 -07:00
Erosika a40deb0301 feat(gateway): bust the cached agent on provider-declared identity values
_extract_cache_busting_config knew one memory provider by name and imported
its config class to read identity keys. it now also asks the configured
provider for identity_signature() through plugins.memory.load_memory_provider
and merges the result under "memory.<key>". the hardcoded block stays until
that provider implements the hook.
2026-09-10 10:27:07 -07:00
Erosika 8969511209 feat(memory): carry the turn's author to sync_turn
on_turn_start already received the author trio. sync_turn did not, so a
provider that wanted to write the turn under its author had to stash state
between the two hooks. sync_turn now takes turn_author as a keyword-only
argument, and MemoryManager sends it only to providers whose signature
accepts it, so existing providers keep working unchanged.

build_turn_context resets the author on the agent at the start of every turn
so a cached gateway agent never carries a bot author into the next human
turn. agent/turn_author.py holds the parsing and the HERMES_TURN_AUTHOR
carrier.

MemoryProvider.identity_signature() is a new optional hook: the identity
values a provider writes under, declared by the provider itself, for the
gateway's agent cache to key on.
2026-09-10 10:27:07 -07:00
Erosika 70b1ff6930 feat(memory): carry the turn's author into the memory-provider contract
`on_turn_start` documents a per-turn kwargs channel — "kwargs may include:
remaining_tokens, model, platform, tool_count" — and `MemoryManager`
forwards whatever it receives. Its only caller passed nothing, so a memory
provider had no way to learn who wrote the turn it was being told about.

Providers that key durable state on identity resolve one identity when the
session is created. A shared session does not work that way: threads are
shared by default (`thread_sessions_per_user` is False), so alice, bob, and
another agent all write turns into a session whose peer is whoever spoke
first. The gateway's answer today is the `[name]` prefix it prepends to the
message text, which the model reads and a provider cannot.

`turn_author` now travels from the gateway through `run_conversation` into
`build_turn_context`, which forwards `author_id`, `author_name`, and
`author_is_bot` to every provider. It stops there — the trio never reaches
the model, and providers that ignore the kwargs are unaffected.

The bot flag is sent on every transport, not only shared sessions: a
provider deciding whether a turn may write to durable memory needs it in a
DM too.

`SessionSource.is_bot` is only as good as its producers. `build_source`
defaults it to False and 3 of 32 adapter call sites pass it, so most
platforms still report every author as human. Populating the rest is
follow-up work; nothing here depends on the flag being right yet.
2026-09-10 10:27:07 -07:00
Teknium ac07e20407 fix: resolve subagent control authority from the live session slot
Subagent list/tail/steer/interrupt authorized against a per-record copy of
the owning session's transport (`owner_transport`). That copy had to be
re-synced at every reattach site; `_rebind_live_transport` did it for
session.resume/activate but prompt.submit and the queued-prompt drain still
attached bare, so a client that reconnected through a prompt (the common
path on a remote gateway / Bot Mode switch) streamed fine while
`subagent.list` returned [] and controls rejected.

Read `owner_session_record["transport"]` at check time instead: the slot is
already mutated by every attach/detach/viewer-failover path, so no site can
forget the sync. `owner_transport` stays as the capture-time "commissioned
by a gateway session" marker (None = no RPC authority ever); non-dict owners
keep the exact-object rule. Drops the registration-time re-read and the
attach-time registry loop.

Diagnosis credit: nftpoetrist (#106663) — their prompt.submit / drain
regression tests pass against this change with no call-site edits.
2026-09-10 10:23:12 -07:00
Erosika 37eb6e1b05 test(bot-mode): pin the waiter budget from Python constants, not relay.ts text
The waiter budget test read relay.ts with a regex, which AGENTS.md bans and which the Python CI lane would not rerun on an apps/-only PR. It now checks DESKTOP_DELIVER_TIMEOUT_SECONDS against the module's own constants and that REPLY_WAIT_SECONDS exceeds it. relay-deliver-budget.test.ts still pins the TS mirrors against those Python constants from the Desktop side.
2026-09-10 10:18:27 -07:00
Erosika b1915cb02d fix(bot-mode): keep the relay waiter watching past the Desktop deliver deadline
The sender-side waiter gave up at 900s while the Desktop held bot_relay.deliver open for 1500s, so a turn finishing between minute 15 and minute 25 wrote a reply nobody read. REPLY_WAIT_SECONDS now rebuilds the Desktop budget from the same numbers and waits 60s past it. The two turn constants move into tools/bot_relay.py so the gateway handler and the waiter share one definition.
2026-09-10 10:18:27 -07:00
hermes-seaeye[bot] 8defaaad48 fmt(js): npm run fix on merge (#107504)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 16:49:07 +00:00
fangliquanflq 0596631bf3 fix(desktop): scope tour responses to active session 2026-09-10 09:40:01 -07:00
fangliquanflq 3c823f40b5 fix(preview): scope action responses to active session 2026-09-10 09:40:01 -07:00
Teknium 50ea107d9d fix(desktop): read an auto-TTS reply aloud once when the HUD is open
With `voice.auto_tts` on and HUD mode active, a reply could be spoken
twice: the HUD renderer and the hidden app window both claim
`speak:<messageId>` through `hermes:ambient:claim`, and main collapsed
the two claims with the same 1 s deduper it uses for the turn-end beep.
The app window under the HUD is throttled by Chromium, so its transcript
subscription fires well past 1 s, finds the key pruned, and is told it
owns the cue. Fixes #99717.

The unit was wrong, not the window: a spoken reply is minutes of audio
keyed by a durable message id. `createAmbientClaimArbiter` keeps the
tick-sized window for `sound:*` and holds `speak:*` claims for a
10-minute TTL. No HUD special-casing: the same race exists between any
two windows showing the same chat, and the arbiter fixes them all.

Diagnosis credit: #99810 (@liuhao1024) and #100289 (@shivamjg101),
whose TTL framing this follows.
2026-09-10 09:38:07 -07:00
Konstantin Khlopkov 62081b87b7 fix(desktop): give the Hyprland HUD float/pin promotion room for a lagging j/clients
The first map on Hyprland can leave the HUD tiled when `j/clients` does not
list the new client within the 8 × 50 ms the promotion loop allowed; the
window then stays a stretched transparent tile (#103091). Widen the budget to
24 × 100 ms. The loop returns on the first successful float+pin, so a prompt
compositor pays nothing extra.

Salvaged from #103116. Its second half (re-promote from focusWindow's
re-show branch) is dropped: the HUD is destroyed and respawned on every
toggle and is not minimizable, so that branch never runs for it.
2026-09-10 09:37:47 -07:00
Teknium 7617a91d44 test(desktop): trim HUD band tests to the two invariants
Keep the row-shape contract (a TurnRow carrying aui_message-group measures;
clearance/background-resume spacers do not) and drop the two cases that
only re-assert the selector's shape.
2026-09-10 09:37:30 -07:00
KoNit-K 84a07e7683 fix(desktop): match HUD transcript rows with aui_message-group slot
TurnRow roots now carry data-slot="aui_message-group", so the band's
*:not([data-slot]) selector measured an empty short session as 0px.
Include message-group rows while keeping the slot-less "show earlier" path.

Fixes #107050

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:37:30 -07:00
Teknium 073c57872a feat(models): DeepSeek V4.1 Flash on the Nous Portal and OpenRouter pickers
Add deepseek/deepseek-v4.1-flash to OPENROUTER_MODELS (Nous list derives from it),
regenerate the docs manifest, and give the slug its own 1M context entry and 600s
reasoning-stale floor — the longest-key-first scan otherwise lands the new slug on
the 128K `deepseek` catch-all and no floor. Live probed on both routes: echoed
model matches, usage.cost billed.
2026-09-10 09:22:31 -07:00
Teknium ee35a4624f fix(routing): address review — session runtime decides, quarantine continues the chain, unclassified ids break ownership
Three defects found in review of the first head (@ehz0ah):

* The mid-request hop read the PERSISTED provider from disk. A live
  `/model xai-oauth` session over `model.provider: auto` therefore still
  reached the discovery chain and billed Nous. _try_payment_fallback now
  takes the route's main_runtime snapshot; disk is the fallback only when
  no session runtime exists.

* After a configured fallback was quarantined mid-request (401, refresh
  failed), the second pass went straight to the discovery chain, which
  the new gate refuses — so later CONFIGURED entries never ran and the
  original error was re-raised. The second pass now re-walks the task
  chain and main chain (the quarantined entry is unhealthy and skipped)
  before discovery.

* current_provider_owns_vendor dropped ids detect_vendor could not
  classify, so Bedrock's 15-id catalog (14 unclassified `us.anthropic…`)
  looked exclusively DeepSeek and `/model deepseek-v4-pro` stuck on
  Bedrock. An unclassified id now counts as evidence of a multi-vendor
  catalog: ownership requires every id to classify to the one vendor.
2026-09-10 09:11:00 -07:00
Teknium 9a3a15c87a fix(models): a vendor's own id on its first-party provider never re-routes
215fd0ecb9 made the current provider's live catalog outrank static
guesses, but a live catalog that could not be fetched (Codex outage, cold
cache) looked identical to "not served", so `/model gpt-6-astra` on
openai-codex still walked to OpenRouter (which relists every vendor)
whenever an OpenRouter key existed. Same shape for grok-* on xai-oauth
and claude-* on anthropic.

detect_provider_for_model now treats a vendor id typed on that vendor's
single-vendor first-party provider as a selection: stay, let the vendor
accept or reject it (the existing "not found in listing" note still
fires). Cross-vendor remaps (claude id on Codex -> keyed OpenRouter) and
aggregator / custom / multi-vendor reseller sessions are unchanged.
2026-09-10 09:11:00 -07:00
Teknium 7e0b5cd235 fix(auxiliary): auto never bills a provider the user did not select
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.
2026-09-10 09:11:00 -07:00
Teknium 2feb546828 feat(skills): archify joins the optional-skills catalog as an upstream-maintained stub
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.
2026-09-10 09:07:35 -07:00
jonny 3868ee9a12 Merge pull request #107239 from KoNit-K/fix/extension-titlebar-hide-fixed-clusters
fix(desktop): hide fixed titlebar clusters on contributed full pages
2026-09-10 17:55:11 +02:00
hermes-seaeye[bot] 110736c0bc fmt(js): npm run fix on merge (#107454)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 15:26:17 +00:00
Teknium 248ff2d3e8 feat(desktop): one row per plugin; desktop halves are app-level copies, never profile-scoped
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.
2026-09-10 08:19:55 -07:00
Teknium ff93f0ee9b fix(desktop): desktop plugins are app-level, not per profile; catalog gets a drag sash; Accent Picker moves to its own repo
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.
2026-09-10 08:19:55 -07:00
Teknium 8092f0e8ee fix(desktop): leaving HUD mode always gives the app window back (macOS)
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.
2026-09-10 08:19:13 -07:00
Teknium 67764dc086 test(desktop): poll the autosave test with vi.waitFor under fake timers
config-settings.test.tsx runs vi.useFakeTimers({ shouldAdvanceTime: true })
and then polled with RTL's waitFor, whose interval/timeout also run on the
faked clock. On a loaded CI runner the interplay of real-time advance and
the faked poll interval starved the poll and the file timed out at 15s
(seen on a PR that never touched this file). vi.waitFor advances the fake
timers on every check, which is what the test needs.
2026-09-10 05:09:15 -07:00
Teknium b6bf9700a9 test(desktop): type the hoisted requestGateway mock so the pin test typechecks
CI runs tsc on the test tree; the hoisted vi.fn had a zero-arg signature so
mockImplementation with a (method) callback was rejected.
2026-09-10 05:09:15 -07:00