Compare commits

...

307 Commits

Author SHA1 Message Date
Ben
c9f3bbed60 docs(assets): add NS-501 multipart upload infographic 2026-06-17 15:34:10 +10:00
Ben
547376f3a8 fix(dashboard): stream file uploads via multipart instead of base64 JSON
The dashboard file manager uploaded files (including backup/restore zip
archives) by reading them client-side with FileReader.readAsDataURL and
POSTing a base64 data URL inside a JSON body to /api/files/upload. For a
large backup this (a) inflates the payload ~33%, (b) buffers the whole
file plus its decoded copy in memory, and (c) reliably trips an upstream
proxy body-size/timeout limit, surfacing as a 502 with the upload
appearing to hang indefinitely (NS-501). Dashboard-only hosted users have
no shell fallback to place the archive, so backup restore was unusable.

Add a streaming multipart endpoint POST /api/files/upload-stream
(UploadFile + Form) that reads the request body in 1 MiB chunks straight
to a sibling temp file, enforces the existing 100 MB size cap as it
streams (413 on overflow, before buffering the whole file), and
atomically renames into place so a partial/aborted/over-limit upload
never clobbers an existing file. The frontend api.uploadFile now sends
multipart/form-data (raw bytes, no base64, browser-set boundary) and
FilesPage passes the File object directly; the dead readAsDataUrl helper
is removed. The legacy base64 JSON endpoint stays for backward compat.

FastAPI's UploadFile/Form require python-multipart, which is NOT pulled in
by fastapi itself, so it is added to the base deps, the [web] extra, and
the tool.dashboard lazy-install set (kept in sync).

Validated: 5 new endpoint tests (roundtrip, multi-chunk >1 MiB,
over-limit 413 without clobbering + no temp-file leak, overwrite=false
conflict, forced-root traversal containment); existing base64 tests still
pass; web typecheck + vite build clean; and a real uvicorn server E2E
(5 MB multipart upload -> HTTP 200 in 0.21s, exact byte match) plus a
30 MB TestClient roundtrip confirm constant-memory streaming end to end.

Reported via beta (NS-501).
2026-06-17 14:23:03 +10:00
Bartok
5e01a5dbf1 fix(cli): detect containerd/CRI cgroup-v2 containers in is_container() (#47131)
Closes #47111

is_container() only recognized Docker (/.dockerenv), Podman
(/run/.containerenv), and docker/podman/lxc markers in /proc/1/cgroup.
Under cgroup v2 (Kubernetes/k3s on containerd or CRI-O) /proc/1/cgroup
collapses to a single "0::/" line with no runtime marker, so
is_container() returned False on every containerd/CRI pod.

That false negative bypassed container-aware behavior across the CLI.
The most damaging case (reported): even after #46290 fixed
detect_service_manager() to gate on _s6_running() alone, other
is_container() call sites (profile home resolution, gateway behaviors,
config, doctor) still misbehave on containerd.

Broaden detection conservatively:
- KUBERNETES_SERVICE_HOST env var (present in every k8s pod).
- kubepods/containerd/crio markers in /proc/1/cgroup (cgroup v1 nested).
- same markers in /proc/self/mountinfo as a cgroup-v2 fallback.

Tests: 3 new (k8s env, kubepods cgroup, cgroup-v2-via-mountinfo) plus the
existing negative case hardened to stub mountinfo + env; 108 constants +
service_manager tests pass.
2026-06-17 12:11:31 +10:00
teknium
36ae958473 feat(gateway): gate message timestamps behind opt-in (default off)
Follow-up to salvaged PR #41633: the timestamp prefix injection was
unconditional. Gate the in-context render behind
gateway.message_timestamps.enabled (default false) at both the live-message
and history-replay sites; timestamp metadata is still captured + persisted
regardless so the toggle can be flipped on later. Add DEFAULT_CONFIG entry,
docs, and gate tests.
2026-06-16 15:49:59 -07:00
Wolfram Ravenwolf
bd7fc8fdcd feat(gateway): inject stable human-readable message timestamps
Consolidates these related Amy fork patches:
- 429830f39 feat(gateway): inject message timestamps into user messages for LLM context
- 3c3d6fac0 fix: handle both ISO string and epoch float timestamps in history replay
- 2874f7725 feat: human-friendly timestamp format with weekday and timezone name
- 3735f4c8b fix: render gateway message timestamps once
2026-06-16 15:49:59 -07:00
brooklyn!
b7f0c9cd52 fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
2026-06-16 16:22:09 -05:00
xxxigm
d1ecebcbfd fix(desktop): re-download Electron binary via mirror when pack fails (#47266) (#47276)
* fix(desktop): re-download Electron binary via mirror when pack fails (#47266)

Since #38673 pinned build.electronDist to node_modules/electron/dist,
electron-builder reads the Electron binary straight from there and never
downloads it during `npm run pack`. That dist tree is only produced by the
electron package's postinstall (install.js) during `npm ci`. When that
download is blocked or throttled (GitHub's release host is unreachable in
some regions), the dist is missing and the build dies with:

    The specified electronDist does not exist: .../node_modules/electron/dist

The existing ELECTRON_MIRROR fallback in all three desktop-build paths
(scripts/install.ps1, scripts/install.sh, and `hermes desktop` in
hermes_cli/main.py) re-ran `npm run pack` with ELECTRON_MIRROR set — but
pack never downloads Electron anymore, so the mirror was never used and the
retry re-read the same missing dist. The fallback was effectively dead.

Drive the mirror through electron's own downloader instead:

- Add a dist-presence check + a downloader helper (Test-ElectronDist /
  Restore-ElectronDist, _electron_dist_ok / _restore_electron_dist,
  _electron_dist_ok / _redownload_electron_dist) that wipes a partial dist
  + the path.txt version marker (electron's install.js short-circuits on it)
  and re-runs `node install.js`, optionally via a mirror.
- On the first retry, repopulate a missing dist from the canonical source;
  on the mirror retry, re-fetch through npmmirror.com, then pack.
- Gate the re-download on the dist check so an unrelated build failure
  (tsc/vite) doesn't trigger a pointless ~200 MB refetch, and skip the final
  pack when the binary still can't be fetched instead of failing the same way.

* test(desktop): cover Electron dist re-download mirror fallback (#47266)

Add behavior coverage for the electronDist re-download fix:

- _electron_dist_ok across linux/win32/darwin, including the partial-dist
  case (dir present but binary missing) that makes the pinned electronDist
  fail.
- _redownload_electron_dist: no-op when the binary is present, bail when
  install.js is absent, wipe a stale dist + path.txt marker and run
  electron's downloader with ELECTRON_MIRROR injected, and report failure
  when the download still produces no binary.
- `hermes desktop`: the mirror fallback now drives electron's own downloader
  before re-running pack, and skips the final pack entirely when the binary
  can't be fetched.

Replaces the old mirror test that asserted the (now-fixed) dead behavior of
re-running `npm run pack` with ELECTRON_MIRROR set — pack never downloads
Electron under the pinned electronDist, so that retry could never help.
2026-06-16 15:40:55 -05:00
teknium1
db44af004c test(model-picker): cover two overlapping user-defined custom providers
Guards that two user-defined custom endpoints exposing an overlapping
model each keep their full catalog — the dedup must never cross-filter
two user-defined rows against each other.
2026-06-16 13:09:40 -07:00
liuhao1024
1b962f001e fix(models): pass model.base_url to fetch_models in /model picker
The /model interactive picker resolved a base_url from user credentials
but never passed it to ProviderProfile.fetch_models(), causing the
picker to always query the provider's hardcoded default endpoint
instead of the user's custom URL (e.g. a company litellm proxy).

- providers/base.py: add optional base_url parameter to fetch_models()
- hermes_cli/models.py: pass resolved base_url to fetch_models()
- Update all subclass overrides for signature compatibility
- Add 6 regression tests covering override, fallback, and integration
2026-06-16 13:09:40 -07:00
Wolfram Ravenwolf
9137b86a52 fix(skills): ignore support docs in skill discovery
Support files under references/, templates/, assets/, and scripts/ are progressive-disclosure data loaded through skill_view(..., file_path=...). They should not be treated as standalone skills during discovery or collision checks.

This prevents archived skill packages or support markdown files inside a real skill from shadowing active skills with the same name while still allowing top-level categories named scripts/templates/assets/references.

Tests cover:
- pruning nested SKILL.md files inside skill support directories
- preserving support-named top-level categories
- avoiding skill_view collisions from support markdown
- keeping archived package SKILL.md files accessible only through file_path
2026-06-16 13:08:34 -07:00
teknium1
7493de7fc3 test(model-switch): cover section-3 no-auth probe; map chimpera author
Salvage follow-up for PR #29575: add regression tests for the section-3
no-api_key /v1/models probe (probes bare endpoints, skips when explicit
models set) and add the contributor AUTHOR_MAP entry.
2026-06-16 13:07:52 -07:00
chimpera
1039e90b5e fix(model-switch): probe /v1/models for providers without api_key
Section 3 of list_authenticated_providers (user-defined endpoints from
the providers: config section) required an api_key before probing the
endpoint's /v1/models for live model discovery. This broke local
self-hosted backends (llama.cpp, Ollama, vLLM, etc.) that don't require
authentication — they would only ever show the single default_model
from config instead of the full model catalog.

Section 4 (custom_providers list) already handled this correctly with
the policy: probe when api_key is set OR when no explicit models are
configured. Apply the same logic to Section 3 so local backends get
full model discovery without requiring a placeholder api_key workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 13:07:52 -07:00
teknium1
8ed16a7a0c test(telegram): rich-reply recovery via send-time index
Cover #47375 fix: record-on-rich-send + lookup-on-reply round trip,
lookup miss leaving reply_to_text None, and precedence (native quote
and echoed caption both win over the index fallback).
2026-06-16 13:04:20 -07:00
teknium1
3f80bcac56 chore(release): AUTHOR_MAP entry for x1erra (Sierra) 2026-06-16 13:04:20 -07:00
Sierra (Hermes Agent)
01ae9b853e fix(telegram): resolve replies to rich (sendRichMessage) messages
Telegram does not echo a sendRichMessage's content back in
reply_to_message (.text/.caption empty, .api_kwargs None), so replies
to rich sends (briefings, the gateway's own rich finals) arrived with
no quotable text and the [Replying to: ...] injection was skipped.

Remember message_id -> text at send time in a best-effort JSON index
(gateway/rich_sent_store.py), and recover it on inbound when text and
caption are both empty. Best-effort and no-throw throughout: any
failure degrades to prior behavior and never breaks a send or message.

Salvaged from #47375 by @x1erra. Dropped the cross-platform run.py
reply-prefix rewrite (out of scope; bloated every reply on every
platform) and scrubbed a docstring reference to an out-of-repo script.
Kept the inbound reply_to logging enrichment used to verify the fix.
2026-06-16 13:04:20 -07:00
teknium1
db01910e3a chore(release): map cyb0rgk1tty noreply email for AUTHOR_MAP
Salvage follow-up for PR #46921 — CI matches contributor authorship on the
commit email, which is the GitHub noreply form.
2026-06-16 13:04:07 -07:00
cyb0rgk1tty
b7fa62c530 fix(inventory): keep user-defined custom providers in model dedup
The #45954 model-dedup builds `user_models` from every is_user_defined
row, then strips those model IDs from every row where is_aggregator(slug)
is True. But is_aggregator() returns True for *every* `custom:*` slug, and
list_authenticated_providers emits named custom providers with slug
`custom:<name>` and is_user_defined=True. So a user's own custom provider
is treated as an aggregator and filtered against user_models — which holds
exactly its own models (the row helped build that set). Every model is
removed, the row drops to zero, and the provider disappears from the model
picker.

Guard the dedup loop to skip is_user_defined rows: a user's configured
provider is never an aggregator duplicate of itself. Built-in aggregators
(openrouter, etc.) are still deduped as before. Adds a regression test.
2026-06-16 13:04:07 -07:00
Jaaneek
f4ef70f6fc docs(xai): update default model references to grok-build-0.1
Reflect the default-model change in the xAI Grok OAuth guide, the web
search docs (EN + zh-Hans), and the web provider docstring. grok-4.3 is
kept in the model tables as the previous default; the Nous/OpenRouter
aggregator catalog still lists grok-4.3 and is left unchanged.
2026-06-16 11:50:17 -07:00
Jaaneek
bbc842d31e feat(xai): default to grok-build-0.1
Switch the default model for the xAI/Grok provider and the xAI web
search backend from grok-4.3 to grok-build-0.1. grok-build-0.1 is
already recognized by the model metadata, so no new model definition
is required; grok-4.3 remains selectable.
2026-06-16 11:50:17 -07:00
teknium
28f92478e3 test(hooks): cover session:compress event; drop dead import
Follow-up to salvaged PR #41624:
- Remove stray urllib.parse import in run_agent.py (cherry-pick cruft, unused)
- Add tests: session:compress emits with correct context, no-callback is
  safe, and a callback exception does not break compression
2026-06-16 11:45:36 -07:00
Wolfram Ravenwolf
e76e7b5073 feat(hooks): session:compress event_callback for MemPalace sync 2026-06-16 11:45:36 -07:00
kshitij
8fa562a399 Merge pull request #47391 from kshitijk4poor/feat/add-glm-5.2
feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
2026-06-17 00:02:05 +05:30
brooklyn!
44e5848e74 feat(desktop): stream subagent activity into watch windows (#47060)
* feat(desktop): stream subagent replies into watch windows

A desktop watch window resumes a child session lazily (no full agent) and
mirrors the parent-relayed `subagent.*` events into native child-session
stream events. The child's streamed reply text was never relayed, so the
window sat blank while the subagent "talked".

- delegate_tool: forward the child's `run_conversation` stream tokens up the
  progress relay as `subagent.text` (inert under CLI/TUI — their progress
  handlers ignore non-tool event types; only a gateway watch window mirrors it).
- server: mirror `subagent.text` -> `message.delta` on the child sid only, and
  skip the parent emit (per-token frames are meaningless on the parent session,
  which shows the child via the spawn tree). Demote `subagent.start` to a
  one-time goal header and drop the noisy `subagent.progress` mirror — tools
  already mirror natively.
- server: guard `_start_agent_build` so a lazy watch session spectating an
  in-flight child stays lazy; incidental RPCs were upgrading it to a full
  agent mid-stream and silently killing the mirror.

* fix(desktop): keep watch-window chat clear of titlebar chrome

Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
hide the titlebar tool cluster + session header, so the transcript ran to the
window's top edge and streamed text slid up under the OS traffic lights.

- Gate the hidden chrome on `isSecondaryWindow()` everywhere (app-shell,
  chat header, thread list) instead of the narrower new-session flag.
- Add a fixed opaque drag-strip at the top of the secondary-window transcript:
  content padding alone scrolls away with the text, so the strip masks
  anything behind it and keeps the window draggable like the main header.

* fix: WSL subagent window

* fix: subagent window top padding

---------

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-16 14:30:11 -04:00
teknium
6ebc449915 fix(prompt): isolate truncation warnings per context
Follow-up to salvaged PR #41619: replace the module-global
_truncation_warnings list with a contextvars.ContextVar so concurrent
gateway-session prompt builds can't drain or clear each other's pending
warnings (cross-session leak). Adds a context-isolation test.
2026-06-16 11:28:35 -07:00
Wolfram Ravenwolf
f6a42b1acf feat(prompt): make context-file truncation limit configurable
PROBLEM: Automatic context files such as SOUL.md and AGENTS.md were capped by a hardcoded CONTEXT_FILE_MAX_CHARS value. Amy's local fork had raised that constant from 20K to 25K so a larger SOUL.md would not be silently truncated, but the hardcoded 25K value changed upstream default behavior and made the patch less generally useful.

SOLUTION: Restore the upstream-compatible 20K default, add a context_file_max_chars config setting for users who intentionally keep larger identity/project-context files, keep chat-visible truncation warnings, and document the new setting. Tests cover the default, config override, explicit max_chars precedence, and the warning text.
2026-06-16 11:28:35 -07:00
kshitijk4poor
b2da39a0f3 feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
Z.ai released GLM 5.2 on 2026-06-15, available on OpenRouter:
  - https://openrouter.ai/z-ai/glm-5.2

GLM-5.2 is Z.ai's flagship for long-horizon tasks, shipping a 1M-token
context window (up from 200K on GLM 5.1) and tool calling. Per the
OpenRouter API: text-only, context_length 1048576, tools supported.
No separate -fast variant exists.

The 1M context length, native zai picker entry, setup wizard, and Z.ai
coding-plan auth entries for glm-5.2 already landed on main. This fills
the remaining gap: the two aggregator surfaces where glm-5.1 appears but
glm-5.2 did not.

Changes:

  hermes_cli/models.py
    - Add z-ai/glm-5.2 to the OpenRouter fallback snapshot (OPENROUTER_MODELS)
      and the Nous Portal curated list (_PROVIDER_MODELS["nous"]), newest
      flagship first. Live catalogs surface it automatically when reachable;
      the fallback lists matter when the manifest fetch fails.

  website/static/api/model-catalog.json
    - Regenerated via scripts/build_model_catalog.py (not hand-edited) so the
      manifest stays in sync with the source lists; guarded by
      tests/hermes_cli/test_model_catalog.py.
2026-06-16 23:35:45 +05:30
kshitij
17251e865b Merge pull request #46857 from liuhao1024/fix/model-picker-merge-live-static
fix(models): merge live API results with curated static catalog in generic provider path
2026-06-16 23:30:34 +05:30
kshitijk4poor
658ac1d866 fix(models): keep curated-first ordering in live+curated merge; use pure-catalog helper in validation
The generic live+curated merge (commit 630b438) seeded the merged list
from live results, demoting curated-only models below live ones. That
regressed #46309, which deliberately surfaces the newest curated model
(kimi-k2.7-code) FIRST in the native picker even when the live /models
listing lags. Restore curated-first ordering: curated entries lead (in
catalog order), live-only entries are appended for discovery. This keeps
the #46850 fix (zai glm-5.2 now appears) without the kimi regression.

Also switch the validate_requested_model curated fallback (commit
ee7b8a4) from provider_model_ids() — which triggers a second, uncached
live /models fetch with its own 8s timeout and may resolve different
credentials than the api_key/base_url just probed — to the pure-catalog
helper _model_in_provider_catalog(). Membership is checked against the
shipped catalog only, with no extra network call.

Tests: restore the curated-first assertion in
test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code; update
the new merge tests to curated-first semantics; de-circularize the
validation fallback tests to patch _PROVIDER_MODELS (the real source)
instead of mocking the function under test.
2026-06-16 23:25:07 +05:30
Teknium
c2c55c4443 fix(memory): strip skill scaffolding for all providers, not just openviking
Generalizes #32663 (@ehz0ah). The slash-skill scaffolding pollution
affected every auto-syncing memory provider — mem0, hindsight, retaindb,
byterover, honcho, supermemory all store/embed the raw user turn, so a
/skill invocation poisoned their stores with the full skill body, not just
openviking.

- Lift the contributor's parser into agent/skill_commands.py as the canonical
  extract_user_instruction_from_skill_message(), co-located with the message
  builders so the markers can't drift.
- Strip once in MemoryManager.{prefetch_all,queue_prefetch_all,sync_all} —
  fixes the whole provider fan-out, bare /skill turns are skipped entirely.
- OpenViking's _derive_openviking_user_text() now delegates to the shared
  helper as defense-in-depth (no duplicated marker literals).
- Marker-drift regression now asserts against the canonical skill_commands
  constants; add manager-level coverage proving every provider gets clean text.
2026-06-16 10:37:37 -07:00
Hao Zhe
e3adbb5ae9 fix(openviking): sanitize skill memory input 2026-06-16 10:37:37 -07:00
teknium1
e236bb87eb docs(skills): regenerate shop skill page after shop-app rename 2026-06-16 10:37:21 -07:00
teknium1
cf52370253 chore(release): AUTHOR_MAP entry for Joe Rinaldi Johnson 2026-06-16 10:37:21 -07:00
teknium1
d7668aaff5 chore(skills/shop): tighten description to ≤60 chars, credit contributor 2026-06-16 10:37:21 -07:00
Joe Rinaldi Johnson
5094325140 feat(skills): replace shop-app with CLI-based shop skill (v1.0.1)
Rewrites the Shop personal-shopping-assistant skill to use the
@shopify/shop-cli (with a full direct-API fallback in references/),
replacing the previous curl-only shop-app skill.

- Rename optional-skills/productivity/shop-app -> shop
- Add references/: catalog-mcp.md, direct-api.md, safety.md, legal.md
- Catalog discovery via Shopify Global Catalog MCP (search / lookup /
  get-product), device-authorization sign-in, UCP agent checkout with
  delegated spending budget, and order tracking / returns / reorder
- One-product-per-message presentation rules + per-channel overrides
- Expanded security, safety, and legal guidance

Website docs are auto-generated from SKILL.md by CI
(website/scripts/generate-skill-docs.py), so no docs are hand-edited here.
2026-06-16 10:37:21 -07:00
brooklyn!
c6e99ab375 Merge pull request #46959 from NousResearch/bb/composer-model-selector
feat(desktop): composer model selector, per-model presets & external-provider disconnect
2026-06-16 09:55:57 -05:00
Brooklyn Nicholson
80e4b8985e feat(desktop): tighten composer model picker interactions
Clicking a model row in the composer dropdown now commits and closes the menu
(via a close context); the hover-revealed reasoning/fast submenu stays open to
tweak. The pill shows a quiet braille loader instead of literal "No model"
until one resolves, and steer takes over the mic slot while typing into a
running agent.
2026-06-16 09:50:27 -05:00
Brooklyn Nicholson
7d938cc5c9 fix(desktop): keep live model switch metadata truthful
A live config.set model switch already moved the next API call to the new model,
but the conversation could still restore an old sessions.system_prompt snapshot
whose Model/Provider lines named the previous runtime. That made "what model are
you?" answer from stale metadata even while inference ran on the new model.

After a live switch we now refresh the stored system prompt and append a real
system-history pivot (not a fake user turn) so the transcript itself records the
new model/provider. Restore also rejects already-stale prompt snapshots when
their Model/Provider lines disagree with the runtime, so existing bad sessions
self-heal.
2026-06-16 09:50:17 -05:00
Brooklyn Nicholson
cb6b4127e7 refactor(desktop): make composer model picker sticky session state
The picker no longer touches the profile default. Model/effort/fast live as
plain UI state persisted in localStorage, so a pick follows across Cmd+N and
restarts instead of snapping back. New chats ship that state through
session.create as per-session overrides; live chats still scope switches to the
current session. Settings -> Model remains the only surface that writes the
profile default.

The gateway now accepts those session.create overrides, builds the agent with
them directly, reflects them in the immediate session.info payload, and writes
the chat's own model_config into the lazy DB row so reconnect/resume restores
that chat instead of the global default.
2026-06-16 09:50:07 -05:00
Teknium
a68ac0c49a feat(desktop): allow /browser connect on a local gateway (#47245)
* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* feat(desktop): allow /browser connect on a local gateway

/browser was hardcoded as terminal-only in the desktop slash palette, so
the chat GUI rejected it with "only available in the terminal interface."
The TUI already drives the live CDP connection via the browser.manage RPC.

Wire the same RPC into the desktop dispatcher as a /browser action handler,
gated to local-gateway connections ($connection.mode !== 'remote'). connect
mutates BROWSER_CDP_URL (and may launch Chrome) in the gateway process, so
it's only meaningful when that process runs on this machine; a remote
gateway gets a clear "local gateway only" message instead.
2026-06-16 09:03:43 -05:00
Wolfram Ravenwolf
16fc717091 fix(mattermost): harden delivery hygiene
PROBLEM: Mattermost threads can become invalid or enormous, exposing two failure modes: internal scratch/reasoning/commentary displays could leak into persistent Mattermost threads via global display toggles, while rejected threaded user-visible replies could disappear unless every failed send fell back flat. A broad flat fallback would pollute channels with tool/status/progress noise.

SOLUTION: Require explicit Mattermost platform opt-in for scratch displays, keep using the existing notify=True metadata marker for user-visible final text/media/file replies, and allow the Mattermost plugin adapter to flat-fallback only notify-worthy sends whose threaded POST failure looks like a broken root/thread. Keep tool/status/progress and other non-notify sends thread-strict. Add regression tests for display opt-in, notify-only broken-thread fallback, generic API failure suppression, and stream notify metadata.

Verification: tests/gateway/test_mattermost.py tests/gateway/test_stream_consumer.py tests/gateway/test_stream_consumer_thread_routing.py tests/gateway/test_stream_consumer_fresh_final.py tests/gateway/test_stream_consumer_draft.py; tests/gateway/test_session_api.py tests/gateway/test_status_command.py tests/gateway/test_resume_command.py tests/hermes_cli/test_commands.py; py_compile touched gateway files; git diff --check.

Session: Mattermost thread 6qg8e9dd1pd9pkhi74xyaa1mry, 2026-06-01.
2026-06-16 06:34:54 -07:00
teknium1
925b0d1ab5 chore: add zimigit2020 to release AUTHOR_MAP 2026-06-16 06:23:53 -07:00
Rory Evans
e65d74bc6f fix(gateway): accept metadata kwarg in WhatsApp/email send_image
`BasePlatformAdapter.send_multiple_images` passes `metadata=metadata` to
`send_image` / `send_image_file` / `send_animation` on every send. The
WhatsApp and email `send_image` overrides stopped their signature at
`reply_to`, so any image delivered as a URL (the common case — image-gen
backends return URLs) raised:

    TypeError: send_image() got an unexpected keyword argument "metadata"

and the image silently failed to send. Their sibling overrides
(`send_image_file` / `send_video` / `send_voice` / `send_document`)
already absorb it via **kwargs, which is why only plain image-URL sends
broke.

- whatsapp/email `send_image`: accept `metadata` (matches the base
  signature); WhatsApp forwards it to the super() text fallback.
- Add `tests/gateway/test_media_metadata_contract.py`: asserts WhatsApp +
  email accept it, plus a best-effort sweep over every adapter so the next
  slip fails at test time instead of in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:23:53 -07:00
Teknium
4858942c55 fix(auxiliary): honor main fallback chain for auto tasks (#47235) 2026-06-16 06:23:24 -07:00
Teknium
4d470b3dbb fix(slack): route /debug via /hermes to restore Telegram-parity (#47248)
Slack caps apps at 50 slash commands and the registry is at that ceiling, so
adding /debug clamped it out of the native list and broke the telegram-parity
test (debug on Telegram, absent from Slack native slashes, in neither
exclusion set). Add 'debug' to _SLACK_VIA_HERMES_ONLY — same treatment credits
already gets. /debug stays native on CLI/TUI/Telegram/Discord and reachable via
/hermes debug on Slack.
2026-06-16 06:20:01 -07:00
Teknium
2483200963 test(tui): isolate session-create no-race test from shard-sibling leakage (#47230)
test_session_create_no_race_keeps_worker_alive flaked on CI shard 3 with
'build thread unregistered its own notify despite no race' while passing
20/20 in isolation locally. Root cause: daemon build threads from sibling
session.create tests in the same shard process mutate the shared
server._sessions dict under _sessions_lock and can replace/pop entries
mid-run, flipping this build thread's 'replaced' check (server.py:1011) to
True and triggering a spurious unregister_gateway_notify.

Fix is test-only: snapshot + clear server._sessions before the request so
the test sees only its own session, restore siblings in finally. Also assert
agent_ready.wait() actually returned True (was silently ignoring timeout) and
bump the timeout 2s -> 10s for loaded CI runners.
2026-06-16 05:56:50 -07:00
teknium1
1ac76a9472 chore: add MrDiamondBallz to release AUTHOR_MAP 2026-06-16 05:56:11 -07:00
MrDiamondBallz
9a59ad73dd fix(auth): preserve Codex pool-only rate-limit state
Classify exhausted pool-only openai-codex credentials as quota/rate-limited instead of missing auth. This prevents auth status and runtime credential resolution from reporting missing credentials when a valid manual:device_code pool credential exists but is temporarily in a 429 usage-limit cooldown.

Adds regression coverage for pool-only Codex auth status and runtime resolution.
2026-06-16 05:56:11 -07:00
teknium
6373aba80f feat(gateway): rename to tool_progress_grouping, add config/docs/tests
Follow-up to salvaged PR #41620:
- Rename tool_progress_style -> tool_progress_grouping (clearer intent)
- Add display.tool_progress_grouping to DEFAULT_CONFIG (accumulate default)
- Document in messaging docs incl. 'separate is noisier, only where progress enabled'
- Add resolver tests (default/global/override/invalid/case)
2026-06-16 05:49:24 -07:00
Wolfram Ravenwolf
fc956b9db6 feat: add tool_progress_style config (accumulate vs separate)
Add display.tool_progress_style setting to control how tool progress
messages are displayed in chat platforms:

- 'accumulate' (default): Edit a single message with all tool calls
  (new v0.9.0 behavior)
- 'separate': Send each tool call as its own message, interleaved
  with thinking messages (pre-v0.9 behavior, better readability)

The setting participates in the per-platform display override system
and can be set globally or per-platform.

Files: gateway/display_config.py, gateway/run.py
2026-06-16 05:49:24 -07:00
teknium
98ae28657f feat(display): document and test memory_notifications setting
Follow-up to salvaged PR #4684:
- Add display.memory_notifications to DEFAULT_CONFIG (off|on|verbose, default on)
- Document the setting in docs/user-guide/features/memory.md
- Add resolver tests for off/on/verbose memory + skill paths
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf
4cf9d80fba feat(display): verbose skill change notifications with content previews
When display.memory_notifications is set to 'verbose', skill_manage
notifications now show meaningful change details instead of just the
generic tool message.

Before (verbose mode):
  💾 📝 Patched SKILL.md in skill 'gogcli' (1 replacement).

After (verbose mode):
  💾 📝 Skill 'gogcli' patched: "old pitfall text..." → "new pitfall text..."

Changes:
- skill_manager_tool.py: _patch_skill() now includes old/new string
  previews (truncated to 200 chars) in the result via '_change' key.
  _create_skill() and _edit_skill() include skill description from
  frontmatter for verbose create/edit notifications.
- run_agent.py: Background review notification builder now reads the
  '_change' dict from skill tool results and formats descriptive
  notifications per action type (patch → old→new diff, create/edit →
  description preview). Falls back to generic message when _change
  data is unavailable (backwards compatible).

This is especially useful when subagents patch skills, since neither
the user nor the parent agent can see what the subagent changed.
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf
20b1f4f3fb feat(memory): configurable background memory update notifications
Background memory reviews now support three notification modes,
configured via display.memory_notifications in config.yaml:

  off     — no chat notification (still logged to stdout/HA log)
  on      — generic '💾 Memory updated' (default, unchanged behavior)
  verbose — content preview with action indicators:
            💾 Memory  Hermes Repo liegt unter /config/amy/hermes-agent/...
            💾 Memory ✏️ Updated repo path from claude-code to hermes-agent...
            💾 Memory  old entry about claude-code path...

Previews are truncated to 120 chars for adds/replaces, 60 for removes.
Each action gets its own line in verbose mode for readability.

Files: run_agent.py, gateway/run.py
2026-06-16 05:45:40 -07:00
Teknium
a6364bfa08 fix(telegram): edit streamed previews in place as rich (Bot API 10.1) (#46890)
Streamed Telegram replies that finalize through editMessageText were
converted to MarkdownV2, which has no table syntax and rewrites pipe
tables into bullet lists — users saw a table while streaming that
collapsed to a list at the last moment.

Finalize now edits the existing preview IN PLACE via Bot API 10.1's
editMessageText rich_message parameter when the content has constructs
the legacy path degrades (tables, task lists, <details>, block math).
No fresh send + delete, so no duplicate-preview flicker — the reason
#46206 reverted the fresh-final re-send path. prefers_fresh_final_streaming
stays False; the in-place edit replaces it.

- _needs_rich_rendering(): rich reserved for table/task-list/details/math
  (adapted from #45995, @YonganZhang); plain replies stay on MarkdownV2.
- _try_edit_rich(): editMessageText + rich_message via do_api_request,
  mirroring _try_send_rich's fallback/latch/transient contract.
- edit_message finalize tries rich in place before the 4,096 overflow
  pre-flight (rich cap is 32,768), falling back to legacy on rejection.
- rich_messages default flipped back to True (DEFAULT_CONFIG + adapter).
- docs (en + zh-Hans) + cli-config example updated to default-on.

Closes the root cause behind #45911 / #46009.
2026-06-16 05:26:04 -07:00
underthestars-zhy
5b3fa26366 fix(photon): unify project identifiers and update documentation for Spectrum provisioning
Co-Authored-By: Marvin <marvin@photon.codes>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:25:56 -07:00
liuhao1024
ee7b8a4672 fix(models): validate_requested_model falls back to curated catalog when live API omits model
When live /v1/models responds but omits a model that exists in the
curated static catalog, validate_requested_model now accepts it with
a note instead of rejecting. This covers the /model slash-command path
(the picker path was already fixed in the parent commit).

Addresses review feedback from potatogim on #46857.
2026-06-16 16:24:11 +08:00
liuhao1024
630b43892d fix(models): merge live API results with curated static catalog in generic provider path
When a provider's live /v1/models endpoint returns a stale or incomplete
list (e.g. Z.AI missing glm-5.2), the generic profile-based code path
returned only the live results, silently dropping curated models.

Generalize the kimi-coding merge pattern to all providers: live entries
come first (provider's preferred order), then curated-only entries are
appended with case-insensitive dedup. This ensures models that the live
endpoint omits still appear in /model picker.

Fixes #46850
2026-06-16 16:21:01 +08:00
Brooklyn Nicholson
dd0e3e0a05 fix(desktop): tighten thread content top padding 2026-06-16 00:08:21 -05:00
Brooklyn Nicholson
a0ec4f52b9 feat(desktop): disconnect external (CLI-managed) providers
External providers (Claude Code) store creds outside Hermes, so the
disconnect API refuses them. The backend now hands the GUI a per-OS
`disconnect_command` that clears the credential the same way the CLI's
logout does (macOS Keychain entry + ~/.claude/.credentials.json), and
the misleading "use claude setup-token" hint is corrected.

Settings → Providers offers a Disconnect button for these: it confirms,
leaves Settings, and runs the removal command in the embedded terminal
via a new runInTerminal() (queues onto $terminalInjection; the terminal
pane flushes and clears it once its session is live). The expanded list
also gets its own "Other providers" header so it no longer reads as
grouped under "Connected". API-managed providers keep the one-click
(trash) disconnect.
2026-06-16 00:08:21 -05:00
Brooklyn Nicholson
0e81d2fb71 feat(desktop): per-model effort/fast presets in the picker
Each model remembers its own reasoning effort / fast mode (localStorage,
like model-visibility): editing a model's effort/fast in the submenu
writes its preset, and selecting a model restores its preset onto the
session (capability-gated, Hermes defaults when unset). Every row shows
its own remembered settings (grayed), and the row label and edit submenu
read the same effective value so they can't disagree.

Presets are desktop-client state only — applyModelPreset() no-ops without
a live session id, so selecting a model can't fall through to the
gateway's persistent agent.reasoning_effort / agent.service_tier writes.
Inactive variant `-fast` edits stay preset-only: toggleFast() records
{ fast } on the base model and only swaps models when the row is active,
and selectFamily() honors a saved variant-fast preset by selecting the
`-fast` sibling id.
2026-06-16 00:08:20 -05:00
Brooklyn Nicholson
989d5d0cb7 fix(desktop): declutter date-pinned model snapshots in the picker
Provider catalogs surface date-pinned snapshots (`…-20251101`) that the
picker rendered as standalone rows with the date baked into the name
("Opus 4 5 20251101"). Strip the trailing date from display names, and
fold a snapshot out of the list when its rolling alias is present so the
alias stays selectable/searchable while the exact dated id isn't shown
as its own row.
2026-06-15 23:53:41 -05:00
Brooklyn Nicholson
c92a95a130 feat(desktop): move model selector from statusbar to composer
Relocate the model pill to the composer, left of the mic. A new
ModelPill reuses the live ModelMenuPanel dropdown verbatim (single
click target) and the formatModelStatusLabel "Model · Fast Med" label,
anchored to its right edge so the menu doesn't drift with model-name
length. modelMenuContent now flows to ChatView instead of
useStatusbarItems, and the status-bar model-summary item is removed;
the pill subscribes to the model atoms directly and falls back to the
full picker when the gateway is closed.
2026-06-15 23:53:41 -05:00
brooklyn!
c6b0eb4de0 fix(desktop): open remote-gateway artifacts via authenticated download (#46895)
On a remote gateway connection, agent-written files live on the gateway
host, not the desktop's disk, so the Artifacts view's file:// hrefs failed
("Invalid external URL") and image thumbnails broke.

Make mediaExternalUrl() remote-aware in one place: in remote mode it
rewrites gateway-local paths to GET /api/files/download (a new endpoint
that streams the file as a Content-Disposition: attachment). The artifacts
view now resolves through it, and so do the existing chat-media and
generated-image callers, for free.

The download endpoint stays auth-gated; auth_middleware additionally
accepts the session token as a ?token= query param for this one path so a
shell/browser-opened download (which can't set the session header) still
authenticates — the same query-token tradeoff as the /api/pty WebSocket.
It is NOT added to PUBLIC_API_PATHS.

Salvages #46663 (which carried ~19k lines of CRLF noise and made the
endpoint public). Reimplemented on a clean LF base with the security hole
closed and tests added.

Co-authored-by: qingshan89 <qs2816661685@gmail.com>
2026-06-15 23:50:19 -05:00
Gille
0441b7f19f fix(desktop): route global remote profile REST calls (#47011)
* fix(desktop): route global remote profile REST calls

* fix(dashboard): scope oauth provider routes by profile

* test(tui): isolate notification poller queue
2026-06-15 23:24:55 -05:00
Shannon Sands
7cd71de1f4 Simplify dashboard update detection to containers 2026-06-15 20:08:39 -07:00
Shannon Sands
b1d6a57883 Detect containerized dashboard update management 2026-06-15 20:08:39 -07:00
Shannon Sands
0b6b29a30c Hide hosted dashboard update controls 2026-06-15 20:08:39 -07:00
brooklyn!
55cb4103be Merge pull request #46951 from NousResearch/bb/new-session-window
feat(desktop): hotkey to open a new session in a compact window
2026-06-15 21:05:30 -05:00
Brooklyn Nicholson
67233d1c2a fix(desktop): sync new sessions across windows
Broadcast session-list mutations from scratch windows so the main sidebar refreshes without manual reloads.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson
0f75e9904a feat(desktop): trim scratch window chrome
Hide nonessential Hermes chrome in the new-session pop-out while preserving native window controls and stable first-message positioning.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson
98c294126b feat(desktop): open new sessions in compact windows
Add the Electron IPC bridge and rebindable shortcut for opening an unkeyed scratch window on the new-session draft.
2026-06-15 20:59:57 -05:00
Teknium
0a8f3e21b8 fix(delegation): forward background flag so delegate_task(background=true) runs async (#46968)
* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* fix(delegation): forward background flag in delegate_task dispatch

delegate_task is an _AGENT_LOOP_TOOLS member, so every surface (CLI,
gateway, desktop/TUI) routes it through AIAgent._dispatch_delegate_task.
That forwarder passed every schema field except background, so
delegate_task(background=true) was silently downgraded to a synchronous
run and returned the sync results payload instead of a delegation_id.

The model sees background in the schema (the call validates), but the
value never reached the function. Add the one missing kwarg so async
background delegation actually engages.
2026-06-15 18:52:02 -07:00
Teknium
2dbc3bd937 fix(skills): guard recursive skill delete against tree-escape (#46929)
Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.
2026-06-15 17:14:59 -07:00
Dominik
9d2ec8d35a Merge pull request #46244 from skyc1e/fix/desktop-explorer-refresh
fix(desktop): keep file tree refresh clickable
2026-06-15 23:52:00 +00:00
brooklyn!
423d24780b Merge pull request #46909 from NousResearch/bb/coalesce-interleaved-reasoning
fix(desktop): coalesce interleaved reasoning/content stream parts
2026-06-15 18:38:24 -05:00
Brooklyn Nicholson
37d717054e refactor(desktop): unify stream-part coalescing into one helper
Collapse segmentMergeIndex + mergeTextInto + the three append helpers
into a single segment-aware appendStreamPart core plus a part-factory
table. Same behavior, DRY.
2026-06-15 18:13:52 -05:00
Brooklyn Nicholson
1cb75b7971 fix(desktop): coalesce interleaved reasoning/content stream parts
Models that interleave their reasoning_content and content token streams
(Kimi/DeepSeek/GLM-style routes) emit text -> reasoning -> text deltas
within a single tool-bounded segment. Appending each delta as its own
part shredded one sentence into "Let me" / Thinking / "verify the file",
with a Thinking disclosure wedged mid-sentence.

Coalesce streaming deltas into the most recent same-type part within the
current segment (bounded by any non-streaming part, e.g. a tool call).
The opposite streaming channel is transparent, so a reasoning burst
between two content deltas no longer opens a fresh text part, while a
real tool call still starts a new segment and preserves narration order.

Data-layer only; the renderer already groups consecutive reasoning.
2026-06-15 17:48:35 -05:00
Teknium
5bfed0fe07 feat(skills): add optional payments skills (Stripe Link, MPP, Projects) (#31343)
* feat(skills): add optional payments skills (Stripe Link, MPP, Projects)

Adds four optional skills under optional-skills/payments/ wrapping the
Stripe Link CLI, the Machine Payments Protocol (MPP) clients, and the
Stripe Projects CLI plugin. Plus a router skill (payments) that picks
between them based on user intent.

All four are gated [linux, macos] — Stripe's Link CLI does not yet
support Windows. The other CLIs (mppx, stripe projects) are
cross-platform on paper but the payments cluster moves as a unit until
Link CLI gains Windows support.

Skills:
- stripe-link-cli  - one-time virtual cards + Shared Payment Tokens
- mpp-agent        - HTTP 402 payments via mppx/Tempo/Privy/AgentCash
- stripe-projects  - provision SaaS services + credential sync
- payments         - router/index skill for the cluster

Hard invariants encoded in every skill:
- Card PANs/wallet keys never enter agent transcripts, logs, or memory
- Spend approvals are not self-bypassable (Link app / wallet UI / CLI prompt)
- Final totals confirmed with user before any --request-approval call
- Credential output files cleaned up after one-time use

Zero core touches. Skills install via:
  hermes skills install official/payments/<skill>

* chore(skills/payments): drop router skill — skills shouldn't depend on other skills

Removed optional-skills/payments/payments/ — the router skill that
existed to hand off between stripe-link-cli, mpp-agent, and
stripe-projects.

Per project convention: skills should be independently loadable; a
router is a footgun because (a) it assumes the loader will follow its
recommendation rather than just loading what the user asked for, and
(b) it duplicates the trigger logic that already lives in each
sub-skill's '## When to Use' section.

The three remaining skills declare their own triggers and routing
hints. The optional-skills catalog still groups them under '## payments',
which is the appropriate place for cluster-level discoverability.

Also drops 'payments' from each remaining skill's 'related_skills' list
and removes the corresponding entries from the docs catalog + sidebars.

* feat(skills/payments): fold in danhill-stripe review feedback

- mpp-agent: add link-cli as a client option (when Link is already set
  up, or the 402 challenge advertises method="stripe")
- stripe-link-cli: reframe Link account / payment method / approval app
  as first-run setup, not hard preconditions (CLI configures them on
  first run)
- regenerate the two affected optional-skills docs pages
2026-06-15 15:28:42 -07:00
Teknium
5a0e0d35b9 fix(mattermost): preserve thread-local delivery hygiene
Salvage the valid thread-routing pieces from #41640:
- route Mattermost progress/status sends through metadata thread IDs
- treat top-level Mattermost channel posts as thread roots for progress
- preserve thread metadata through media/file sends
- allow flat fallback only for final notify-worthy replies on confirmed broken roots

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
2026-06-15 15:06:23 -07:00
kshitij
d2b34e89b0 Merge pull request #44431 from erosika/feat/honcho-identity-tree
feat(honcho): gateway-gated identity tree + canonicalize on pinUserPeer
2026-06-16 03:35:24 +05:30
Erosika
6dde7d4657 docs(memory-providers): cover gateway identity mapping for Honcho
The Honcho provider page documented the per-profile peer model (user
peer / AI peer / observation) but never the gateway axis — how platform
runtime IDs map to peers. Adds the three keys to the config table and a
short Gateway identity mapping subsection that points at the Honcho page
for the resolver ladder.

Uses the corrected pinUserPeer wording (pins non-agent users, overrides
aliases) so the provider-comparison reader gets the same accurate framing
as the dedicated page.
2026-06-15 21:50:24 +00:00
Erosika
c7513df4f9 docs(honcho): clarify pinUserPeer pins only non-agent users
'everyone collapses to your peer' read as a promise about all traffic.
pinUserPeer pins the user-side peer and is checked before userPeerAliases
(session.py:335), so a pin overrides every alias — including agent peers.
For a multi-agent operator that silently pools distinct agents onto one
peer, the opposite of intent.

Scopes the wording to 'every non-agent gateway user', notes the pin
overrides aliases, and points agent-mesh operators at pinUserPeer:false +
userPeerAliases instead. Same correction in the wizard menu/echo text,
the plugin README, and the website Honcho page.
2026-06-15 21:34:09 +00:00
ethernet
062c17d34f Merge pull request #46867 from NousResearch/hermes-always-run
fix(ci): always run pull_request checks
2026-06-15 17:09:36 -04:00
ethernet
e0492aa2dc fix(ci): always run pull_request checks
no waiting for pending forever!
2026-06-15 17:03:55 -04:00
kshitij
cffd6e3c8d Merge pull request #46078 from xxxigm/fix/discord-slash-command-100-cap
fix(discord): cap slash commands at Discord's 100-command limit
2026-06-16 02:05:31 +05:30
Teknium
c66ecf0bc3 feat(delegation): async background subagents via delegate_task(background=true) (#40946)
* feat(delegation): async background subagents via delegate_task(background=true)

delegate_task(background=true) dispatches a subagent that runs in the
background and returns a handle immediately, so the user and model keep
working while it runs. The full result — plus the original task source —
re-enters the conversation as a new turn when the subagent finishes,
riding the same completion-queue rail as terminal background processes.

- tools/async_delegation.py: daemon-executor registry, capacity cap,
  rich self-contained completion event pushed onto the shared
  process_registry.completion_queue (type='async_delegation').
- delegate_tool.py: background param + single-task dispatch branch;
  batch async rejected (v1).
- process_registry.py: format_process_notification renders the rich
  task-source block (goal/context/toolsets/model/status/result).
- gateway/run.py: dedicated _async_delegation_watcher drains + injects
  results into the originating session (idle + post-turn), session_key
  routing enrichment, shutdown interrupt of dangling delegations.
- config: delegation.max_async_children (default 3).

Reuses the existing idle-drain wiring rather than mutating a running
agent loop, preserving message-role alternation and prompt-cache
invariants. 13 targeted tests; CLI + gateway paths E2E-verified.

* test(delegation): make async non-blocking tests environment-independent

CI 'test (5)' flaked on a cold, 8-worker runner: the first
delegate_task(background=true) call measured 2.27s of one-time setup
(config load + child-agent construction + imports), tripping the
elapsed < 1.0 wall-clock assertion. That assertion was testing setup
overhead, not blocking.

Replace the wall-clock thresholds with the real invariant: dispatch
returns while the child is still gated (active_count == 1, completion
queue empty), which a synchronous impl could not do. Keep only a loose
4s sanity backstop well under the runner's 5s gate.

* fix(delegation): harden async background delegation

Follow-up review fixes:
- Detach background child from parent._active_children at dispatch —
  otherwise parent-turn interrupts (Ctrl+C, mid-turn steering), cache
  evicts (release_clients), and session close (/new) kill/close the
  detached subagent mid-run, defeating the point of background mode.
  Lifecycle is owned by the async registry's interrupt_fn.
- Make the capacity check atomic with the record insert (TOCTOU: two
  concurrent dispatches could both pass active_count() and exceed the cap).
- TUI dedup: key async_delegation events by delegation_id — the
  fallthrough keyed them all as ("", type), suppressing every completion
  after the first in the desktop/TUI status feed.
- CLI /stop now interrupts running background delegations and /agents
  lists them (they live outside the process registry and were invisible).
- Drop stray unbalanced ']' line from the re-injection block and the
  unused _ASYNC_DEFAULT import.

Tests: detach-at-dispatch + concurrent-capacity race added (15 total in
test_async_delegation.py); 137 delegate + 140 process-registry/notify/watch
+ 7 TUI dedup tests pass.

* fix(delegation): harden async background completion drains
2026-06-15 13:33:12 -07:00
Austin Pickett
368fcf1ff0 fix(desktop): read HERMES_HOME from the Windows registry when env is stale (#46772)
A GUI app launched from Explorer inherits the environment block captured at
login, so a HERMES_HOME set via 'setx' AFTER login is invisible in process.env
even though the CLI (a fresh shell) sees it. The desktop then silently fell
back to %LOCALAPPDATA%\hermes and reported 'No inference provider configured'
despite a valid configured home (#45471).

resolveHermesHome() now consults the live HKCU\Environment registry value on
Windows before the LOCALAPPDATA default. New windows-user-env.cjs helper parses
'reg query' output, expands %VAR% refs, and fails safe (returns null off-Windows,
on spawn error, or empty value). The registry value is normalized through the
same normalizeHermesHomeRoot() path as the env var for consistency.

Co-authored-by: jeffrobodie-glitch <jeffrobodie@gmail.com>
2026-06-15 15:16:55 -05:00
ethernet
39f479cba8 Merge pull request #46085 from xxxigm/fix/bundled-node-global-npm-path
fix(install): make `npm install -g` packages reachable on PATH
2026-06-15 15:47:54 -04:00
Austin Pickett
ed20f5ed06 fix(desktop): let explicit model switches escape broken config providers (#42241) (#46796)
When a desktop/dashboard session had no agent built yet and the user explicitly
picked a provider in the model picker, config.set('model', ...) would first try
to initialize the agent from the (possibly broken) config default provider —
failing before the user's explicit switch could take effect, trapping them on a
misconfigured default.

config.set now pre-parses the model flags: if an explicit --provider is present
and no agent exists yet, it skips the default-provider agent build and routes
straight through _apply_model_switch with the explicit provider. _apply_model_switch
gained a parsed_flags passthrough (avoids double-parsing) and only falls back to
resolve_runtime_provider(requested=None) when no explicit provider was given.

The desktop hook now sends config.set instead of slash.exec for active-session
model changes, so errors from the selected provider surface to the user instead
of being swallowed.

Co-authored-by: rodboev <rod.boev@gmail.com>
2026-06-15 15:36:51 -04:00
xxxigm
2a08b8c86f test(dump): cover terminal backend override reporting
Verifies `hermes debug` surfaces a TERMINAL_ENV override of
terminal.backend, reports the config value when no override is present,
and emits no spurious note when env and config agree.
2026-06-15 12:31:23 -07:00
xxxigm
b2a4766463 fix(dump): report effective terminal backend in hermes debug
`terminal.backend` in config.yaml is bridged to the TERMINAL_ENV env var,
but a TERMINAL_ENV set in .env / the shell overrides config and is what
terminal_tool actually uses. The dump printed only the config value, so a
user whose agent was jailed in a docker/podman sandbox via a stale
TERMINAL_ENV still saw `terminal: local` — hiding the real cause. Report
the effective backend and flag when TERMINAL_ENV overrides config.yaml.
2026-06-15 12:31:23 -07:00
liuhao1024
60cc42e38b fix(inventory): deduplicate models between user-defined and aggregator providers
When a user-defined provider (e.g. litellm-proxy) and an aggregator
(e.g. openrouter) both advertise the same model name, the Desktop/TUI
model picker would show the model under both groups. Selecting it from
the aggregator row silently set model.provider to the aggregator,
breaking calls because the aggregator doesn't actually serve that model
ID.

Fix: after list_authenticated_providers() returns, collect all models
from user-defined provider rows and filter them out of aggregator rows.
Uses is_aggregator() from hermes_cli/providers.py to identify
aggregators. Case-insensitive matching.

Fixes #45954
2026-06-15 12:25:41 -07:00
liuhao1024
9df1a1a8de fix(doctor): recognize nvidia as vendor-slug-accepting provider
NVIDIA NIM API uses vendor-prefixed model IDs (e.g. qwen/qwen3.5-122b-a10b,
nvidia/nemotron-3-super-120b-a12b). The doctor command incorrectly warns that
vendor-prefixed slugs belong to aggregators like openrouter when nvidia is
the configured provider.

Add 'nvidia' to the providers_accepting_vendor_slugs set so doctor no longer
raises false-positive warnings for valid NVIDIA NIM configurations.

Fixes #35425
2026-06-15 12:24:46 -07:00
brooklyn!
c33e0457d7 Merge pull request #46836 from NousResearch/bb/salvage-macos-electron-pack
fix(desktop): restore Electron binary before macOS pack rename (salvage #38673)
2026-06-15 14:22:05 -05:00
Austin Pickett
f7c1cbe66f docs: point desktop download links to site root (deprecate /desktop) (#46795)
The /desktop page is deprecated and redirects to the home page. The
landing page for the desktop app is now simply
https://hermes-agent.nousresearch.com/. Update all docs and the
Docusaurus nav/footer links accordingly.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 15:02:24 -04:00
Brooklyn Nicholson
c23a2eec15 chore: map salvaged contributor email for attribution (#38673) 2026-06-15 13:53:23 -05:00
ChasLui
f3b32e9f52 fix(desktop): restore Electron binary before macOS pack rename (salvage #38673)
electron-builder 26.8.x can stage an Electron.app without its
Contents/MacOS/Electron binary, then fail renaming it to Hermes:

    ENOENT: no such file or directory, rename .../MacOS/Electron -> .../MacOS/Hermes

This breaks `npm run pack` and the installer desktop stage before a
launchable Hermes.app exists.

- Point build.electronDist at the already-installed Electron dist so
  electron-builder reuses it instead of re-unpacking from cache.
- Add a darwin-only prebuilder patch that restores the missing main
  binary from the runtime dist before the rename. Idempotent (marker
  guard), soft-fails on shape mismatch, survives node_modules reinstall.

Co-authored-by: ChasLui <chaslui@outlook.com>
2026-06-15 13:53:01 -05:00
Austin Pickett
5f6be7f31b fix(teams): package Microsoft Teams SDK as an installable extra (salvage #43945) (#46764)
* fix(teams): package Microsoft Teams SDK as an installable extra

The Teams adapter imports the microsoft-teams-apps SDK, but it was never
declared as a dependency, so source/local installs hit ImportError and the
adapter silently reported the SDK as unavailable. Add a 'teams' extra
(microsoft-teams-apps==2.0.13.4 + aiohttp) and document 'uv sync --extra teams'.

Per the 2026-05-12 [all] policy, opt-in messaging-platform SDKs are NOT added
to [all] (they would break every fresh install on a quarantined release); the
teams extra is installed on demand like the other platform backends.

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>

* chore: map rio-jeong contributor email for attribution (#43945)

* feat(teams): lazy-install the Teams SDK on demand (parity with other channels)

The teams extra alone left Teams as the only messaging platform that wouldn't
auto-install its SDK — every other channel (telegram, discord, slack, matrix,
dingtalk, feishu) lazy-installs via tools.lazy_deps on first connect. Bring
Teams to parity:

- Add 'platform.teams' to LAZY_DEPS (microsoft-teams-apps + aiohttp).
- Replace the passive 'check_teams_requirements = check_requirements' alias with
  a real lazy-installer that calls ensure_and_bind('platform.teams', ...),
  rebinding all Teams SDK globals on success (mirrors check_slack_requirements).
- Call check_teams_requirements() at the top of TeamsAdapter.connect() so
  enabling Teams installs the SDK on demand.
- Keep the passive check_requirements() as the registry check_fn so 'gateway
  status' probes never trigger a pip install.

The 'teams' extra remains for packagers / explicit 'uv sync --extra teams'.

Tests: rework the alias test into shortcircuit + lazy-install assertions, and
update test_connect_fails_without_sdk to simulate an uninstallable SDK.

---------

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 14:35:15 -04:00
Austin Pickett
0bbf325a8f fix(dashboard): scope chat sidebar model card to selected profile (#46665)
* fix(dashboard): scope chat sidebar model card to selected profile

The PTY already honors ?profile= on profile switch, but the JSON-RPC
sidecar created sessions against the dashboard launch profile. Pass the
management profile through session.create and reconnect on switch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): sync active profile with management scope

Align the sidebar switcher with the sticky active profile on load and
when "Set as active" is clicked, so Chat and management pages match
what the Profiles page shows as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): auto-reconnect chat sidebar on profile switch

Bump the sidecar connection version when profile or PTY channel changes,
matching the manual Reconnect path so gateway and events sockets come
back without clicking the error banner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): prevent model selector chevron overlapping label

Use inline flex layout instead of Button suffix, which is absolutely
positioned and overlapped truncated model names at px-0.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 12:50:19 -04:00
Austin Pickett
0bbff1fc7e fix(deps): declare websockets as core dep + relax dev setuptools pin (salvage #45486, #44693) (#46744)
* fix: declare websockets as a core dependency

* fix(deps): relax dev setuptools pin 82.0.1 -> 81.0.0 (torch caps setuptools<82)

torch >= 2.11 publishes Requires-Dist: setuptools<82, so any environment
that resolves the dev extra together with torch is unsatisfiable:

    $ uv pip install --dry-run ".[dev]" "torch==2.12.0"
    x No solution found when resolving dependencies:
      ... torch==2.12.0 and all versions of hermes-agent[dev] are incompatible.

81.0.0 is the latest release under the cap and stays inside the declared
build-system window (setuptools>=77.0,<83). uv.lock regenerated with
'uv lock'; diff is scoped to the setuptools entry.

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

* chore: map salvaged contributor emails for attribution

Add AUTHOR_MAP entries for the two cherry-picked contributors so the
check-attribution CI gate passes:
- yehaotian@xuanshudeMac-mini.local -> ArcanePivot (#45486)
- dbeyer7@gmail.com -> benegessarit (#44693)

---------

Co-authored-by: 玄枢 <yehaotian@xuanshudeMac-mini.local>
Co-authored-by: David Beyer <dbeyer7@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 12:44:44 -04:00
ethernet
ae433634db fix(desktop): move tsconfig to es2023
Co-authored-by: ibrahim özsaraç <160004724+iborazzi@users.noreply.github.com>
2026-06-15 12:07:17 -04:00
ethernet
9eb0bcd60f change(ci): rip out nix ci for now
to be re-added later when we have more stable ci flows
2026-06-15 12:06:54 -04:00
xxxigm
45e2f4fdcd nix: refresh npmDepsHash for the @assistant-ui/store pin
The store pin changed package-lock.json, so the workspace-wide
npmDepsHash in nix/lib.nix is stale and the Nix flake check fails on
the hash mismatch. Use the hash reported by the real fetchNpmDeps
build (the flake check's `got:`), which is authoritative — it differs
from prefetch-npm-deps' lockfile-contents hash, exactly the divergence
nix/lib.nix already documents.
2026-06-15 11:55:02 -04:00
xxxigm
30377e108c ci(desktop): build the renderer on PRs so vite breaks fail in CI
The desktop build break shipped because nothing in CI runs the
apps/desktop production build. typecheck only runs `tsc`, which does
not exercise Vite/Rolldown module resolution, so an unresolvable
package export (the @assistant-ui/tap "./react-shim" split) sailed
through green checks and only failed when users built from source on
install/update.

Add a desktop-build job that runs `npm run build` (tsc -b + vite build
+ assert-dist-built) for apps/desktop. This closes the gap so the same
class of break fails in CI instead of on every user's machine.
2026-06-15 11:55:02 -04:00
xxxigm
f02484feba test(deps): guard @assistant-ui cluster on one tap version
Lockfile invariant that would have caught the desktop build break: the
single hoisted @assistant-ui/tap must satisfy every @assistant-ui/*
package's declared tap requirement (deps or non-optional peer). It is a
contract, not a snapshot -- no hardcoded versions -- so it stays green
across routine bumps but fails the moment the cluster splits its tap
requirement again.
2026-06-15 11:55:02 -04:00
xxxigm
eae3836eb6 fix(desktop): pin @assistant-ui/store so the cluster shares one tap
The desktop app is built from source on every install/update
(install.ps1 -> npm ci/install -> tsc -b && vite build). The
@assistant-ui packages share an internal reactivity lib,
@assistant-ui/tap, and only interoperate when they all resolve the
SAME tap version.

@assistant-ui/react@0.12.28 and @assistant-ui/core pin tap@^0.5.x
(which exports only "." and "./react"), but the caret range
react -> store@^0.2.9 floated store up to 0.2.18, which bumped its
tap peer to ^0.9.0 and began importing "@assistant-ui/tap/react-shim"
-- an entry point that only exists in the tap 0.9.x line. With the
hoisted tap stuck on 0.5.x, vite build crashed:

    "./react-shim" is not exported ... from package @assistant-ui/tap

i.e. the opaque "apps/desktop build failed (exit 1)" everyone hit when
updating today.

Pin @assistant-ui/store via root overrides to 0.2.13 -- the last
release that targets tap@^0.5.x -- so react/core/store all agree on the
hoisted tap@0.5.14 again. Verified: tsc -b and vite build both pass.
2026-06-15 11:55:02 -04:00
Teknium
3e7e9b24d4 fix: harden salvaged session and browser improvements
Polish salvaged contributor work before PR review:
- read browser inactivity timeout from config with documented fallback
- skip redundant v10 trigram backfill before v11 FTS rebuild
- show delegate_task goals safely in progress previews
- show gateway status model/context without redundant token wording
- wire gateway /sessions to shared session-listing helpers
- map Ravenwolf author emails for release attribution

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
Co-authored-by: Amy Ravenwolf <amy@ravenwolf.de>
2026-06-15 07:46:34 -07:00
Wolfram Ravenwolf
ead38107a2 feat(status): restore model and context in gateway status
PROBLEM: The old public /status PR drifted out of the current Amy patch stack, leaving /status without the model/provider, context window, or explicit cumulative token label that Wolfram uses to monitor context pressure from chat.

SOLUTION: Re-port the feature onto the current gateway status handler. Prefer live/cached agent runtime metadata, fall back to SessionDB + SessionStore state between turns, add localized status model/context lines, and keep token totals explicitly labeled cumulative.

Verification: tests/gateway/test_status_command.py, tests/hermes_cli/test_commands.py
2026-06-15 07:46:34 -07:00
Amy Ravenwolf
5035fa9029 feat(display): show delegate_task goals in tool progress notifications
Previously, delegate_task in batch mode only showed '3 parallel tasks'
without revealing what the tasks actually are. Single-task mode showed
the goal via the primary_args fallback, but batch mode had no goal
extraction.

Changes:
- build_tool_preview(): Add dedicated delegate_task handler that
  extracts individual task goals from both single and batch modes.
  Batch shows '3 tasks: Goal A | Goal B | Goal C'.
- _get_cute_tool_message_impl(): Show individual goals in CLI cute
  messages for batch delegate calls ('3x: Goal A | Goal B').
- Add 4 tests covering single goal, batch goals, missing goals,
  and no-goal edge case.
2026-06-15 07:46:34 -07:00
Wolfram Ravenwolf
5b2604df99 fix(state): skip redundant trigram backfill before v11 FTS rebuild 2026-06-15 07:46:34 -07:00
Amy Ravenwolf
2f2e3616b4 fix(config): read browser inactivity timeout from config 2026-06-15 07:46:34 -07:00
xxxigm
bee13817f0 test(desktop): cover $connection resync on profile switch
Asserts ensureGatewayProfile keeps $connection in lockstep with the active
profile's backend: activating a remote pool profile flips mode to remote,
returning to default resyncs to local, a failed descriptor fetch leaves the
prior connection intact, and a same-profile activation doesn't churn it.
Regression coverage for #46651.
2026-06-15 07:11:02 -07:00
xxxigm
fbabf438a1 fix(desktop): sync $connection on profile switch so remote profiles attach images as bytes
The renderer's $connection seeds from the PRIMARY (window) backend at boot and
otherwise only refreshes on a sleep/wake reconnect. Activating a background
profile (ensureGatewayProfile) pointed the live gateway + REST at that profile's
backend but never updated $connection, so its `mode` stayed stuck on the
primary. With a local primary and a remote pool profile active, every code path
that branches on local-vs-remote misfired: image attachments went out via the
path-based `image.attach` instead of `image.attach_bytes`, handing the remote
gateway a client-only Windows path it can't resolve ("image not found: C:\..."),
and the /api/fs/* file browser and /api/media fetches targeted the wrong
machine.

Resync $connection from the now-active profile's descriptor right after the
gateway swap, so the remote-aware paths follow the live backend. Best-effort: a
failed descriptor fetch leaves the prior connection intact for boot/reconnect to
resync. Single-profile users are unaffected (the same-profile fast path never
runs the swap).

Fixes #46651
2026-06-15 07:11:02 -07:00
Teknium
49e743985a fix: route minimax m3 reasoning controls through profile
Follow up PR #46609's api.minimax.io reasoning report by moving the behavior out of the broad run_agent host gate and into the MiniMax provider profile. Only MiniMax-M3 on the documented OpenAI-compatible /v1 route gets reasoning_split/thinking/reasoning_effort; Anthropic-format MiniMax and non-M3 models keep their existing wire shapes.

Co-authored-by: goku94123 <gooku94123@gmail.com>
2026-06-15 07:08:43 -07:00
goku94123
ba3883cd18 fix(minimax): enable reasoning extra_body for api.minimax.io 2026-06-15 07:08:43 -07:00
Teknium
be7c919bf9 fix(process): label background completion causes (#46659)
Track why a background process finished and include that source in notify-on-complete messages so SIGTERM from process.kill, kill_all, backend loss, and ordinary exits are distinguishable.
2026-06-15 07:08:24 -07:00
Teknium
733472952a fix: complete cron jobs lock salvage
Route curator rollback through the same cross-process cron job lock, make save_jobs lock for legacy direct callers without deadlocking nested mutation paths, and harden the regression test so a second _jobs_lock caller really blocks across processes.
2026-06-15 06:29:00 -07:00
CiarasClaws
e5b4cf7bea fix(cron): make jobs.json writes safe across processes
`hermes cron pause`/`resume`/`remove` run in their own CLI process (CLI →
cronjob tool → pause_job → update_job → save_jobs), entirely separate from
the gateway process that also writes jobs.json (mark_job_run, advance_next_run,
due-fast-forward in get_due_jobs). The only synchronization was a module-level
`threading.Lock`, which serializes writers *within a single process* but does
nothing across processes — and update_job/pause_job/remove_job/create_job did
not even take it.

The result is a classic lost update: a `cron pause` issued while the gateway is
live loads jobs.json, sets enabled=False, and saves; concurrently the gateway
loads the same file and saves back its run-bookkeeping, clobbering the pause.
The CLI prints "Paused" (it succeeded against its own in-memory copy) but the
job stays enabled and keeps firing, with no error surfaced. The scheduler's
`.tick.lock` flock can't be reused for this — it is held for the entire tick,
including multi-minute agent runs, so a CLI mutation would block for minutes.

Add `_jobs_lock()`: a short-held cross-process advisory file lock (fcntl/msvcrt
flock on `<hermes_home>/cron/.jobs.lock`) layered over the existing in-process
lock, and wrap every load→modify→save critical section with it — create_job,
update_job, remove_job, mark_job_run, advance_next_run, get_due_jobs,
rewrite_skill_refs. The lock degrades to in-process-only if neither fcntl nor
msvcrt is available, preserving prior behaviour. All critical sections are short
(field edits, no agent execution), so contention resolves in milliseconds.

Adds a regression test that proves the lock excludes a second process (an
in-process threading.Lock cannot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:29:00 -07:00
Teknium
29c6985590 fix(nix): refresh npm deps hash 2026-06-15 06:18:27 -07:00
FT_IOxCS
92a456f711 fix(cli,deps): clear esbuild audit loop
Upgrade the Vite/esbuild surfaces that kept web, ui-tui, and the bootstrap installer on vulnerable esbuild versions, regenerate the root lockfile, and preserve intentional package+lock dependency edits during update lockfile cleanup.
2026-06-15 06:18:27 -07:00
Teknium
975b9f0a54 docs: recommend standard installer for development (#46646) 2026-06-15 06:14:57 -07:00
Teknium
0d82060c74 fix: harden WhatsApp target alias salvage
Add a parser-only routing regression that proves raw WhatsApp group JIDs bypass channel-directory resolution and home-channel fallback, include channel_aliases.json in quick state snapshots, harden malformed alias handling, and map Keiron McCammon for release attribution.
2026-06-15 05:51:47 -07:00
Keiron McCammon
ea49a79633 fix(messaging): route WhatsApp group JIDs to the target, not the home DM
send_message(target="whatsapp:<group-jid>") silently delivered to the
configured home DM instead of the requested group. Two gaps:

1. _parse_target_ref had no WhatsApp branch. Group JIDs (<id>@g.us),
   user JIDs (<id>@s.whatsapp.net), linked-identity JIDs (<id>@lid), and
   broadcast/newsletter JIDs matched no pattern and fell through to
   `return None, None, False`, so the caller treated them as
   unresolvable and used the home channel. The bridge's /send endpoint
   accepts any chatId, so only the tool-side target parsing was at fault.
   Add a whatsapp branch that recognizes native JIDs as explicit targets.
   The pre-existing '+'-prefixed E.164 path is preserved.

2. WhatsApp groups have no human-friendly name — the channel directory
   is regenerated from session data on a timer, so a group shows up as
   its raw 18-digit JID and any hand-edit to channel_directory.json is
   clobbered on the next rebuild. Add a user-maintained alias overlay
   (~/.hermes/channel_aliases.json) re-applied on every build AND every
   load, giving durable friendly names and letting a freshly-created
   group be pre-named before its first message.

Tests: TestParseTargetRefWhatsAppJID (7 cases) for the parser;
TestChannelAliases (7 cases) for the overlay, plus an autouse fixture
isolating CHANNEL_ALIASES_PATH so a real alias file can't leak into the
existing directory tests.
2026-06-15 05:51:47 -07:00
Teknium
c17469cb19 chore: map Veritas-7 release attribution
Add the contributor noreply email used by the salvaged xAI OAuth refresh-skew commit so release notes credit the original author.
2026-06-15 05:40:23 -07:00
Veritas-7
febdddb41a fix(auth): refresh xAI OAuth tokens earlier 2026-06-15 05:40:23 -07:00
Teknium
aab2e99bae test: cover request debug dump redaction
Keep request dump writes on the shared atomic JSON path, add regression coverage for request body/error/stdout redaction, and map the salvaged contributor email for release attribution.
2026-06-15 05:31:21 -07:00
xtymac
ad58dd51ac redact secrets in API request debug dumps
dump_api_request_debug() masks the provider Authorization header but writes
the request `body` (system prompt, tool defs, context-embedded values) and the
error message raw via atomic_json_write. This path also fires unconditionally
on API errors (not only under HERMES_DUMP_REQUESTS), so any secret surfaced
into context (e.g. an integration token) lands in cleartext at
request_dump_*.json on every failed call.

Run the serialized dump through the existing redact_sensitive_text() scrubber
(already used for logs/tool output) before persisting and before the
HERMES_DUMP_REQUEST_STDOUT print; preserve atomicity via temp-file +
Path.replace. Also add the Notion internal-integration prefix (ntn_) to
_PREFIX_PATTERNS so bare values are caught.

Per SECURITY.md §3.2 this is a redaction (in-process heuristic) hardening, not
a §3.1 vulnerability. Refs #46583.
2026-06-15 05:31:21 -07:00
Teknium
a688d2a1bd test: assert disk cleanup prunes protected walks 2026-06-15 05:25:27 -07:00
墨綠BG
40699c3292 🐛 fix(disk-cleanup): avoid brittle sweep review issues 2026-06-15 05:25:27 -07:00
墨綠BG
c1a70a5439 🐛 fix(disk-cleanup): prune protected cleanup walks 2026-06-15 05:25:27 -07:00
liuhao1024
2cddc9c895 fix(bedrock): check boto3 version >= 1.34.59 before using converse_stream
converse() and converse_stream() were added in boto3 1.34.59. When Hermes
is installed editable into system Python (e.g. Ubuntu 24.04 ships 1.34.46),
the system boto3 takes precedence and calls to converse_stream fail with
AttributeError. Add an early version check in _require_boto3() that raises
a clear RuntimeError with upgrade instructions.
2026-06-15 05:25:17 -07:00
Teknium
f79b109f4f chore: map 0xneobyte release author 2026-06-15 05:25:07 -07:00
Tharushka Dinujaya
ec05d2bc3e fix(gateway): evict scoped lock when PID+start_time match but process is not a gateway
On Linux, systemd spawns core services (cron, nginx, sshd) with
deterministic PIDs and jiffy start_times across reboots. A service can
land on the exact same PID and start_time as a previous gateway, causing
acquire_scoped_lock to mistake it for a live gateway and block startup.

The existing stale-detection paths only covered:
  - start_times both non-None and different (clear mismatch)
  - start_times both None (macOS/Windows fallback to cmdline check)

The boot-time collision falls through both: times are non-None and
equal, so neither branch fired.

Add a third check: when both start_times are known and match but the
live process fails _looks_like_gateway_process, read its cmdline. If
the cmdline is readable (non-None), we have positive evidence of an
impostor and mark the lock stale. Requiring a readable cmdline keeps the
check conservative — if cmdline is unreadable we do not evict.
2026-06-15 05:25:07 -07:00
Nicolò Boschi
a376ca0081 feat(hindsight): make observation scopes configurable on retain
Adds an observation_scopes config key (and HINDSIGHT_RETAIN_OBSERVATION_SCOPES
env var) so retained memories can opt into per_tag / all_combinations /
custom scoping instead of Hindsight's default combined pass.

Threaded through _build_retain_kwargs so all three retain paths honor it:
auto-retain and flush-on-switch already use aretain_batch; the tool retain
path is switched from aretain to aretain_batch (functionally equivalent,
aretain just wraps a single-item batch) since aretain doesn't accept the
observation_scopes parameter.
2026-06-15 04:59:17 -07:00
kshitij
8844e091c1 Merge pull request #46614 from kshitijk4poor/salvage/xai-oauth-profile-writethrough
fix(auth): resolve xAI OAuth credentials across profiles + write rotated tokens back to root
2026-06-15 17:16:19 +05:30
kshitijk4poor
1227007aed chore: map capt-marbles contributor email for attribution
Salvaged commit in this PR is authored by capt-marbles
(andrewdmwalker@gmail.com), a bare gmail that does not auto-resolve in
the check-attribution job. Add the AUTHOR_MAP entry.
2026-06-15 17:09:27 +05:30
kshitijk4poor
497352bc4e fix(auth): write rotated xAI OAuth tokens back to global root (#43589)
The salvaged read-side fix lets a profile resolve the xAI OAuth grant from
the global-root auth store when it has no own providers.xai-oauth block.
But _save_xai_oauth_tokens still wrote rotated tokens only to the active
profile store. Because xAI rotates the refresh_token on every refresh, a
profile that reads root's grant and refreshes it left root holding a now-
revoked refresh token — killing every other profile reading the stale root
grant with invalid_grant once its access token expired (#43589).

Detect the read-from-root case (profile lacks its own providers.xai-oauth
block) and, after the profile save, write the rotated chain back to the
global root too via a best-effort, TOCTOU-safe write-through that reuses
_save_auth_store with an explicit target path. A profile that genuinely
shadows root (has its own block) is left untouched, classic mode is a
no-op, and a failed root write never breaks the profile's own save.

Pairs with the read fallback in the preceding commit so the cross-profile
xAI grant stays coherent in both directions.
2026-06-15 17:08:19 +05:30
Andrew Walker
f1d6f04362 fix(auth): resolve xAI OAuth credentials across profiles
(cherry picked from commit 8d8b9f50e4)
2026-06-15 17:03:35 +05:30
helix4u
dcc3216955 fix(mcp): fail fast for noninteractive oauth without tokens 2026-06-15 04:22:07 -07:00
Teknium
aca11c227e fix(docker): skip gateway reconciliation in dashboard container (autodetect) (#46293)
* fix(docker): skip per-profile gateway reconciliation in dashboard container

When gateway and dashboard containers share a bind-mounted HERMES_HOME,
both run the cont-init.d profile reconciliation script, which creates
s6-log processes for every persisted profile.  These s6-log processes
in different containers race to flock() the same log-directory lock
files under logs/gateways/<profile>/lock, producing repeated
"s6-log: fatal: unable to lock ... Resource busy" errors and a
supervision restart storm.

Add HERMES_SKIP_PROFILE_RECONCILE env var support to container_boot.py
and set it in the official docker-compose.yml dashboard service so the
dashboard container no longer creates per-profile gateway s6 services
it never uses.

* chore(release): map salvaged contributor

* refactor(docker): autodetect dashboard container instead of env-var gate

Replace the HERMES_SKIP_PROFILE_RECONCILE env var with PID 1 argv role
detection. A dashboard-only container never spawns or supervises
per-profile gateways, so the reconcile boot hook now skips itself when
/proc/1/cmdline is the dashboard command — no operator flag to set (or
forget in a hand-written manifest, which would reintroduce the s6-log
flock storm this prevents).

- Extract _strip_container_argv_prefix() shared by the legacy-gateway
  and new dashboard detectors (DRY the init/wrapper/hermes peel).
- Add _is_dashboard_container(); gate reconcile main() on it.
- Drop HERMES_SKIP_PROFILE_RECONCILE from code + docker-compose.yml.
- Tests: argv matrix for both roles + main()-level skip/reconcile proof
  and a regression that the removed env var is now inert.

Co-authored-by: 895252509 <895252509@qq.com>

---------

Co-authored-by: zhouxiang <895252509@qq.com>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 20:51:48 +10:00
kshitij
6cb88a0874 Merge pull request #46552 from kshitijk4poor/salvage/file-tools-session-cwd
fix(tools): respect session cwd in file tools (salvage of #46460)
2026-06-15 14:13:15 +05:30
kshitijk4poor
8fce54499f refactor(tools): extract shared sentinel-free abs cwd validator
_configured_terminal_cwd and _registered_task_cwd_override carried a
byte-identical sentinel + expanduser + isabs validation tail. Extract it
into _sentinel_free_abs_cwd(raw) so the relative/sentinel rejection rule
lives in one place. Behaviour unchanged (the str() coercion the override
path relied on is preserved in the helper).
2026-06-15 14:03:41 +05:30
kshitijk4poor
b0c99c12dd docs(tools): document registered-cwd step in resolver docstrings
The session-cwd fix inserted a registered task/session cwd override step
between the live-cwd and $TERMINAL_CWD fallbacks, but three docstrings still
described the old two-step order — _resolve_base_dir's numbered list was
outright wrong. Update _authoritative_workspace_root, _resolve_base_dir, and
_path_resolution_warning to reflect the actual four-step resolution order.
No behaviour change.
2026-06-15 14:02:54 +05:30
kshitijk4poor
ddf7c7af81 refactor(tools): consolidate task-override lookup into one helper
The raw-key-first-then-collapsed override lookup was hand-rolled in three
places with subtly different spellings: terminal_tool's command setup, and
both file_tools._registered_task_cwd_override and _get_file_ops. Since that
exact raw-vs-collapsed invariant is what the session-cwd fix depends on,
keeping three copies invites the drift that caused the original bug.

Add terminal_tool.resolve_task_overrides(task_id) as the single source and
route all three sites through it. Behaviour is unchanged (verified
byte-equivalent across raw/collapsed/isolation/None/subagent inputs).
2026-06-15 14:02:17 +05:30
Gille
d6a8d9dcab fix(tools): respect session cwd in file tools 2026-06-15 14:00:42 +05:30
Ben Barclay
95715dcb03 fix(s6): reserved default gateway must not follow sticky active_profile (#46483)
The supervised `gateway-default` s6 slot runs bare `hermes gateway run`
(no -p) to mean "the root HERMES_HOME profile". But `_apply_profile_override`
falls through its #22502 HERMES_HOME guard for the container root
(/opt/data, whose parent is not `profiles`) and reads the sticky
`active_profile` file. If the user set another profile active (e.g. via
the dashboard), the reserved default gateway gets redirected into that
profile — producing a duplicate gateway for the active profile and no
real default gateway. The profile page and `gateway status` then
correctly report default as "not running" because there genuinely isn't
one.

Guard step 2 (the sticky active_profile fallback) with the existing
HERMES_S6_SUPERVISED_CHILD sentinel that the container run-script already
exports. Supervised named-profile slots pass -p explicitly (step 1, never
reaches step 2); only the bare default slot was affected. Inert outside
the s6 container — the sentinel is never set elsewhere.

Reported in the 'Docker & Profiles & Dashboard' support thread.
2026-06-15 05:36:20 +00:00
Ben Barclay
80f8ffc74c fix(dashboard): pin machine-dashboard reroute to the machine root, not $HOME/.hermes (#46487)
The unified machine-dashboard reroute (cmd_dashboard) re-execs a named-profile
dashboard launch as the machine dashboard and dropped HERMES_HOME from the
child env with the comment "so the child binds the machine root". That holds
for a standard install (root == ~/.hermes) but breaks the Docker layout: the
published image sets `ENV HERMES_HOME=/opt/data`, so once HERMES_HOME is unset
the child falls back to $HOME/.hermes = /opt/data/.hermes — an empty,
auto-seeded home.

Two user-visible symptoms, one root cause (reported via support):

1. Dashboard Profiles page shows only an empty `default` — the real
   default/oracle/saga profiles live under /opt/data/profiles, but the
   rerouted child resolves _get_profiles_root() to /opt/data/.hermes/profiles.

2. The "Update Hermes" button runs `hermes update` inside the container
   repeatedly instead of bailing with the docker-update guidance. The Docker
   guard keys off detect_install_method(), which reads
   $HERMES_HOME/.install_method; the image stamps that at /opt/data, but the
   misresolved home has no stamp, no HERMES_MANAGED, and no .git → falls
   through to "pip", so the guard never fires.

The reporter's workaround was to bind-mount the host dir at both /opt/data and
/opt/data/.hermes so the two paths converge (at the cost of a self-referential
recursion).

Fix: resolve the machine root explicitly with get_default_hermes_root() and set
it on the child env instead of popping HERMES_HOME. That helper returns the
root for both layouts — ~/.hermes for a standard install, and /opt/data for
Docker (it strips a trailing profiles/<name>). Falls back to the old pop
behaviour only if root resolution raises, so the reroute is never blocked.

Regression tests in test_dashboard_unified_launch.py: the existing standard-
install test now asserts the child carries HERMES_HOME == get_default_hermes_root()
(not absent), and a new test_reexec_pins_docker_machine_root covers the Docker
layout (HERMES_HOME=/opt/data/profiles/oracle → child gets /opt/data). Both
fail against the pre-fix pop behaviour (mutation-verified).
2026-06-15 15:33:15 +10:00
Teknium
c2b7669ad3 fix(s6): clear stale log lock before startup (#46289)
* fix(cli): clear stale s6-log lock file before startup on virtiofs

* chore(release): map salvaged contributor

---------

Co-authored-by: zxcasongs <35259607+zxcasongs@users.noreply.github.com>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 14:10:51 +10:00
Teknium
b770967263 fix(s6): persist profile gateway desired state (#46292)
* fix: persist s6 gateway desired state

* chore(release): map salvaged contributor

---------

Co-authored-by: Alfred Smith <alfred@my-cloud.me>
Co-authored-by: Ben <ben@nousresearch.com>
2026-06-15 14:02:10 +10:00
Teknium
61ee2dbfdb fix(s6): make profile gateway log parent writable (#46291)
* fix(gateway): chown logs/gateways parent so late-added profiles can log

The per-profile log service script created $HERMES_HOME/logs/gateways/
via 'mkdir -p' but only chowned the leaf logs/gateways/<profile>. When
the first log service boots in root context, the gateways/ parent stays
root:root; every profile registered later runs its log service as the
dropped hermes user, 'mkdir -p' fails with EACCES, and s6-log enters a
sub-second fatal crash-loop flooding the container log. The stage2
recursive heal does not catch it either: it is gated on needs_chown,
which is false when the top-level $HERMES_HOME is already hermes-owned.

Two complementary fixes:

- service_manager._render_log_run: chown the gateways/ parent
  (non-recursively) before the leaf chown. Runs on every root-context
  boot, so it also heals volumes already poisoned by older images.
- docker/stage2-hook.sh: seed logs/gateways in the as_hermes mkdir -p
  block; cont-init runs before any service starts, so the parent
  already exists hermes-owned when the first log/run does 'mkdir -p'.

The needs_chown repair loop needs no twin entry: it already chowns
logs/ recursively, which covers logs/gateways.

Fixes #45258

* chore(release): map salvaged contributor

---------

Co-authored-by: tangtaizhong666 <tangtaizhong792@gmail.com>
2026-06-15 13:47:05 +10:00
leo4226
f795513782 fix(windows): kill hermes before recreating venv to release _bcrypt.pyd lock (#45120)
On Windows, native Python extensions such as _bcrypt.pyd are loaded as
DLLs by any running hermes process. When the installer tries to recreate
the venv (Remove-Item -Recurse -Force "venv"), Windows denies the delete
because the DLL is still mapped into the running process.

Add a taskkill /F /T /IM hermes.exe call before the Remove-Item so any
hermes process tree is stopped first, releasing the file lock. A short
sleep gives the OS time to unload the image before deletion proceeds.

This mirrors the existing force_kill_other_hermes() guard already present
in the --update flow (update.rs), applying the same pattern to the full
reinstall/repair path through install.ps1.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 02:27:00 +00:00
Alli
8fe334b056 fix(desktop): inset hover-reveal trigger past the adjacent scrollbar (#44159)
The collapsed-pane hover-reveal trigger strip (14px wide, 6px edge
gutter) overlapped the neighboring scroller's 8px .scrollbar-dt
scrollbar, which sits flush with the window edge when the rail panes
are collapsed. Hovering the scrollbar revealed the file browser over
it, and clicks on the overlapped band hit the trigger instead of the
scrollbar thumb.

Widen the edge gutter to calc(0.5rem + 2px) so the strip clears the
scrollbar (rem-coupled to the .scrollbar-dt width) while still
covering the OS window-resize grab area inset.

Part of #44140 (item 2).

Co-authored-by: AIalliAI <285906080+AIalliAI@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 21:23:38 -05:00
Teknium
40d7c264f0 fix(s6): register profile gateways without auto-starting (#46266)
* fix(s6): prevent profile create from auto-starting gateway service

When hermes profile create runs inside an s6 container,
_maybe_register_gateway_service() calls register_profile_gateway()
which creates the service directory and triggers s6-svscanctl -a.
Previously the service always started immediately, causing profiles
that share the main gateway's bot token (e.g. Kanban worker profiles)
to fail with a token-lock conflict and persist gateway_state: running
— becoming zombies that resurrect on every container restart.

Wire the existing start_now parameter through the S6 implementation:
when start_now=False, write a  marker file (same pattern as
container_boot.py _register_gateway_slot) so s6-supervise leaves the
service stopped until the user explicitly runs hermes -p <profile>
gateway start.

4 files, +61/-6, 4 new tests (all passing).

* test(docker): wait for gateway running state before restart

---------

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-15 11:43:23 +10:00
Teknium
4eb0ff639b Remove is_container check when restarting over dashboard (#46290)
Co-authored-by: IAvecilla <ignacio.avecilla@lambdaclass.com>
2026-06-15 11:09:23 +10:00
Teknium
f3fe99863d revert(web): remove keyless Parallel search fallback (#46350)
Remove the free Parallel Search MCP path and restore the keyed Parallel backend behavior from before it was introduced.

Also drops the keyless fallback registration/display labeling tests and returns the Parallel SDK pin to the prior version.
2026-06-14 16:47:57 -07:00
Teknium
a829e04d62 fix: migrate cloned profile configs (#46345) 2026-06-14 16:30:23 -07:00
Teknium
2a14e8957d fix(kimi): surface K2.7 Code in native picker (#46309) 2026-06-14 14:01:03 -07:00
mr-r0b0t
bff78a34dc feat(zai): add GLM-5.2 with verified 1M context window
GLM-5.2 ships with a 1M (1,048,576) token context window. Without this
entry, Hermes falls through to the generic 'glm' key (202,752 tokens),
under-reporting the context bar and prematurely compressing conversations.

The 1M limit was verified empirically via needle-in-a-haystack retrieval
at 789,240 prompt tokens on api.z.ai/api/coding/paas/v4 — zero errors,
zero truncation, correct retrieval at every tested size (25K through 789K).

Changes:
- agent/model_metadata.py: add 'glm-5.2': 1_048_576 before 'glm' fallback
- hermes_cli/models.py: add glm-5.2 to zai curated models
- hermes_cli/setup.py: add glm-5.2 to setup wizard zai list
- hermes_cli/auth.py: add glm-5.2 to coding plan endpoint probes
- plugins/model-providers/zai/__init__.py: add glm-5.2 to fallback_models
- tests/agent/test_model_metadata.py: context resolution + vendor-prefix tests
2026-06-14 13:50:36 -07:00
Teknium
4e6d05c6a5 perf(skills): share raw config cache in skill utils (#46149) 2026-06-14 11:14:58 -07:00
Teknium
a1f51feb72 fix(telegram): avoid rich final duplicate previews (#46206) 2026-06-14 11:13:38 -07:00
kshitij
6c34088a17 Merge pull request #46237 from kshitijk4poor/salvage/46095-cross-process-cache
fix(gateway): cross-process agent-cache coherence (#45966) + preserve prompt caching
2026-06-14 23:05:17 +05:30
kshitij
fc2b8b3d31 Merge pull request #46236 from kshitijk4poor/salvage/disabled-skills-union
fix(skills): platform-disabled skills still appear in <available_skills> + unify all resolution sites (#46201)
2026-06-14 23:00:11 +05:30
kshitijk4poor
3bc4a2ff78 fix(gateway): re-baseline agent-cache message_count after each turn
The #45966 cross-process coherence guard snapshots a session's on-disk
message_count next to the cached agent and rebuilds the agent when the
count changes.  But the snapshot is taken at agent-BUILD time — before
the turn writes its own user + assistant (+ tool) rows — and the cache
entry is never rewritten on a reuse.  So this process's OWN turn grows
message_count, and the very next turn sees a mismatch and rebuilds the
agent.  That happens every turn, for every conversation, silently
destroying the per-conversation prompt caching the cache exists to
protect (AGENTS.md: prompt caching is sacred).

Add _refresh_agent_cache_message_count(): after a turn completes and the
agent has flushed its rows to the SessionDB, re-baseline the stored count
to the now-current value.  The guard then fires ONLY when a DIFFERENT
process changes the transcript — preserving the #45966 fix while keeping
the cache warm for normal single-process operation.

Tests drive the real SessionDB + the real guard condition: 5 consecutive
same-process turns now all REUSE the cached agent (0 before the fix); a
cross-process append still invalidates; and the re-baseline is fail-safe
(no DB, falsy session_id, raising probe, legacy 2-tuple, pending sentinel
all no-op).
2026-06-14 22:58:55 +05:30
kshitijk4poor
ce19fdb7ce fix(skills): apply global|platform disabled union to all resolution sites
The platform-disabled fix landed only in agent.skill_utils.get_disabled_skill_names
(the system-prompt path). Two sibling resolvers still used the old
replace-not-union semantics, so the same skill could be hidden from the
<available_skills> prompt yet reported enabled elsewhere:

- hermes_cli/skills_config.get_disabled_skills (the 'hermes skills config' UI)
  returned only the platform list, so a globally-disabled skill showed as
  enabled (unchecked) on any platform with a platform_disabled entry.
- tools/skills_tool._is_skill_disabled (gates whether skill_view loads a skill)
  ignored the global list when a platform list existed, so a globally-disabled
  skill could still be loaded on such a platform.

Both now union the global list with the platform list, matching
get_disabled_skill_names. An explicit empty platform list no longer re-enables
a globally-disabled skill — global disables hold on every platform (#46201).

Also: fix the now-stale get_disabled_skill_names docstring and drop a stray
blank line. Regression tests added for both sites (proven to fail on the old
replace semantics).
2026-06-14 22:54:54 +05:30
kyssta-exe
7f245b0035 fix(gateway): invalidate agent cache on cross-process session writes (#45966)
(cherry picked from commit 6d0f79defe)
2026-06-14 22:54:39 +05:30
ibrahim özsaraç
7bbe7024c2 fix: filter platform-disabled skills from <available_skills> prompt (#46201)
build_skills_system_prompt() already resolved _platform_hint but called
get_disabled_skill_names() with no argument, so the resolved platform never
reached the filter and the prompt cache_key varied by platform while the
disabled set did not. Pass _platform_hint or None.

get_disabled_skill_names() also fully ignored the global 'disabled' list once
a platform-specific list was found. Return the union (global | platform) so a
globally-disabled skill stays disabled on every platform.

Salvaged from #46203 by @iborazzi; the unrelated apps/shared/tsconfig.json
ES2023 bump is intentionally dropped (one concern per PR).
2026-06-14 22:52:57 +05:30
Teknium
7433d5f0eb fix(gateway): scope early duplicate guard to pid file 2026-06-14 08:42:06 -07:00
konsisumer
1436793051 fix(gateway): block shell gateway run when a service supervises the profile 2026-06-14 08:42:06 -07:00
brooklyn!
08d89e7aba fix(desktop): limit thinking shimmer to the disclosure label (#46197)
Reasoning body text was inheriting tw-shimmer while streaming even though
the "Thinking" header already pulses — keep shimmer on the label only.
2026-06-14 10:14:58 -05:00
Teknium
2c174bce24 fix(gateway): preserve new input on interrupted replay cleanup 2026-06-14 05:10:39 -07:00
Arnaud L
5191c1c2ce fix(gateway): stop replaying interrupted tool-call tails and auto-continue notes
Three changes to prevent infinite re-execution loops when a user sends
a new message while long-running tools are executing:

1. Filter interrupted tool results in _build_gateway_agent_history:
   skip tool messages whose content contains [Command interrupted] or
   exit_code 130 — they represent partial execution, not valid results.

2. Don't replay auto-continue notes as user messages: detect
   gateway-injected [System note: ...] / [IMPORTANT: ...] prefixes
   and skip them in _build_gateway_agent_history so the LLM doesn't
   see 4+ messages from 'the user' telling it to finish old work.

3. Fix the wording: the system note now instructs the model to
   address the user's NEW message FIRST, IGNORE pending results,
   and NOT re-execute old tool calls.

Closes #45230
2026-06-14 05:10:39 -07:00
Teknium
0f3670ba79 chore(release): map Diyoncrz18 author email 2026-06-14 04:52:54 -07:00
Diyon18
288f7026e3 fix(messaging): correct Weixin personal account labeling 2026-06-14 04:52:54 -07:00
Teknium
efbe1635dd fix(gateway): include replied-to media attachments (#46107) 2026-06-14 04:51:50 -07:00
Teknium
a27d7e68cc fix(mcp): block suspicious stdio configs before probe (#46112) 2026-06-14 04:46:54 -07:00
Teknium
13a1bd0f83 perf(model-metadata): persist OpenRouter metadata cache (#46114) 2026-06-14 04:45:46 -07:00
Teknium
0e22bf6439 docs(gateway): document exact silence tokens (#46105) 2026-06-14 04:37:18 -07:00
Teknium
972a9885ee fix(mcp): block exfil-shaped stdio server configs (#46083) 2026-06-14 04:24:14 -07:00
Teknium
9459057d7f fix(telegram): guard rich details math crash (#46102) 2026-06-14 04:22:22 -07:00
Teknium
cf7d5932f8 fix(email): make IPv4 SMTP fallback use supported sockets 2026-06-14 04:16:26 -07:00
liuhao1024
04d4471d79 fix(email): use SMTP_SSL for port 465 and fall back to IPv4 on timeout
Port 465 expects implicit TLS (SMTP_SSL) from the first byte. The email
adapter always used SMTP() + starttls(), which is correct for port 587
but hangs/fails on port 465 providers (e.g., Swiss ISPs).

Additionally, when the SMTP host has AAAA DNS records but IPv6 is
unreachable, socket.create_connection() tries IPv6 first and hangs
until timeout. Add an IPv4 fallback via AF_INET socket.

Extract _connect_smtp() helper to consolidate the 4 duplicate SMTP
connection sites into a single method with correct protocol selection
and IPv6 fallback logic.
2026-06-14 04:16:26 -07:00
xxxigm
1db8f7ea80 fix(install): repair existing managed-Node global prefix on re-run
The initial fix only wrote the prefix npmrc on a fresh Node install, so
pre-existing bundled-Node installs (Node already present) were not repaired
by re-running the installer — install_node/ensure_node skip when Node is
already up to date.

Extract the redirect into an idempotent helper
(configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops
when there's no Hermes-managed npm, and call it unconditionally from
check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh).
Re-running the install command now repairs an affected install in place,
not just brand-new ones.
2026-06-14 17:34:11 +07:00
Teknium
5105c3651a perf(api-server): normalize chat content linearly (#46079) 2026-06-14 03:25:49 -07:00
Aldo
293c04fef6 fix(gateway): suppress exact silence tokens without mutating history 2026-06-14 03:25:08 -07:00
xxxigm
98205da008 test(install): cover bundled-Node npm global prefix redirect
Guards that install.sh and node-bootstrap.sh redirect the bundled Node's
npm global prefix to the command link dir's parent via a prefix-local
global npmrc, so `npm install -g` binaries land on PATH instead of the
off-PATH $HERMES_HOME/node/bin.
2026-06-14 17:21:25 +07:00
xxxigm
a4ee1f223d fix(install): make npm install -g packages reachable on PATH
When the installer falls back to a bundled Node under $HERMES_HOME/node,
npm's default global prefix is that Node dir, so `npm install -g <pkg>`
drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are
symlinked into the command link dir (~/.local/bin, /usr/local/bin, or
$PREFIX/bin) — so user-installed global package binaries are NOT on PATH
and can't be run, even though `npm i -g` reports success. They also get
wiped on every Node upgrade (the dir is rm -rf'd and re-extracted).

Redirect the bundled Node's npm global prefix to the command link dir's
parent, so global bins land in the link dir (already on PATH, alongside
node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via
its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's
other Node installs and their ~/.npmrc are untouched. Hermes's own global
installs (agent-browser) pass an explicit --prefix and are unaffected.
2026-06-14 17:21:20 +07:00
Teknium
10bad2faf1 fix(gateway): serialize startup auto-resume before inbound (#46074)
Gateway startup now queues real inbound messages until restart-interrupted auto-resume turns have completed, preventing duplicate agents for the same session after a restart.
2026-06-14 03:21:06 -07:00
Teknium
2b4873f7fb fix(agent): persist repaired-turn responses (#46071) 2026-06-14 03:20:25 -07:00
Teknium
723c2331bd fix: make profile subprocess HOME policy explicit 2026-06-14 03:20:21 -07:00
zccyman
b00060ce54 fix(agent): expose HERMES_REAL_HOME in subprocess envs for profile isolation
When profile isolation activates ({HERMES_HOME}/home/ exists), child
processes receive HOME={HERMES_HOME}/home/ for tool config isolation
(git, ssh, gh). However, scripts using Path.home() to locate
~/.hermes/ would incorrectly resolve to the isolated profile home,
breaking helpers that rely on the real user home directory.

New get_real_home() helper in hermes_constants resolves the actual
user home independently of profile isolation. All four subprocess
spawners now inject HERMES_REAL_HOME alongside the profile HOME:

- tools/code_execution_tool.py (execute_code)
- tools/environments/local.py (terminal background, run_env)
- agent/copilot_acp_client.py (Copilot ACP)

Child scripts can now use:
  Path(os.environ.get("HERMES_REAL_HOME", os.environ.get("HOME", "")))

to reliably find the real user home regardless of profile isolation.

Closes #25114
2026-06-14 03:20:21 -07:00
Teknium
0428945b5b fix(desktop): keep profile homes out of bootstrap (#46073) 2026-06-14 03:08:52 -07:00
xxxigm
8f4a718f95 test(discord): guard slash-command registration against the 100 cap
Registers 200 plugin commands on top of the native + COMMAND_REGISTRY set
and asserts the tree never exceeds Discord's 100-command limit, that native
high-priority commands survive the cap, and that overflow is actually
dropped. Regression guard for the recurring error 30032
("Maximum number of application commands reached") sync failures.
2026-06-14 17:02:21 +07:00
xxxigm
5e851bc6bc fix(discord): cap slash commands at Discord's 100-command limit
Discord enforces a hard cap of 100 global application commands per app.
The adapter registers ~27 native commands plus every gateway-available
entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated
/skill group. On a loaded install (many plugins/quick commands) the
desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands()
hits error 30032 ("Maximum number of application commands reached") and
Discord rejects the ENTIRE batch — silently breaking every slash command,
not just the overflow.

Cap registration at the 100-command limit: native commands (registered
first, highest priority) and the /skill group are always kept; lower-
priority auto-registered COMMAND_REGISTRY and plugin commands are added
only until the cap is reached, with a single concise warning telling the
user how to surface the rest. Since both sync paths read from
tree.get_commands(), bounding the tree fixes the root cause for both.
2026-06-14 17:01:28 +07:00
Teknium
afc8615509 perf(webhook): prune request caches incrementally (#46065) 2026-06-14 02:40:54 -07:00
LeonSGP43
89bdb1e546 fix: read dashboard spa assets as utf-8
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-14 02:31:04 -07:00
Teknium
7b9dc7cd0a test(gateway): align web profile wrapper expectation 2026-06-14 02:20:55 -07:00
helix4u
d76a58bd15 fix(gateway): resolve sudo profile system installs 2026-06-14 02:20:55 -07:00
Teknium
1f5eef8093 test(tui): tolerate resume init kwargs in protocol tests 2026-06-14 02:15:33 -07:00
Teknium
9f33d673e9 fix(tui): persist resumed profile cwd updates to profile db 2026-06-14 02:15:33 -07:00
dsad
d842155da1 Keep resumed profile cwd scoped to profile DB 2026-06-14 02:15:33 -07:00
helix4u
4936a49a0c fix(mcp): preserve loop during probes 2026-06-14 02:09:45 -07:00
helix4u
85e6232a07 fix(providers): support anthropic proxy v1 endpoints 2026-06-14 02:09:16 -07:00
Teknium
81e42335a1 fix(file-safety): relax user-write deny policy (#45947)
Allow file tools to edit shell startup files, user package-manager configs, and Hermes control files that the user can already modify directly. Keep hard blocks for SSH keys, .env/OAuth token stores, mcp-tokens, pairing files, and system privilege files.
2026-06-14 02:07:32 -07:00
brooklyn!
526a1e24b5 Merge pull request #46029 from NousResearch/bb/summarize-gui
fix(desktop): show summarizing indicator during auto-compaction
2026-06-14 02:53:14 -05:00
Brooklyn Nicholson
1eb13744b4 fix(desktop): polish compaction indicator and preserve scrollback
Show a shimmering "Summarizing thread" label during auto-compaction, skip
the post-turn hydrate when compaction fired so the live transcript does not
collapse to the stored summary-only session.
2026-06-14 02:48:48 -05:00
brooklyn!
49dd91d682 fix(desktop): show copied checkmark on session Copy ID (#46030)
Route sidebar Copy ID through CopyButton so dropdown and context menus
get the same checkmark feedback as every other copy action.
2026-06-14 07:38:55 +00:00
Brooklyn Nicholson
715b691723 fix(desktop): show summarizing indicator during auto-compaction
Auto-compression rewrites history mid-turn, which made long threads look
like they reset. Re-tag the gateway lifecycle status as compacting and
surface it in the desktop thread loading indicators.
2026-06-14 02:28:07 -05:00
brooklyn!
9cbb91abd3 fix(desktop): clarify UX — loading, enter-to-send, radio align (#46014)
* fix(desktop): clarify enter-to-send and top-align choice radios

Match the composer keyboard contract in clarify freeform answers and align choice-row radio dots to the start of wrapped labels.

* fix(desktop): clarify loading spinner until request is ready

Hold the clarify panel on a centered Loader2 until clarify.request arrives instead of showing disabled choices or a loading-question stub.

* refactor(desktop): dedupe clarify shell and drop stale ready gates

Extract the shared clarify panel wrapper and remove disabled-state checks that loading already makes unreachable.
2026-06-14 07:06:40 +00:00
kshitij
c8ad2ca997 Merge pull request #46013 from kshitijk4poor/salvage/refusal-content-filter
fix(agent): surface model refusals as content_filter (salvage #43108 + edge-case fix)
2026-06-14 12:28:51 +05:30
kshitijk4poor
10bd01972b refactor(agent): share the content_policy_blocked result builder + recovery hint
The HTTP-200 refusal handler (finish_reason=content_filter) and the
exception-path handler (a provider moderation error classified as
content_policy_blocked) independently built the same terminal turn result —
the same {final_response, messages, api_calls, completed:False, failed:True,
error:'content_policy_blocked: ...'} dict — and ended their user-facing
message with the same 'Try rephrasing... hermes fallback add' trailer, copied
verbatim. The two copies could drift.

Funnel both through a shared _content_policy_blocked_result() builder and a
shared _CONTENT_POLICY_RECOVERY_HINT constant. Also collapse the HTTP-200
path's two near-identical with/without-explanation templates into one (compute
the detail fragment once) and pass reason=FailoverReason.content_policy_blocked
.value to the error hook instead of a hand-written string literal, matching the
sibling hook call.

Behavior-preserving: the provider/refusal lead-in wording stays distinct (a
provider safety filter vs the model declining are genuinely different signals),
the with-text and exception messages are byte-identical to before, and the
no-explanation case only gains a paragraph break for consistency. Surfaced by
the simplify-code reuse/quality reviewers.

The efficiency reviewer's 'redundant normalize_response' flag was deliberately
NOT applied: that branch is cold (refusal-only) and pure-CPU, and reusing the
sibling-branch normalized locals would risk a NameError on the codex_responses
path (which sets finish_reason without normalizing) — re-normalizing is the
robust choice.
2026-06-14 12:19:19 +05:30
kshitijk4poor
12c84d6c77 fix(transports): only treat a refusal as terminal when it is the sole payload
A chat-completions response that carries real text or tool calls *alongside*
a `message.refusal` note is a normal, usable turn — the model did work. The
prior logic flipped finish_reason to `content_filter` whenever a refusal
string was present, so the conversation loop reframed a content-bearing turn
as a *failed* safety refusal (failed=True) and buried the model's actual
output inside the "model declined" template, or dropped tool calls entirely.

Only promote to a terminal `content_filter` when the refusal is the sole
payload (no visible text AND no tool calls). The refusal explanation is still
recorded in provider_data in every case for observability. Refusal-only
responses (the bug this feature targets) are unaffected and still surface
terminally; the empty+refusal, bare content_filter passthrough, and no-refusal
common cases are byte-identical to before.

Updates the partial-content test to the corrected contract and adds a
tool_calls-alongside-refusal regression guard.
2026-06-14 12:12:52 +05:30
SHL0MS
ab26541b9a test(transports): lock in content_filter passthrough for OpenRouter
OpenRouter (and every other OpenAI-compatible provider) uses the default
chat_completions transport, so it is already covered by the refusal fix:
an upstream Claude / moderation refusal arrives as
finish_reason="content_filter" (often empty content, no message.refusal).
Add a regression test asserting the transport passes that finish reason
straight through to the loop's content_filter handler.

(cherry picked from commit 60168a513b)
2026-06-14 12:10:08 +05:30
SHL0MS
bb46bf8ce4 fix(agent): surface model refusals instead of retrying them as errors
A Claude refusal (HTTP 200, stop_reason="refusal", empty content) was
laundered into a generic retry loop and surfaced as a misleading
"rate limited / invalid response" or "no content after retries" error,
burning paid attempts reproducing a deterministic refusal.

This hit two distinct paths:

- Direct Anthropic (anthropic_messages): validate_response rejected the
  empty-content refusal *before* normalize_response mapped refusal ->
  content_filter, so it fell into the invalid-response retry loop.
- Nous Portal / OpenAI-compatible (chat_completions): the portal surfaces
  a Claude refusal via message.refusal with empty content, which sailed
  past validation and died in the empty-response retry loop.

Fix (one unified content_filter dispatch for all backends):
- AnthropicTransport.validate_response: accept empty content when
  stop_reason == "refusal" so it flows to normalize_response.
- ChatCompletionsTransport.normalize_response: promote message.refusal to
  content + a content_filter finish reason.
- conversation_loop: handle finish_reason == "content_filter" - fire the
  api_request_error hook (content_policy_blocked), try a configured
  fallback once, else return a clear terminal refusal message. Never retry
  a deterministic refusal.

Supersedes #43084, which fixed only the direct-Anthropic path and could
not reach the chat_completions/portal path.

Tests: transport-level (validate_response refusal, message.refusal
promotion) + end-to-end loop (refusal surfaced, exactly one API call).

(cherry picked from commit 01f546f92c)
2026-06-14 12:10:08 +05:30
brooklyn!
4b5ba112ad fix: shrink images to reported provider dimension limit (#45979)
Parse provider-reported image pixel ceilings so many-image Anthropic requests can recover by shrinking Retina screenshots below the stricter limit instead of retrying the same rejected payload.
2026-06-14 01:07:43 -05:00
brooklyn!
cdf30a7ac6 Merge pull request #45866 from NousResearch/bb/desktop-notifications
feat(desktop): native OS notifications with per-type toggles
2026-06-14 00:36:38 -05:00
Brooklyn Nicholson
b0288ae9b6 feat(desktop): move completion-sound picker into Notifications settings
The turn-end sound is a notification concern, not an appearance one — relocate
the variant picker + preview from the Appearance tab to the Notifications tab
(its i18n keys move from settings.appearance to settings.notifications with it).
2026-06-14 00:31:09 -05:00
Brooklyn Nicholson
630a4ef03c feat(desktop): native OS notifications with per-type toggles
Adds a native OS notification system (Electron Notification, routed cross-OS)
distinct from the in-app toast feed. Before this, one hardcoded cue existed
(message.complete while document.hidden) with no settings or event coverage.

- Engine (store/native-notifications.ts): localStorage-backed prefs (master
  switch + per-kind toggles) and a gated dispatcher over five kinds — approval,
  input, turnDone, turnError, backgroundDone — with a 1s per-(kind,session)
  self-evicting throttle.
- Gating: "backgrounded" = document.hidden OR !document.hasFocus(), so an
  alt-tabbed window still counts as away. Completion kinds fire only when
  backgrounded and for the active session (no spam from a busy gateway);
  attention kinds (approval/input) also break through for off-screen sessions.
- Wired into real event sites (use-message-stream.ts): message.complete, error,
  approval/clarify/sudo/secret.request; backgroundDone from composer-status at
  the running -> exited transition.
- Click focuses the window and jumps to the originating session; approval
  notifications carry Approve/Reject buttons that resolve in place over
  approval.respond, mirroring the in-app Run/Reject bar.
- Settings: new Notifications panel (master + per-kind switches, test button
  with real OS-result feedback). Full i18n (en/ja/zh/zh-hant).
2026-06-14 00:31:03 -05:00
brooklyn!
b4ba3f5e3b feat(desktop): add curated completion cue for agent turn completion (#42480)
* feat(desktop): add curated completion sound bank for turn completion

Replace the prior haptic-only completion cue with a curated Web Audio completion sound flow, defaulting to the minimal two-note comfort preset while keeping alternate presets available for quick iteration. Play the cue on every message completion event (including background sessions) so turn-end feedback is consistent across active and non-active chats.

* refactor(desktop): drop done1 byte sample from completion bank

Keep the curated Web Audio presets only; the embedded sample added bulk without shipping as the default cue.

* feat(desktop): expand completion sounds and add Appearance picker

Add fourteen synthesized turn-end presets with preview in settings, persisted variant selection, and softer default mixing for late-night use.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(desktop): dedupe completion-sound resolver, trim audio comments

Make the store the single source of truth for the variant default + range
validation and have the sound lib import it (one-way lib→store edge, no
cycle), instead of two divergent copies. Extract the shared white-noise
buffer used by the air/whoosh voices and cut the synth comments down to
why-only notes.

---------

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 00:21:40 -05:00
Teknium
8f278403d1 perf(execute-code): stop waiting on idle RPC accept (#45948) 2026-06-13 21:57:15 -07:00
Teknium
1b16c48170 fix: guard OAuth account removal 2026-06-13 21:47:13 -07:00
Flownium
e986e3fc68 fix: add provider account removal 2026-06-13 21:47:13 -07:00
Justin Sunseri
12682d96b9 feat(telegram): restore rich messages opt-out
Salvages PR #45840's client-compatibility opt-out while keeping rich messages enabled by default via telegram.extra.rich_messages: true.
2026-06-13 21:45:49 -07:00
aimable100
8d5d36d793 fix(dispatch): forward session_id into registry.dispatch (#28479)
Both the regular and execute_code dispatch paths forward task_id into
registry.dispatch via middleware _dispatch lambdas but silently dropped
session_id. Dispatch-layer hooks (e.g. set_enforcement_fn) that correlate
calls with the active session received "" for every invocation.

Pass session_id=session_id at both _dispatch call sites inside
handle_function_call, matching the existing task_id pattern. Hooks
already received session_id; this closes the registry.dispatch gap.

Rebased onto current main where dispatch is wrapped by
run_tool_execution_middleware — the old direct-dispatch sites from
#28479 no longer exist.

test(dispatch): add tests for session_id forwarding (NousResearch#28479)

Covers standard and execute_code paths through the middleware wrapper.
Verifies task_id forwarding is not broken by the change.
2026-06-14 00:27:59 -04:00
Teknium
7aaae7acd0 fix(ssl): align guard docs and escape hatch 2026-06-13 21:14:32 -07:00
Teknium
73d1357747 style(agent): keep run_agent import order stable 2026-06-13 21:14:32 -07:00
Teknium
af1995a838 chore(release): map chromalinx noreply author 2026-06-13 21:14:32 -07:00
Teknium
dc90ca4e17 fix(ssl): run CA guard during agent initialization 2026-06-13 21:14:32 -07:00
Teknium
af5b526472 fix(ssl): validate CA bundle paths before provider calls 2026-06-13 21:14:32 -07:00
chromalinx
b42c5bf652 test(ssl_guard): fix macOS fallback test that passed for the wrong reason
The previous test patched ssl.create_default_context globally with a bare
SSLContext that has zero CA certs. Both verify_ca_bundle() and the macOS
fallback got the same mocked context, so the test verified nothing useful:
both paths produced empty get_ca_certs() and the assertion that no
exception escaped was vacuously satisfied.

Only mock the fallback call (no cafile) — let the certifi call hit the
real SSL stack and fail with SSLError on the broken PEM. The mock
fallback returns a context with load_default_certs() so the test now
verifies the real scenario: broken certifi → SSLConfigurationError,
macOS system trust store → success.

Also pads the broken PEM past the 1 KB size guard so the size check
doesn't short-circuit before ssl.create_default_context(cafile=...) runs.

Reported by @liuhao1024 in PR review.
2026-06-13 21:14:32 -07:00
chromalinx
a218a0f156 fix(agent,gateway,doctor): add SSL CA cert bundle fail-fast guard
A stale certifi CA bundle after a partial `hermes update` used to crash
the agent on the first outbound HTTPS call with a raw traceback and
trap the gateway in a retry loop.

This patch:

* Adds `agent/errors.py` with a typed `SSLConfigurationError`
* Adds `agent/ssl_guard.py` with a `verify_ca_bundle()` pre-flight
  that asserts the bundle exists, is non-trivial in size, and can build
  a working SSLContext. On macOS, it falls back to the system trust
  store when the bundle is empty but the system store is healthy
  (covers corporate proxies / MDM setups).
* Wires the guard into `run_agent.py` and `gateway/run.py` right
  after the `hermes_bootstrap` import, inside a try/except so a bug
  in the guard itself can never prevent startup.
* Adds a `SSL / CA Certificates` section to `hermes_cli doctor` so
  users can detect the failure with one command.
* Adds unit tests covering the healthy, missing, empty, skip-env, and
  macOS-fallback paths.
* Adds an RCA document describing the failure mode and the recovery
  path (`pip install -e .`).

When the bundle is broken the user sees:

    \u26a0\ufe0f SSL certificate bundle issue detected.
       Run: pip install -e .

`HERMES_SKIP_SSL_GUARD=1` disables the check for sandboxed
environments that ship their own trust store.
2026-06-13 21:14:32 -07:00
Teknium
1106879147 perf(process): wake waiters on background completion (#45831) 2026-06-13 21:11:19 -07:00
brooklyn!
6b76284c77 fix(desktop): surface off-screen approvals via the jump-to-bottom control (#45853)
* fix(desktop): jump-to-approval pill for off-screen approvals

A blocked approval's only response surface is the inline Run/Reject bar on
the pending tool row. When that row is scrolled out of view the session looks
stalled with no visible action. Surface a composer-anchored "Approval needed"
pill only when an approval is pending AND its inline bar is scrolled away;
clicking scrolls the bar back into view. Preserves the deliberate inline (not
modal) approval design — the pill never duplicates the approve/reject controls.

The inline bar mirrors its own viewport visibility via IntersectionObserver
(tracks scroll/resize/layout) and registers a scroll-into-view handler the pill
fires, mirroring the existing thread-scroll jump-button bridge.

Supersedes #45828.

* fix(desktop): morph jump-to-bottom into approval prompt; drop scroll bridge

Collapse the separate "jump to approval" pill into the existing
scroll-to-bottom control: when scrolled away from the bottom while an approval
is pending, it relabels to "Approval needed". A parked approval's inline
Run/Reject bar is always the bottom-most content, so the existing
scroll-to-bottom action lands the user right on it — one control, no collision.

This also fixes the layout corruption from the first cut: the pill called
native el.scrollIntoView(), which scrolls every scrollable ancestor including
the overflow:hidden chat shell containers. Those have no scrollbar to scroll
back and don't remount on session switch, so the composer stayed shoved and
the breakage persisted across sessions. Reusing requestScrollToBottom() (the
use-stick-to-bottom path) only touches the one designated scroll container.

Removes the now-unused approval-scroll store + IntersectionObserver wiring.
2026-06-13 23:07:22 +00:00
Teknium
4026f526d5 chore(release): map MaxFreedomPollard author email 2026-06-13 15:01:42 -07:00
Max Pollard
9a2b976326 test(skills): add regression tests for bundled-update backup recovery
Three tests covering: a stale .bak poisoning a failed update's move/restore, an orphaned .bak misread as a user deletion, and a partially written dest blocking restore-on-failure. All three fail on current main without the fix.

Refs #44942
2026-06-13 15:01:42 -07:00
Max Pollard
3581131e7d fix(skills): make bundled-update backup handling crash-safe and idempotent
Recover an orphaned .bak before classification (interrupted updates no longer read as user deletions), clear a stale .bak before shutil.move (replace, not nest), and clear a partial dest before restore so restore-on-failure actually runs.

Fixes #44942
2026-06-13 15:01:42 -07:00
Teknium
bf8effad02 fix(utils): copy fallback for atomic replace across devices (#43852)
Fallback from `os.replace` on EXDEV/EBUSY using copy+fsync+unlink while preserving symlink target semantics and metadata.
2026-06-13 14:50:05 -07:00
Teknium
817f392311 feat(read): extract notebook and office documents (#37082)
Add stdlib-only extraction for `.ipynb`, `.docx`, and `.xlsx` in read_file with lazy integration and malformed-document fallback.
2026-06-13 14:42:51 -07:00
Teknium
2b67e96aec fix(approval): gate in-place edits to sensitive user files
Cover sed, perl, and ruby in-place mutations against shell rc, SSH, and credential files so terminal approvals pair the redirection and copy guards.
2026-06-13 14:35:27 -07:00
helix4u
abd69b8117 fix(approval): detect absolute home shell rc writes 2026-06-13 14:35:27 -07:00
briandevans
da28d5d113 fix(security): gate cp/mv/install into ~/.ssh, credential, and shell-rc files
tools/approval.py already denies tee/redirection writes to every
_SENSITIVE_WRITE_TARGET (~/.ssh/*, ~/.netrc/.pgpass/.npmrc/.pypirc, shell
rc files, ~/.hermes/config.yaml/.env) via the DANGEROUS_PATTERNS tee/`>`
rules, but cp/mv/install were only paired for _SYSTEM_CONFIG_PATH (/etc) and
the project-relative env/config target. So `cp evil ~/.ssh/authorized_keys`
(SSH-key implant / persistence), `cp creds ~/.netrc`, and `cp evil ~/.bashrc`
(login-time command injection) auto-approved while the equivalent tee/`>`
forms were denied — an unpaired write deny is theater (same rationale as
#14639 / commit 4e9d886d, which paired the terminal side for
~/.hermes/config.yaml writes but did not touch these cp/mv/install verbs on
the broader sensitive set).

Add one (cp|mv|install) DANGEROUS_PATTERNS entry reusing the existing
_SENSITIVE_WRITE_TARGET fragment, anchored via _COMMAND_TAIL so it fires on
the destination (last arg) only: reading OUT of a sensitive path
(`cp ~/.ssh/config /tmp/x`) stays auto-approved. Description differs from the
system-config cp entry so the two keep distinct approval keys (no silent
cross-approval). Additive — does not subsume the /etc or project-config rules.

Adds TestSensitiveCopyMovePattern: 5 positive cases (ssh authorized_keys,
ssh private key via mv, netrc via install, bashrc, ~/.hermes/config.yaml) +
2 negative guards (copy FROM ssh, unrelated copy). The ssh/netrc/bashrc
positives fail on main and pass on this branch; the negatives stay green
both ways.
2026-06-13 14:35:27 -07:00
Teknium
1fa761f8de fix(search): keep partial results on search timeout (#36142)
Treat search command budget timeouts as soft truncation so partial results survive, while real search failures still return structured errors.
2026-06-13 14:35:21 -07:00
Teknium
069bfd6545 fix(agent): keep Codex reasoning replay on Codex path 2026-06-13 14:35:00 -07:00
briandevans
1d584a301e fix(agent): treat Codex reasoning items as thinking-only 2026-06-13 14:35:00 -07:00
ITheEqualizer
57c2a55be4 fix(telegram): harden rich message fallback handling
Carry forward focused follow-ups from PR #45741: treat PTB's raw Bot API 10.1 response shapes safely, recognize real missing-endpoint errors, preserve link preview settings on rich sends, and lock the rich limit to Telegram's character-based cap.
2026-06-13 14:34:53 -07:00
brooklyn!
0a865e5948 fix(desktop): bypass Chromium editing pipeline for large paste & select-delete (#45812)
Large paste and Ctrl+A → Delete froze the composer for seconds — both routed
through Chromium's contenteditable editing pipeline (~O(n²) on multiline DOM).

- insertPlainTextAtCaret: Range + text/<br> fragment (paste path)
- deleteSelectionInEditor: range.deleteContents for non-collapsed Backspace/Delete
- Shared composerSelectionRange helper; both flush via flushEditorToDraft

Profiled live (47 KB / 122 paragraphs): paste 4474 ms → 13 ms; select-delete
1304 ms → 4 ms. Collapsed-caret deletes still native.
2026-06-13 20:49:58 +00:00
Teknium
c8e5f34f24 fix(gemini): strip native self prefixes before generateContent (#36141)
Strip `google/` and `gemini/` self-prefixes before native Gemini generateContent calls, and keep provider-normalization expectations aligned.
2026-06-13 13:47:08 -07:00
briandevans
7d11fa4e9e fix(codex-responses): let final_answer complete top-level incomplete responses 2026-06-13 13:45:29 -07:00
ITheEqualizer
7c0605bf22 fix(telegram): preserve rich formatting on stream final 2026-06-13 13:44:45 -07:00
achaljhawar
819def44c7 fix(agent): scope Nous tags to Nous auxiliary calls 2026-06-13 13:24:40 -07:00
Teknium
08890d77e6 fix(plugins): normalize browser-pasted GitHub repo URLs (#33539)
Accept common GitHub web URLs in `hermes plugins install` by normalizing repository views back to cloneable `.git` URLs, with focused parser coverage.
2026-06-13 13:23:59 -07:00
brooklyn!
425e777f54 fix(desktop): polish slash command completion (space/tab/click + typed args) (#45760)
* fix(desktop): accept slash command on space at command stage

Pressing space on a no-arg slash command (e.g. /hermes-agent) fell
through to the arg-completion stage and dead-ended on "No matches"
instead of inserting the directive. Space now mirrors Tab/Enter while
the command name is still being typed: no-arg commands commit the chip,
arg-taking commands expand to their options step.

* fix(desktop): suppress arg popover for no-arg slash commands

Committing a no-arg command (`/hermes-agent `) re-detected the chip+space
as an arg query and re-opened the popover on "No matches". The arg-stage
menu now only opens when the command actually takes args.

* fix(desktop): polish slash arg completion (space/tab/click + typed args)

Unify Enter/Tab/Space accept of the highlighted item at both the command
and arg stages: no-arg commands commit a chip, arg commands expand to
options, and an arg option commits the full `/cmd arg` chip. A fully-typed
arg (which the backend completer drops from suggestions) now commits on
Space/Tab via the verbatim text instead of dead-ending, and the "No
matches" empty state is suppressed past a command's name. Space stays
slash-only so @ mentions keep a literal space.
2026-06-13 18:43:52 +00:00
kshitij
7be22e37e1 Merge pull request #45753 from kshitijk4poor/salvage/gateway-auto-resume-duplicate-agent
fix(gateway): claim session slot before auto-resume task to prevent duplicate agents (#45456)
2026-06-13 23:46:17 +05:30
kshitijk4poor
28902dc890 chore: map liuhao1024 contributor email for attribution 2026-06-13 23:39:49 +05:30
kshitijk4poor
63097ee0d7 test(gateway): cover auto-resume full-path no-regression; clarify guard docstring
The salvaged fix's two regression tests mock adapter.handle_message, so
they only assert the pre-claimed sentinel is set/cleaned around a stub —
they never drive the real dispatch chain. Add a full-path test that
exercises _schedule_resume_pending_sessions -> _guarded_handle_message ->
adapter.handle_message -> _process_message_background -> _handle_message
and asserts the resumed session's agent runs EXACTLY ONCE: not zero (the
pre-claim must not self-bounce the resume into a queued no-op) and not
twice (the duplicate-agent bug #45456 the fix targets). Also assert no
leaked sentinel and no orphaned pending event after the drain settles.

Tighten the _guarded_handle_message docstring: on current main the real
sentinel is taken over inside _handle_message (not _process_message_background),
and note the `is _AGENT_PENDING_SENTINEL` guard only releases the slot we
ourselves placed, never one a live run owns.
2026-06-13 23:39:35 +05:30
liuhao1024
6e2fd955ca fix(gateway): claim session slot before auto-resume task to prevent duplicate agents
When the gateway restarts and auto-resumes an interrupted session, an
inbound message arriving in the window between `asyncio.create_task()`
and the task's first await could spin up a second AIAgent for the same
session.  Both agents would then process messages concurrently,
producing interleaved duplicate responses (#45456).

Fix: set `_AGENT_PENDING_SENTINEL` in `_running_agents` immediately
after the "already running" check, before creating the task.  This
closes the race window — any inbound message sees the slot as occupied
and queues behind the auto-resume.

A `_guarded_handle_message` wrapper ensures the pre-claimed sentinel is
always released, even if `handle_message` raises before reaching
`_process_message_background` (whose `finally` block handles normal
cleanup).

(cherry picked from commit 85150c976b)
2026-06-13 23:36:51 +05:30
helix4u
78c11d99e3 fix(update): stop Windows gateways before mutating install 2026-06-13 10:46:08 -07:00
ashishpatel26
957a8ffa88 fix(bedrock): omit sampling params for restricted Claude models
Bedrock Converse rejects non-default sampling parameters for Opus 4.7 and 4.8 with a ValidationException. Reuse the Anthropic-native sampling-param guard in the Bedrock kwargs builder so those models omit temperature/topP while older Claude and non-Claude models keep existing behavior.

Includes the stop-sequence regression from the parallel fix to ensure stopSequences still pass through for restricted Opus models.

Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
2026-06-13 10:45:56 -07:00
Teknium
cc14b74718 docs(profile): update clone-from references 2026-06-13 07:33:58 -07:00
Teknium
9b5f7b63c6 fix(profile): make clone-from a full source selector 2026-06-13 07:33:58 -07:00
Teknium
d146b85173 chore(release): map WompaJango author 2026-06-13 07:33:58 -07:00
WompaJango
28bf8fb47d feat(dashboard): clone profiles from any source 2026-06-13 07:33:58 -07:00
Que0x
3380563d94 fix(security): stop /api/status leaking host paths and PID on gated binds
The dashboard's public /api/status liveness endpoint is in PUBLIC_API_PATHS
and bypasses dashboard auth, yet it returned absolute hermes_home,
config_path, env_path, the gateway PID, and the internal gateway health URL.
That exceeds the shape its own allowlist documents as public ("version,
gateway state, active session count, and the dashboard auth-gate shape. No
bodies, no session content, no secrets"), leaking deployment recon to any
unauthenticated caller on a network-exposed (gated) bind.

Withhold host-local detail unless the bind is loopback / --insecure, where
the dashboard is local-only and the caller is already inside the trust
envelope -- the same split should_require_auth draws. The NAS liveness probe
and the auth-gate badge are unaffected.

Adds invariant tests for both modes (gated withholds, loopback keeps).
2026-06-13 07:18:59 -07:00
Teknium
ad7436a5d9 fix(gateway): preserve WeCom per-group sender allowlists
Keep the own-policy fail-closed hardening from PR #45444, but still trust WeCom groups.<id>.allow_from because the adapter already checked that sender allowlist before dispatching to gateway auth.
2026-06-13 07:18:54 -07:00
Que0x
fc46354580 fix(security): fail closed when an own-policy gateway adapter has no allowlist
Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default dm_policy/group_policy to "open", which forwards every sender. The gateway's adapter-trust shortcut in _is_user_authorized blanket-trusted those platforms when no env allowlist was set, so an operator who enabled one with only credentials authorized the entire external network -- the fail-open SECURITY.md section 2.6 forbids ("an allowlist is required for every enabled network-exposed adapter").

Trust the adapter only when its effective policy for the chat type is an actual "allowlist" restriction (the case #34515 was protecting). "open"/"pairing"/anything else falls through to default-deny, where {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS and the pairing flow remain the explicit opt-ins.
2026-06-13 07:18:54 -07:00
Teknium
1185dfd773 test: cover legacy Office document extensions 2026-06-13 07:18:37 -07:00
Clayton Chew
f82cb48120 fix(platform): add .xls, .doc, .ppt to SUPPORTED_DOCUMENT_TYPES
Old Office formats (.xls, .doc, .ppt) were missing from the
SUPPORTED_DOCUMENT_TYPES dict in gateway/platforms/base.py while their
newer counterparts (.xlsx, .docx, .pptx) were included.

Sending an .xls file via Telegram triggers 'Unsupported document type'
and the file is silently dropped instead of being cached and forwarded
to the agent.

Add the three legacy MIME types so these files are handled the same way
as their modern equivalents.
2026-06-13 07:18:37 -07:00
Tranquil-Flow
4fd9397ae3 fix(codex): drop extra_headers for chatgpt.com backend 2026-06-13 07:13:24 -07:00
Sarvesh
45f9099e51 fix(matrix): preserve markdown table structure 2026-06-13 06:57:08 -07:00
Teknium
4373e802a1 fix(docs): reuse healthy skills index during Pages deploys (#45616) 2026-06-13 06:46:07 -07:00
Teknium
d206e1f51d fix(dashboard): keep local file browser on home 2026-06-13 06:39:38 -07:00
konsisumer
16fb573bae fix(gateway): clear bloated compression binding on compression-exhaustion auto-reset
After compression exhaustion the auto-reset created a fresh session but
discarded reset_session()'s return value and left the Telegram topic
binding pointing at the oversized compressed child. The next inbound
message in that topic healed the binding forward and switch_session'd the
freshly-reset lane back onto the bloated transcript, re-triggering
compression exhaustion in a loop with a new session id each time.

Capture the fresh entry and re-sync the topic binding to it so the next
message starts clean. No-op on non-topic lanes.

Regression of the #9893/#10063 auto-reset fix.

Fixes #35809
2026-06-13 06:38:29 -07:00
Teknium
6f43ff5572 chore(release): map Gemini schema contributor 2026-06-13 06:12:52 -07:00
Henrik Bentel
eed61a1251 fix(gemini): add role field to systemInstruction 2026-06-13 06:12:52 -07:00
Teknium
74c5158b10 fix(model): show bare custom endpoints in gateway picker (#45597)
Surface direct model.provider=custom endpoints in /model picker output and keep explicit bare custom switches on the current endpoint instead of requiring a named providers/custom_providers row.
2026-06-13 06:05:30 -07:00
Teknium
6724daa2c2 fix: keep CLI idle timer ticking (#45592) 2026-06-13 05:55:04 -07:00
Teknium
aa53a78d67 fix(desktop): hand off Windows bootstrap recovery (#45594) 2026-06-13 05:54:32 -07:00
Teknium
0333a99925 fix: merge session-only model analytics rows (#45582) 2026-06-13 05:52:42 -07:00
Tranquil-Flow
5acd185f7c fix(moonshot): handle union type arrays in tool schemas 2026-06-13 05:51:41 -07:00
Teknium
39a35b784f chore(release): map custom provider resume contributors 2026-06-13 05:51:05 -07:00
Adalsteinn Helgason
2667601c05 fix(tui): keep reasoning-only assistant turns visible on session resume
A thinking-only assistant turn (reasoning present, empty visible text) is
persisted with its reasoning fields and stays recallable from the transcript,
but `_history_to_messages` dropped it as "empty" before its reasoning was
attached. On desktop/TUI resume or reload the turn therefore vanished from the
session view while the agent could still recall it from a fresh session --
exactly the "messages disappear when the LLM uses its thinking block, but a new
session can recall them" symptom reported on #44022.

Keep an assistant turn when it carries reasoning, even with empty text, so the
desktop "Thinking…" disclosure has something to render. Genuinely empty turns
(no text, no reasoning, no tool calls) are still filtered out.

Refs #44022

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Adalsteinn Helgason
643dc82793 Fix custom provider identity loss in session persistence
_runtime_model_config persisted the live agent's RESOLVED provider into
the session row's model_config JSON. For any named providers:/
custom_providers: entry, agent.provider is the literal string "custom",
so the entry name was lost (and the api_key is deliberately never
persisted). On session.resume or _reset_session_agent the stored
provider="custom" fed resolve_runtime_provider(requested="custom"),
which cannot match a named entry — the rebuild either raised "No LLM
provider configured" or silently resolved placeholder credentials
against the patched-back base_url.

Persist the REQUESTED/entry identity instead: a new reverse lookup
find_custom_provider_identity(base_url) maps the endpoint URL back to
the canonical custom:<name> menu key. _runtime_model_config stores that
key; _make_agent performs the same recovery for rows persisted before
the fix, falling back to passing the stored base_url as
explicit_base_url so the direct-alias branch still targets the
session's endpoint when no entry matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Haozhe Zhang
e256f4aae4 fix(gateway): don't restore a bare billing provider as the resumed session's provider
`_stored_session_runtime_overrides` restored the session provider from
`billing_provider` when `model_config` had no explicit provider. For a
`custom:<name>` endpoint that only ran normal turns (no `/model` switch), the
persisted `billing_provider` is the bare billing bucket `"custom"`, which
`agent_init` treats as non-routable, so `session.resume` failed with
"No LLM provider configured" even though new chats and CLI `--resume` work.

Only restore an explicit `model_config.provider`; skip a bare billing bucket
(`auto`/`openrouter`/`custom`) so resume falls back to the configured default,
matching the CLI path.

Fixes #44022
2026-06-13 05:51:05 -07:00
Teknium
cb125c2b3f fix(kanban): pin assigned profile toolsets for workers (#45590) 2026-06-13 05:50:09 -07:00
Teknium
a59d5e37e8 feat(telegram): make rich messages always on (#45584)
Remove the rich_messages config toggle entirely so Telegram replies always try the Bot API 10.1 rich-message path first, with the existing MarkdownV2 fallback/latch behavior for unsupported endpoints and per-message failures.

Restore the Telegram platform hint to encourage rich Markdown tables/task lists/math now that the rich path is the default, and remove the config/docs surface for the old toggle.
2026-06-13 05:45:11 -07:00
Teknium
4b646bc21e fix(auxiliary): preserve main provider base url (#45587) 2026-06-13 05:44:18 -07:00
Teknium
62b4618e9a fix(dashboard): scope sessions and analytics to selected profile (#45598) 2026-06-13 05:42:38 -07:00
H-Ali13381
2abcae9678 fix(cli): preserve renderer state on resize 2026-06-13 05:40:18 -07:00
xxxigm
c814d3d1dd test(installer): regression for unmerged-index update failure
Functional bash test drives install.sh's autostash block against a throwaway
repo with a real conflicted index and asserts the stash now succeeds and the
unmerged entries are cleared (previously `git stash` failed with "could not
write index"). Source-order assertions cover both scripts to ensure the
`git reset` clear runs before `git stash push` (a no-op otherwise).
2026-06-13 05:19:44 -07:00
xxxigm
573b964dc7 fix(installer): clear an unmerged git index before stashing on update
When an existing install at $INSTALL_DIR has an unmerged index (files in a
"needs merge" state left by a previously interrupted update), the update path
ran `git stash` then `git checkout <branch>`. On a conflicted index `git stash`
aborts with "could not write index" and `git checkout` then aborts with "you
need to resolve your current index first" — surfacing to desktop/bootstrap
users as `git checkout main failed (exit 1)` and failing the whole install at
the repository stage.

Mirror the `hermes update` Python path (#4735): detect unmerged entries with
`git ls-files --unmerged` and clear the conflict state with `git reset` before
stashing. Working-tree changes are still captured by the subsequent stash, so
nothing is discarded; only the index-level conflict markers are dropped, which
lets the checkout proceed.

Fixed in both installers (install.sh and install.ps1) so the Windows GUI
installer and the POSIX one share the same recovery behavior.
2026-06-13 05:19:44 -07:00
Teknium
aa0798352a fix(auth): self-heal missing Codex access tokens
Recover Codex singleton auth entries that have a refresh token but no access token by adopting a valid Codex CLI token pair, matching the cron-time failure mode before falling back to the credential pool.
2026-06-13 05:15:26 -07:00
Kennedy Umege
311ff967de review: validate refresh_token, path-agnostic recovery log, map author email
Addresses PR review feedback:
- Validate refresh_token (not only access_token) before persisting the
  re-imported Codex token, so a half-token payload can't silently break the
  next refresh cycle.
- Make the recovery log path-agnostic ("Codex CLI auth.json") since
  _import_codex_cli_tokens can read $CODEX_HOME, not only ~/.codex.
- Add regression test: relogin-required + imported token missing refresh_token
  -> re-raise and persist nothing.
- Map kenmege@yahoo.com -> Kenmege in scripts/release.py AUTHOR_MAP
  (fixes the check-attribution job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Kennedy Umege
bd66e7e3fb fix(auth): self-heal Codex refresh_token rotation by reimporting from ~/.codex
Hermes keeps its own copy of the Codex OAuth token per profile and at the
top level, separate from the Codex CLI's ~/.codex/auth.json. OAuth
refresh_tokens are single-use, so when the Codex CLI (or another Hermes
process) rotates the shared token, the frozen copy's refresh_token goes
stale and refresh_codex_oauth_pure fails with a relogin-required error
(invalid_grant / refresh_token_reused / 401). Today that surfaces as a hard
401 on the turn — idle profiles and desktop sessions 401 "token_expired"
until a manual re-auth — even though ~/.codex/auth.json holds a fresh token.

_refresh_codex_auth_tokens now falls back to _import_codex_cli_tokens() (the
canonical Codex CLI store) when the stored refresh_token is rejected, adopts
and persists the fresh token, and lets the in-flight retry succeed. This
complements PR #6525 (force relogin on 401/403): we attempt automatic
recovery before surfacing a relogin prompt. Transient failures (e.g. 429
quota, relogin_required=False) are never self-healed — the stored token is
still valid there — so they re-raise unchanged, and the happy path is
untouched.

Adds tests/hermes_cli/test_auth_codex_self_heal.py covering: self-heal on
invalid_grant, no self-heal on 429 quota, re-raise when ~/.codex is absent,
and happy-path-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Teknium
2681c5a12d fix(photon): correct gateway start command (#45566) 2026-06-13 05:14:59 -07:00
xxxigm
fa2aba90b4 docs(docker): explain per-profile gateway ports for multi-profile setups
The Multi-profile section never explained how to reach more than one
profile from outside the container, and distinguishes the two surfaces
that people conflate:

- Hermes Desktop's Remote Gateway connects to a `hermes dashboard`
  backend (port 9119), and a single dashboard serves every co-located
  profile via its profile switcher (the target profile is sent per
  request; the backend opens that profile's HERMES_HOME). No per-profile
  port or second connection is needed for Desktop.
- OpenAI-compatible API clients (Open WebUI, LobeChat, /v1) talk to each
  profile's API server, which binds 8642 for every profile with no
  auto-allocation. Reaching a second profile from such a client needs a
  distinct `API_SERVER_PORT` in that profile's own `.env` (and the port
  must NOT go in the container-wide `environment:` block, or every
  profile collides on it).

Adds the create -> set port -> restart flow, the bridge port-publishing
note, and clarifies the default profile's connection is untouched.
2026-06-13 05:13:25 -07:00
xxxigm
5b857201b7 fix(profiles): correct misleading per-profile gateway port docstrings
The s6 profile-gateway docstrings claimed the bind port comes from a
`[gateway] port` key in config.yaml ("the single source of truth"). No such
key exists or is read anywhere — the API server port is resolved by
gateway/config.py from `API_SERVER_PORT` (or `platforms.api_server.extra.port`)
and defaults to 8642. The wrong reference actively misled a Docker user into
setting a non-functional `gateway.port`.

Point both docstrings (`S6ServiceManager._render_run_script`,
`_maybe_register_gateway_service`) at the real knob, and note the practical
consequence: since each supervised profile gateway loads its own HERMES_HOME,
two profiles left at the default both try to bind 8642 — each needs a distinct
`API_SERVER_PORT` in its own `.env`.
2026-06-13 05:13:25 -07:00
Teknium
905ed413d1 fix(doctor): avoid unsafe npm audit fallback
Root-level npm audit fix can crash with isDescendantOf on the same monorepo tree, so workspace audit advisories should explain the lockfile-bump path instead of recommending another manual npm fix command.
2026-06-13 05:09:56 -07:00
xxxigm
bea6c1c01f test(doctor): assert audit-fix hint avoids crashing form and explains build-tool advisories 2026-06-13 05:09:56 -07:00
xxxigm
a5e9b17ce3 fix(doctor): stop recommending the npm-crashing audit fix, explain build-tool advisories
`hermes doctor` flagged the web/ui-tui workspaces and told the user to run
`npm audit fix --workspace <name>`, which crashes current npm with
"Cannot read properties of null (reading 'edgesOut')" (an arborist bug with
workspace-filtered audit fix). Recommend the root-level `npm audit fix`
instead.

Even the root form can hit a known npm arborist crash (edgesOut /
isDescendantOf) on this monorepo tree, so add a note that these workspace
advisories are build-time tooling (esbuild/vite, etc.) — not runtime code —
and clear via a lockfile bump rather than a manual fix. This keeps doctor
from handing users a command that errors out and from implying a broken
Hermes install.
2026-06-13 05:09:56 -07:00
xxxigm
5d6c16e972 test(desktop): cover the inline command expander on the approval bar
Asserts the full command is absent until the Command toggle is clicked, then
rendered in full — guarding the long-command reveal path.
2026-06-13 05:08:37 -07:00
xxxigm
266b5a19f1 feat(desktop): expand the full command inline from the approval bar
The native desktop approval bar deliberately omits the command because the
pending tool row "already shows it" — but that row only renders a single
truncated line, and a pending row can't be expanded (it has no result yet). So
the full command was only reachable by opening the "Always allow" dropdown,
reading the modal, cancelling, then clicking Run — 4-5 clicks just to see what
you're approving.

Add a "Command" toggle to the approval bar that reveals the full
`request.command` inline (reusing the dialog's pre styling), default collapsed.
Approving a long command is now "expand, Run". Gated on a non-empty command so
zero-command approvals are unaffected.
2026-06-13 05:08:37 -07:00
Black-Kylin
202e318cb1 fix(gateway): sync compression session splits before failures
Salvages PR #25747 by preserving gateway session rotation even when a post-compression model call fails before returning final content.

Co-authored-by: Hermes <127238744+teknium1@users.noreply.github.com>
2026-06-13 04:51:59 -07:00
helix4u
2d474e39c7 fix(acp): preserve memory provider tools 2026-06-13 04:51:44 -07:00
Erosika
1544813bfe chore(honcho): replace example Telegram UID with placeholder 2026-06-11 15:06:07 -04:00
Erosika
2708c33c75 docs(honcho): anonymize example peer name to alice 2026-06-11 15:04:01 -04:00
Erosika
23a7458acf docs(website): cover gateway identity mapping in Honcho feature page
The identity-mapping keys never made it to the site docs. Add the three keys
to the config reference and a Gateway Identity Mapping section: when it
applies (gateway only, setup-gated), the intent tree, resolver order, the
un-pin orphan warning, and the deprecated pinPeerName alias.
2026-06-11 14:58:19 -04:00
Erosika
99feb03607 docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree
Drop pinPeerName from the key table (now a deprecated-alias note), and replace
the single/multi/hybrid 'deployment shapes' section with the gateway-gated
intent tree the wizard actually presents, including the [e] raw-edit hatch and
the un-pin pooling steer.
2026-06-10 16:15:17 -04:00
Erosika
d7dfeed6dc feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree
The single/multi/hybrid 'deployment shape' was a misnomer: these keys only
affect the gateway (the one entrypoint supplying a runtime user ID), and the
three preset names stamped a lossy taxonomy onto three orthogonal knobs while
hiding which keys got written.

Replace it with an intent-led tree gated on gateway detection:
- _gateway_platforms() lazily inspects the gateway config (best-effort, no
  hard dependency); the step auto-skips when no platform is connected.
- 'who talks to this?' → just me / me+others (pooled?) / only others, deriving
  pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result.
- [e] drops to a raw-knob editor for power users.
- The single→multi orphan guard survives as a pooling steer.
2026-06-10 16:14:24 -04:00
Erosika
bb5cb32838 refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key
The setup wizard wrote the legacy pinPeerName even though pinUserPeer is
the canonical key that outranks it in the resolver — so it had to scrub
the canonical key afterward to stop it winning. Write pinUserPeer directly
and migrate any legacy pinPeerName onto it on touch (setup load + clone),
which removes the precedence-fighting entirely.

Resolver still reads pinPeerName as a back-compat alias; that's deferred.
2026-06-10 16:07:53 -04:00
540 changed files with 34285 additions and 12282 deletions

View File

@@ -1,12 +1,11 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
permissions:
contents: read

View File

@@ -11,8 +11,20 @@ on:
- 'optional-skills/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
inputs:
skills_index_run_id:
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
required: false
type: string
rebuild_skills_index:
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
required: false
default: false
type: boolean
permissions:
contents: read
actions: read
pages: write
id-token: write
@@ -55,26 +67,81 @@ jobs:
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Build skills index (unified multi-source catalog)
- name: Prepare skills index (unified multi-source catalog)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
# Rebuild the unified catalog. The file is gitignored, so a fresh
# checkout starts without it and we want the freshest crawl in
# every deploy.
# The unified external catalog is expensive to crawl and can burn
# through the repository installation's GitHub API quota when several
# docs deploys land close together. Normal docs deploys therefore
# reuse the latest healthy catalog: first the artifact from a
# scheduled skills-index run, then the currently live index. Only a
# manual force rebuild does a fresh crawl here.
#
# This MUST be fatal. build_skills_index.py runs a health check and
# exits non-zero WITHOUT writing the output file when a source
# collapses (e.g. a GitHub API rate limit zeroes the github /
# claude-marketplace / well-known taps all at once). Letting the
# deploy continue would either (a) ship a degenerate index missing
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
# local-only catalog. Failing here keeps the last good deployment
# live (GitHub Pages serves the previous build) instead of
# publishing a broken catalog. Re-run the workflow once the
# transient rate limit clears.
# If we do crawl, the build remains fatal. build_skills_index.py runs
# the health check BEFORE writing and exits non-zero on source
# collapse, keeping the last good Pages deployment live instead of
# publishing a degenerate catalog.
set -euo pipefail
INDEX_PATH="website/static/api/skills-index.json"
mkdir -p "$(dirname "$INDEX_PATH")"
validate_index() {
python3 - "$INDEX_PATH" <<'PY'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
sys.exit(1)
skills = data.get("skills")
if not isinstance(skills, list) or len(skills) < 1500:
count = len(skills) if isinstance(skills, list) else "missing"
print(f"skills index too small: {count}", file=sys.stderr)
sys.exit(1)
print(f"skills index ready: {len(skills)} skills")
PY
}
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
python3 scripts/build_skills_index.py
validate_index
exit 0
fi
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
tmpdir="$(mktemp -d)"
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
if [ -n "$candidate" ]; then
cp "$candidate" "$INDEX_PATH"
if validate_index; then
exit 0
fi
fi
fi
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
fi
echo "Downloading currently live skills index"
if curl -fsSL --retry 3 --retry-delay 5 \
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
-o "$INDEX_PATH" && validate_index; then
exit 0
fi
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
rm -f "$INDEX_PATH"
python3 scripts/build_skills_index.py
validate_index
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py

View File

@@ -18,13 +18,12 @@ on:
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- Dockerfile
- docker/**
- .hadolint.yaml
- .github/workflows/docker-lint.yml
permissions:
contents: read

View File

@@ -11,16 +11,13 @@ on:
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
release:
types: [published]

View File

@@ -1,10 +1,12 @@
name: Docs Site Checks
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
paths:
- 'website/**'
- '.github/workflows/docs-site-checks.yml'
branches: [main]
workflow_dispatch:
permissions:
@@ -14,9 +16,9 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
@@ -26,9 +28,9 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
python-version: "3.11"
- name: Install ascii-guard
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3

View File

@@ -14,6 +14,9 @@ name: History Check
# the PR head and main to be non-empty.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -24,9 +27,9 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
fetch-depth: 0 # full history both sides for merge-base
- name: Reject PRs with no common ancestor on main
run: |

View File

@@ -15,12 +15,12 @@ on:
- "**/*.md"
- "docs/**"
- "website/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
permissions:
contents: read
@@ -154,7 +154,6 @@ jobs:
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /

View File

@@ -1,255 +0,0 @@
name: Nix Lockfile Fix
on:
push:
branches: [main]
paths:
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package.json'
- 'apps/desktop/package.json'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to fix (leave empty to run on the selected branch)'
required: false
type: string
issue_comment:
types: [edited]
permissions:
contents: write
pull-requests: write
concurrency:
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: false
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
# the paths filter above, so this cannot re-trigger itself.
# 2. An explicit file-whitelist check before commit aborts if
# fix-lockfiles ever modifies unexpected files.
# 3. Job-level concurrency with cancel-in-progress: true ensures
# back-to-back pushes collapse to the newest; ref: main checkout
# always operates on the latest branch state.
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
# triggers downstream nix.yml verification.
auto-fix-main:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 25
concurrency:
group: auto-fix-main
cancel-in-progress: true
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles -- --apply
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
# modified — prevents accidental self-triggering if fix-lockfiles
# ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
if [ -n "$unexpected" ]; then
echo "::error::Unexpected modified files: $unexpected"
exit 1
fi
# Record the base SHA before committing — used to detect package
# file changes if we need to rebase after a non-fast-forward push.
BASE_SHA="$(git rev-parse HEAD)"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
-m "Source: $GITHUB_SHA" \
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
# Retry push with rebase in case main advanced with an unrelated
# commit during the nix build. Without this, a non-fast-forward
# rejection silently loses the fix. If package files changed during
# the rebase, abort — a fresh auto-fix run will handle the new state.
for attempt in 1 2 3; do
if git push origin HEAD:main; then
exit 0
fi
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
git fetch origin main
# If package files changed between our base and the new main,
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
fi
git rebase origin/main
done
echo "::error::Failed to push after 3 rebase attempts"
exit 1
# ── PR fix (manual / checkbox) ─────────────────────────────────────
# Existing behavior: run on manual dispatch OR when a task-list
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
fix:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Authorize & resolve PR
id: resolve
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
// 1. Verify the actor has write access — applies to both checkbox
// clicks and manual dispatch.
const { data: perm } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.actor,
});
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
core.setFailed(
`${context.actor} lacks write access (has: ${perm.permission})`
);
return;
}
// 2. Resolve which ref to check out.
let prNumber = '';
if (context.eventName === 'issue_comment') {
prNumber = String(context.payload.issue.number);
} else if (context.eventName === 'workflow_dispatch') {
prNumber = context.payload.inputs.pr_number || '';
}
if (!prNumber) {
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
core.setOutput('repo', context.repo.repo);
core.setOutput('owner', context.repo.owner);
core.setOutput('pr', '');
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('ref', pr.head.ref);
core.setOutput('repo', pr.head.repo.name);
core.setOutput('owner', pr.head.repo.owner.login);
core.setOutput('pr', String(pr.number));
# Wipe the sticky lockfile-check comment to a "running" state as soon
# as the job is authorized, so the user sees their click was picked up
# before the ~minute of nix build work.
- name: Mark sticky as running
if: steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### 🔄 Applying lockfile fix…
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
ref: ${{ steps.resolve.outputs.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Apply lockfile hashes
id: apply
run: nix run .#fix-lockfiles
- name: Commit & push
if: steps.apply.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/lib.nix
git commit -m "fix(nix): refresh npm lockfile hashes"
git push
- name: Update sticky (applied)
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile fix applied
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (already current)
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ✅ Lockfile hashes already current
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
- name: Update sticky (failed)
if: failure() && steps.resolve.outputs.pr != ''
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
number: ${{ steps.resolve.outputs.pr }}
message: |
### ❌ Lockfile fix failed
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.

View File

@@ -1,105 +0,0 @@
name: Nix
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
pull-requests: write
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/nix-setup
with:
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Resolve head SHA
if: github.event_name == 'pull_request'
id: sha
shell: bash
run: |
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
echo "full=$FULL" >> "$GITHUB_OUTPUT"
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
- name: Check flake
id: flake
continue-on-error: true
run: nix flake check --print-build-logs
# When the flake check fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
run: nix run .#fix-lockfiles -- --check
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
# etc.) it won't set stale=true/false. Treat that as a distinct failure
# mode rather than silently ignoring it.
- name: Fail if hash check crashed without reporting
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
run: |
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
exit 1
- name: Post sticky PR comment (stale hashes)
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
message: |
### ⚠️ npm lockfile hash out of date
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
${{ steps.hash_check.outputs.report }}
#### Apply the fix
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the flake check passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (check failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
(steps.hash_check.outputs.stale == 'false' ||
steps.flake.outcome == 'success')
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if flake check failed
if: steps.flake.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix flake check failed. See logs above."
fi
exit 1

View File

@@ -20,29 +20,23 @@ name: OSV-Scanner
# vulnerabilities in pinned deps that we may need to patch deliberately.
on:
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
push:
branches: [main]
paths:
- 'uv.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'website/package-lock.json'
- "uv.lock"
- "pyproject.toml"
- "package.json"
- "package-lock.json"
- "website/package-lock.json"
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: '0 9 * * 1'
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
@@ -54,7 +48,7 @@ permissions:
jobs:
scan:
name: Scan lockfiles
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.

View File

@@ -53,4 +53,4 @@ jobs:
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}

View File

@@ -1,11 +1,11 @@
name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
# No paths filter — the jobs must always run so required checks
# report a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
@@ -29,8 +29,10 @@ jobs:
scan: ${{ steps.filter.outputs.scan }}
# True when pyproject.toml changed in this PR
deps: ${{ steps.filter.outputs.deps }}
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
@@ -54,6 +56,14 @@ jobs:
else
echo "deps=false" >> "$GITHUB_OUTPUT"
fi
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'optional-mcps/**' \
'hermes_cli/mcp_catalog.py' || true)
if [ -n "$MCP_CATALOG_FILES" ]; then
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
else
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
fi
scan:
name: Scan PR for critical supply chain risks
@@ -62,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -197,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -268,3 +278,50 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
mcp-catalog-review:
name: MCP catalog security review
needs: changes
if: needs.changes.outputs.mcp_catalog == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require explicit MCP catalog review label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
echo "MCP catalog review label present."
exit 0
fi
BODY="## ⚠️ MCP catalog security review required
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
A maintainer should verify:
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
- stdio transports do not use shell+egress/exfiltration payloads,
- git install refs are pinned and bootstrap commands are minimal,
- requested env vars/secrets match the upstream MCP's documented needs.
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
exit 1
mcp-catalog-review-gate:
name: MCP catalog security review
needs: changes
if: always() && needs.changes.outputs.mcp_catalog != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No MCP catalog changes, skipping MCP catalog security review."

View File

@@ -6,11 +6,11 @@ on:
paths-ignore:
- "**/*.md"
- "docs/**"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
permissions:
contents: read
@@ -219,4 +219,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""

View File

@@ -4,6 +4,9 @@ name: Typecheck
on:
push:
branches: [main]
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
@@ -23,3 +26,20 @@ jobs:
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run --prefix apps/desktop build

View File

@@ -47,15 +47,15 @@ on:
push:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/uv-lockfile-check.yml"
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
branches: [main]
paths:
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/uv-lockfile-check.yml'
permissions:
contents: read
@@ -71,10 +71,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.

View File

@@ -78,7 +78,41 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
### Clone and install
### Install with the standard installer
For most contributors, the best development bootstrap is the same path users
take: run the standard installer, then work inside the repository it cloned.
The installer creates the Hermes venv, wires the `hermes` command, stamps the
install method for `hermes update`, and clones the full git project into
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
development environment on the same layout the CLI, updater, lazy dependency
installer, gateway, and docs assume.
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
# Add dev/test extras on top of the standard install.
uv pip install -e ".[all,dev]"
# Optional: browser tools / docs site dependencies.
npm install
```
After that, create branches and run tests from that checkout:
```bash
git checkout -b fix/description
scripts/run_tests.sh
```
### Manual clone fallback
Use this only if you intentionally do not want Hermes' managed install layout
(for example, a throwaway clone inside a container or CI job). If you install
this way, make sure you run the `hermes` entrypoint from this venv; running the
system `python3 -m hermes_cli.main` can pick up unrelated system Python
packages.
```bash
git clone https://github.com/NousResearch/hermes-agent.git
@@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
### Run
```bash
# Symlink for global access
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
# Verify
# The standard installer already put `hermes` on PATH.
hermes doctor
hermes chat -q "Hello"
```
If you used the manual clone fallback, run `./hermes` from the checkout or
symlink this clone's venv explicitly:
```bash
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
```
### Run tests
```bash

View File

@@ -181,16 +181,20 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
Quick start for contributors — clone and go with `setup-hermes.sh`:
Quick start for contributors — use the standard installer, then work from the
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
managed venv, lazy dependencies, gateway, and docs tooling.
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
./hermes # auto-detects the venv, no need to `source` first
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
Manual path (equivalent to the above):
Manual clone fallback (for throwaway clones/CI where you intentionally do not
want the managed install layout):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh

View File

@@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
贡献者快速开始——克隆并使用 `setup-hermes.sh`
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
./hermes # 自动检测 venv无需先 source
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
手动安装(等效于上述命令
手动克隆备用路径(用于一次性 clone / CI或你明确不想使用 managed install layout 时
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh

View File

@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
enabled_toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
inject_memory_provider_tools(state.agent)
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
)
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
tool_view = SimpleNamespace(
tools=list(tools or []),
valid_tool_names={
tool.get("function", {}).get("name")
for tool in tools or []
if isinstance(tool, dict)
},
enabled_toolsets=toolsets,
_memory_manager=getattr(state.agent, "_memory_manager", None),
)
inject_memory_provider_tools(tool_view)
tools = tool_view.tools
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]

View File

@@ -27,7 +27,7 @@ import threading
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor
@@ -195,6 +195,7 @@ def init_agent(
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@@ -299,6 +300,7 @@ def init_agent(
# would mangle the escape sequences. None = use builtins.print.
agent._print_fn = None
agent.background_review_callback = None # Optional sync callback for gateway delivery
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
@@ -425,6 +427,7 @@ def init_agent(
agent.status_callback = status_callback
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback
@@ -596,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the
@@ -900,6 +904,9 @@ def init_agent(
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
verify_ca_bundle_with_fallback()
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with model: {agent.model}")
@@ -1193,38 +1200,8 @@ def init_agent(
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
# Inject memory provider tool schemas into the tool surface.
# Skip tools whose names already exist (plugins may register the
# same tools via ctx.register_tool(), which lands in agent.tools
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
if isinstance(t, dict)
}
for _schema in agent._memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
if _tname:
agent.valid_tool_names.add(_tname)
_existing_tool_names.add(_tname)
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
_inject_memory_provider_tools(agent)
# Skills config: nudge interval for skill creation reminders
agent._skill_nudge_interval = 10

View File

@@ -881,6 +881,8 @@ def try_recover_primary_transport(
def drop_thinking_only_and_merge_users(
messages: List[Dict[str, Any]],
*,
drop_codex_reasoning_items: bool = True,
) -> List[Dict[str, Any]]:
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
@@ -902,7 +904,13 @@ def drop_thinking_only_and_merge_users(
return messages
# Pass 1: drop thinking-only assistant turns.
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
kept = [
m for m in messages
if not _ra().AIAgent._is_thinking_only_assistant(
m,
drop_codex_reasoning_items=drop_codex_reasoning_items,
)
]
dropped = len(messages) - len(kept)
if dropped == 0:
return messages
@@ -1209,12 +1217,23 @@ def dump_api_request_debug(
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
atomic_json_write(dump_file, dump_payload, default=str)
# Redact secrets before persisting/printing. This dump captures the
# full request body (system prompt, tool defs, context-embedded
# values), and this path fires unconditionally on API errors — so it
# otherwise lands any context-embedded secret in cleartext on disk.
# Run the serialized dump through the same scrubber used for logs/tool
# output, then hand the resulting payload back to the shared atomic
# JSON writer so request dumps keep the same write semantics as before.
from agent.redact import redact_sensitive_text
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
atomic_json_write(dump_file, _redacted_payload, default=str)
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
return dump_file
except Exception as dump_error:

View File

@@ -751,6 +751,9 @@ def build_anthropic_client(
from httpx import Timeout
normalized_base_url = _normalize_base_url_text(base_url)
if normalized_base_url:
import re as _re
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),

View File

@@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
if normalized.endswith("/anthropic"):
path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
return True
hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com":
@@ -3078,23 +3079,20 @@ def _try_configured_fallback_chain(
if not fb_provider or fb_provider.lower() == skip:
continue
fb_model = str(entry.get("model", "")).strip() or None
fb_base_url = str(entry.get("base_url", "")).strip() or None
fb_api_key = str(entry.get("api_key", "")).strip() or None
label = f"fallback_chain[{i}]({fb_provider})"
try:
fb_client = _resolve_single_provider(
fb_provider, fb_model, fb_base_url, fb_api_key)
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception:
fb_client = None
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, fb_model or "default",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
)
return fb_client, fb_model, label
return fb_client, resolved_model or fb_model, label
tried.append(label)
if tried:
@@ -3105,6 +3103,103 @@ def _try_configured_fallback_chain(
return None, None, ""
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve one fallback entry through the central provider router."""
provider = str(entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip() or None
if not provider or not model:
return None, None
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _fallback_entry_api_key(entry)
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
def _try_main_fallback_chain(
task: Optional[str],
failed_provider: str = "",
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try the top-level main-agent fallback chain for an auxiliary call.
``provider: auto`` auxiliary tasks should respect the user's declared
main fallback policy before dropping into Hermes' built-in discovery
chain. The top-level chain is read through ``get_fallback_chain`` so
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
if not chain:
return None, None, ""
failed_norm = (failed_provider or "").strip().lower()
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
fb_norm = fb_provider.lower()
label = f"fallback_providers[{i}]({fb_provider})"
if fb_norm in skip:
tried.append(f"{label} (skipped)")
continue
if _is_provider_unhealthy(fb_norm):
_log_skip_unhealthy(fb_norm, task)
tried.append(f"{label} (unhealthy)")
continue
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception as exc:
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
resolved_model or fb_model,
)
return fb_client, resolved_model or fb_model, fb_provider
tried.append(label)
if tried:
logger.debug(
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
task or "call", ", ".join(tried),
)
return None, None, ""
def _resolve_single_provider(
provider: str,
model: Optional[str] = None,
@@ -3115,16 +3210,19 @@ def _resolve_single_provider(
Uses the existing provider resolution infrastructure where possible.
"""
# Reuse resolve_provider_client which handles provider→client mapping
# Reuse resolve_provider_client which handles provider→client mapping.
client, resolved_model = resolve_provider_client(
provider=provider,
model=model,
base_url=base_url,
api_key=api_key,
explicit_base_url=base_url,
explicit_api_key=api_key,
)
return client
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(
main_runtime: Optional[Dict[str, Any]] = None,
task: Optional[str] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.
Priority:
@@ -3190,7 +3288,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
explicit_base_url = None
explicit_base_url = runtime_base_url or None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
@@ -3222,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
main_provider, resolved or main_model)
return client, resolved or main_model
# ── Step 2: aggregator / fallback chain ──────────────────────────────
# ── Step 2: user-configured fallback policy ─────────────────────────
# In auto mode, respect the task-specific fallback chain first, then the
# main agent's top-level fallback_providers/fallback_model chain. The
# hardcoded provider discovery chain below is only the convenience default
# for users who have not declared a fallback policy.
if task:
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
# ── Step 3: aggregator / fallback chain ──────────────────────────────
tried = []
for label, try_fn in _get_provider_chain():
if _is_provider_unhealthy(label):
@@ -3343,6 +3456,7 @@ def resolve_provider_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -3463,7 +3577,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto(main_runtime=main_runtime)
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
@@ -4356,11 +4470,16 @@ def _client_cache_key(
api_mode: Optional[str] = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@@ -4553,6 +4672,7 @@ def _get_cached_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -4590,6 +4710,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=main_runtime,
is_vision=is_vision,
task=task,
)
with _client_cache_lock:
if cache_key in _client_cache:
@@ -4634,6 +4755,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=runtime,
is_vision=is_vision,
task=task,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -5004,7 +5126,7 @@ def _build_call_kwargs(
# Provider-specific extra_body
merged_extra = dict(extra_body or {})
if provider == "nous" or auxiliary_is_nous:
if provider == "nous":
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
if merged_extra:
kwargs["extra_body"] = merged_extra
@@ -5139,7 +5261,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5465,14 +5587,19 @@ def call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# For auto users (no explicit aux provider), use the full
# auto-detection chain instead — its Step 1 IS the main agent
# model, so users on `auto` already get main-model fallback.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@@ -5635,7 +5762,7 @@ async def async_call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5903,13 +6030,19 @@ async def async_call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# Auto users get the full auto-detection chain instead — its
# Step 1 IS the main agent model.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)

View File

@@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
def summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Build the human-facing action summary for a background review pass.
Walks the review agent's session messages and collects "successful tool
action" descriptions to surface to the user (e.g. "Memory updated").
Tool messages already present in ``prior_snapshot`` are skipped so we
don't re-surface stale results from the prior conversation that the
review agent inherited via ``conversation_history`` (issue #14944).
Walks the review agent's session messages and collects successful memory
and skill-management actions to surface to the user. Tool messages already
present in ``prior_snapshot`` are skipped so stale inherited results are
not re-surfaced as fresh background work (issue #14944).
Matching is by ``tool_call_id`` when available, with a content-equality
fallback for tool messages that lack one.
``notification_mode`` controls display detail:
- ``off``: return no actions.
- ``on``: generic "Memory updated"/tool messages.
- ``verbose``: include compact content previews from tool-call arguments.
"""
mode = str(notification_mode or "on").lower()
if mode == "off":
return []
verbose = mode == "verbose"
existing_tool_call_ids = set()
existing_tool_contents = set()
for prior in prior_snapshot or []:
@@ -262,6 +269,42 @@ def summarize_background_review_actions(
if isinstance(content, str):
existing_tool_contents.add(content)
# Map review-agent tool results back to the calls that produced them. The
# result JSON only says "Entry added"; the call arguments contain action,
# target, and content previews. Restricting to notify_tools also prevents
# helper tools from surfacing as memory work just because they succeeded.
notify_tools = {"memory", "skill_manage"}
all_tool_call_ids: set = set()
call_details: dict = {}
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
fn_name = fn.get("name", "")
tcid = tc.get("id")
if tcid:
all_tool_call_ids.add(tcid)
if fn_name not in notify_tools:
continue
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
if tcid:
call_details[tcid] = {
"tool": fn_name,
"action": args.get("action", "?"),
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
}
actions: List[str] = []
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
@@ -273,6 +316,8 @@ def summarize_background_review_actions(
content_str = msg.get("content")
if isinstance(content_str, str) and content_str in existing_tool_contents:
continue
if tcid and all_tool_call_ids and tcid not in call_details:
continue
try:
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
target = data.get("target", "")
if "created" in message.lower():
actions.append(message)
elif "updated" in message.lower():
actions.append(message)
elif "added" in message.lower() or (target and "add" in message.lower()):
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "Entry added" in message:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "removed" in message.lower() or "replaced" in message.lower():
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
message_lower = message.lower()
if not verbose:
if "created" in message_lower:
actions.append(message)
continue
if "updated" in message_lower:
actions.append(message)
continue
if is_skill and "patched" in message_lower:
actions.append(message)
continue
if is_skill:
label = "Skill"
elif target:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
else:
continue
if verbose:
action = detail.get("action", "")
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
"" if len(old_string) > 80 else ""
)
new_preview = new_string[:80].replace("\n", " ") + (
"" if len(new_string) > 80 else ""
)
actions.append(
f"📝 Skill '{skill_name}' patched: "
f"\"{old_preview}\"\"{new_preview}\""
)
elif action == "create" and description:
actions.append(f"📝 Skill '{skill_name}' created: {description}")
elif action == "edit" and description:
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
elif action == "replace" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif action == "remove" and old_text:
preview = old_text[:60] + ("" if len(old_text) > 60 else "")
actions.append(f"{label} {preview}")
else:
actions.append(f"{label} updated")
elif (
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
actions.append(f"{label} updated")
return actions
@@ -522,6 +623,7 @@ def _run_review_in_thread(
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:

View File

@@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
_bedrock_control_client_cache: Dict[str, Any] = {}
_MIN_BOTO3_VERSION = (1, 34, 59)
def _require_boto3():
"""Import boto3, raising a clear error if not installed."""
"""Import boto3, raising a clear error if not installed or too old."""
try:
import boto3
return boto3
except ImportError:
raise ImportError(
"The 'boto3' package is required for the AWS Bedrock provider. "
"Install it with: pip install boto3\n"
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
)
# converse() / converse_stream() were added in boto3 1.34.59.
# When Hermes is installed editable into system Python, the system boto3
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
# version pinned in pyproject.toml.
try:
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
except (AttributeError, ValueError):
return boto3 # can't parse — don't block on version check
if version < _MIN_BOTO3_VERSION:
raise RuntimeError(
f"boto3 {boto3.__version__} does not support converse_stream "
f"(minimum 1.34.59 required). Upgrade with: "
f"pip install --upgrade boto3"
)
return boto3
def _get_bedrock_runtime_client(region: str):
@@ -935,11 +952,14 @@ def build_converse_kwargs(
if system_prompt:
kwargs["system"] = system_prompt
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
from agent.anthropic_adapter import _forbids_sampling_params
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if not _forbids_sampling_params(model):
if temperature is not None:
kwargs["inferenceConfig"]["temperature"] = temperature
if top_p is not None:
kwargs["inferenceConfig"]["topP"] = top_p
if stop_sequences:
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences

View File

@@ -1081,6 +1081,7 @@ def _normalize_codex_response(
message_items_raw: List[Dict[str, Any]] = []
tool_calls: List[Any] = []
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
saw_commentary_phase = False
saw_final_answer_phase = False
saw_reasoning_item = False
@@ -1095,6 +1096,7 @@ def _normalize_codex_response(
if item_status in {"queued", "in_progress", "incomplete"}:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True
if item_type == "message":
item_phase = getattr(item, "phase", None)
@@ -1252,7 +1254,9 @@ def _normalize_codex_response(
finish_reason = "tool_calls"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
elif saw_streaming_or_item_incomplete:
finish_reason = "incomplete"
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Response contains only reasoning (encrypted thinking state and/or

View File

@@ -40,6 +40,16 @@ from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
# drivers like the desktop app can show an explicit "Summarizing…" indicator
# instead of the transcript appearing to silently reset. Keep the marker phrase
# intact if you reword COMPACTION_STATUS.
COMPACTION_STATUS_MARKER = "Compacting context"
COMPACTION_STATUS = (
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -324,9 +334,7 @@ def compress_context(
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
focus_topic,
)
agent._emit_status(
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
)
agent._emit_status(COMPACTION_STATUS)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@@ -595,6 +603,20 @@ def compress_context(
force=True,
)
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.
@@ -631,7 +653,11 @@ def compress_context(
return compressed, new_system_prompt
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
def try_shrink_image_parts_in_messages(
api_messages: list,
*,
max_dimension: int = 8000,
) -> bool:
"""Re-encode all native image parts at a smaller size to recover from
image-too-large errors (Anthropic 5 MB, unknown other providers).
@@ -642,7 +668,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
``data:image/...;base64,...`` payload. For each one whose encoded
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
ceiling with header overhead), write the base64 to a tempfile, call
ceiling with header overhead) or whose longest side exceeds
``max_dimension``, write the base64 to a tempfile, call
``vision_tools._resize_image_for_vision`` to produce a smaller data
URL, and substitute it in place.
@@ -664,10 +691,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
# the 5 MB byte cap. In many-image requests, the provider can report a
# lower cap (observed: 2000px). The caller passes that parsed ceiling
# when the rejection includes it.
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
@@ -684,9 +710,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
# Even if bytes are fine, check pixel dimensions against the
# provider's reported per-side cap. A screenshot can be tiny in
# bytes yet too large in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
@@ -795,6 +821,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
__all__ = [
"COMPACTION_STATUS",
"COMPACTION_STATUS_MARKER",
"check_compression_model_feasibility",
"replay_compression_warning",
"compress_context",

View File

@@ -71,6 +71,35 @@ logger = logging.getLogger(__name__)
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
parts = []
for value in (
error,
getattr(error, "message", None),
getattr(error, "body", None),
):
if value:
try:
parts.append(str(value))
except Exception:
pass
text = " ".join(parts).lower()
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
return None
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
if not match:
return None
try:
max_dimension = int(match.group(1))
except ValueError:
return None
if 512 <= max_dimension <= 8000:
return max_dimension
return None
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
if not getattr(agent, "tools", None):
@@ -271,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc,
)
if stored_prompt:
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The
@@ -337,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""
def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value
stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False
stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False
return True
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3])
@@ -368,6 +430,42 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
)
# Shared recovery hint appended to every content-policy refusal message. Both
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
# exception path (a provider moderation error classified as
# ``content_policy_blocked``) end with the same actionable next steps, so they
# share one trailer to keep the guidance from drifting between the two sites.
_CONTENT_POLICY_RECOVERY_HINT = (
"Try rephrasing the request, narrowing the context, or "
"adding a fallback provider with `hermes fallback add`."
)
def _content_policy_blocked_result(
messages: List[Dict],
api_call_count: int,
*,
final_response: str,
error_detail: str,
) -> Dict[str, Any]:
"""Build the terminal turn result for a content-policy block.
A content-policy refusal is deterministic for the unchanged prompt, so the
turn ends here (no retry). Both the HTTP-200 refusal handler and the
exception-path handler return the identical shape — a failed, non-completed
turn carrying the user-facing message and a ``content_policy_blocked:``
prefixed error — so they funnel through this one builder.
"""
return {
"final_response": final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": f"content_policy_blocked: {error_detail}",
}
def run_conversation(
agent,
user_message: str,
@@ -376,6 +474,7 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -391,6 +490,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.
Returns:
@@ -412,6 +513,7 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
@@ -707,7 +809,10 @@ def run_conversation(
# a thinking-only turn. Runs on the per-call copy only — the
# stored conversation history keeps the reasoning block for the
# UI transcript and session persistence.
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
api_messages = agent._drop_thinking_only_and_merge_users(
api_messages,
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
)
# Normalize message whitespace and tool-call JSON for consistent
# prefix matching. Ensures bit-perfect prefixes across turns,
@@ -1316,6 +1421,106 @@ def run_conversation(
)
finish_reason = "length"
# ── Content-policy refusal (HTTP 200) ──────────────────
# The model — or the provider's safety system — returned a
# *successful* response whose stop/finish reason is a refusal:
# Anthropic ``stop_reason="refusal"`` → ``content_filter``;
# OpenAI / portal ``finish_reason="content_filter"`` or a
# populated ``message.refusal`` (mapped in the chat_completions
# transport); Bedrock ``guardrail_intervened``. The content is
# typically empty, so without this branch the response falls
# through to the empty-response / invalid-response retry loops
# and is mis-surfaced as "rate limited" / "no content after
# retries" — burning paid attempts reproducing a deterministic
# refusal. Surface it clearly and stop. Mirrors the
# exception-based ``content_policy_blocked`` recovery: try a
# configured fallback once, otherwise return the refusal.
if finish_reason == "content_filter":
_refusal_transport = agent._get_transport()
if agent.api_mode == "anthropic_messages":
_refusal_result = _refusal_transport.normalize_response(
response, strip_tool_prefix=agent._is_anthropic_oauth
)
else:
_refusal_result = _refusal_transport.normalize_response(response)
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
# Some refusals carry the explanation only in the reasoning
# channel; fall back to it so the user sees *something*.
if not _refusal_text:
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
agent._invoke_api_request_error_hook(
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
api_call_count=api_call_count,
api_start_time=api_start_time,
api_kwargs=api_kwargs,
error_type="ContentPolicyBlocked",
error_message=_refusal_text or "model declined to respond (content_filter)",
status_code=None,
retry_count=retry_count,
max_retries=max_retries,
retryable=False,
reason=FailoverReason.content_policy_blocked.value,
)
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
# Deterministic for the unchanged prompt — never retry.
# Try a configured fallback once (a different model may not
# refuse); otherwise surface the refusal terminally.
if agent._has_pending_fallback():
agent._buffer_status(
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
)
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
_retry.primary_recovery_attempted = False
continue
agent._flush_status_buffer()
_refusal_log = (
_refusal_text[:500] + "..."
if len(_refusal_text) > 500
else _refusal_text
)
logger.warning(
"%sModel declined to respond (finish_reason=content_filter). "
"model=%s provider=%s refusal=%s",
agent.log_prefix, agent.model, agent.provider,
_refusal_log or "(no text)",
)
agent._emit_status(
"⚠️ The model declined to respond to this request (safety refusal)."
)
_refusal_detail = (
f"Model's explanation: {_refusal_text}"
if _refusal_text
else "The model returned no explanation."
)
_refusal_response = (
"⚠️ The model declined to respond to this request "
"(safety refusal — not a Hermes/gateway failure).\n\n"
f"{_refusal_detail}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_refusal_response,
error_detail=_refusal_text or "model declined (content_filter)",
)
if finish_reason == "length":
if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
agent._vprint(
@@ -2067,7 +2272,11 @@ def run_conversation(
and not _retry.image_shrink_retry_attempted
):
_retry.image_shrink_retry_attempted = True
if agent._try_shrink_image_parts_in_messages(api_messages):
image_max_dimension = _image_error_max_dimension(api_error) or 8000
if agent._try_shrink_image_parts_in_messages(
api_messages,
max_dimension=image_max_dimension,
):
agent._vprint(
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
f"shrank and retrying...",
@@ -3083,20 +3292,17 @@ def run_conversation(
if classified.reason == FailoverReason.content_policy_blocked:
_summary = agent._summarize_api_error(api_error)
_policy_response = (
f"⚠️ The model provider's safety filter blocked this request "
f"(not a Hermes/gateway failure).\n\n"
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
f"Provider message: {_summary}\n\n"
f"Try rephrasing the request, narrowing the context, or "
f"adding a fallback provider with `hermes fallback add`."
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
error_detail=_summary,
)
return {
"final_response": _policy_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": f"content_policy_blocked: {_summary}",
}
return {
"final_response": None,
"messages": messages,

View File

@@ -70,16 +70,6 @@ def _resolve_args() -> list[str]:
def _resolve_home_dir() -> str:
"""Return a stable HOME for child ACP processes."""
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
return profile_home
except Exception:
pass
home = os.environ.get("HOME", "").strip()
if home:
return home
@@ -105,7 +95,10 @@ def _resolve_home_dir() -> str:
def _build_subprocess_env() -> dict[str, str]:
env = os.environ.copy()
env["HOME"] = _resolve_home_dir()
home = _resolve_home_dir()
env["HOME"] = home
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(env)
return env

View File

@@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
report["attempted"] = True # we tried but there was nothing to do
return report
# Load and rewrite the live jobs under the scheduler's lock.
# Load and rewrite the live jobs under the scheduler's cross-process lock.
try:
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
from cron.jobs import load_jobs, save_jobs, _jobs_lock
except ImportError as e:
report["error"] = f"cron module unavailable: {e}"
return report
report["attempted"] = True
try:
with _jobs_file_lock:
with _jobs_lock():
live_jobs = load_jobs()
changed = False

View File

@@ -12,6 +12,7 @@ import time
from dataclasses import dataclass, field
from difflib import unified_diff
from pathlib import Path
from typing import Any
from utils import safe_json_loads
from agent.tool_result_classification import file_mutation_result_landed
@@ -168,6 +169,27 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
def _truncate_preview(text: str, max_len: int | None) -> str:
if max_len and max_len > 0 and len(text) > max_len:
if max_len <= 3:
return "." * max_len
return text[:max_len - 3] + "..."
return text
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
goals: list[str] = []
for task in tasks:
if not isinstance(task, dict):
continue
raw_goal = task.get("goal")
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
goals.append(_truncate_preview(goal or "?", per_goal_len))
return len(goals), goals
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a short preview of a tool call's primary argument for display.
@@ -191,6 +213,22 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
"clarify": "question", "skill_manage": "name",
}
# delegate_task: show goal (single) or individual task goals (batch)
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
preview = (
f"{task_count} tasks: " + " | ".join(goals)
if goals else f"{len(tasks)} parallel tasks"
)
return _truncate_preview(preview, max_len)
goal = args.get("goal", "")
if goal is None:
return None
preview = _oneline(str(goal))
return _truncate_preview(preview, max_len) if preview else None
if tool_name == "process":
action = args.get("action", "")
sid = args.get("session_id", "")
@@ -858,20 +896,6 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def _used_free_parallel(result: str | None) -> bool:
"""True when a web result came from Parallel's free Search MCP.
Only the keyless Parallel path tags its result with ``provider="parallel"``;
the paid REST path and every other provider omit it. Used to label the tool
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
the call.
"""
if not isinstance(result, str) or '"provider"' not in result:
return False
data = safe_json_loads(result)
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
@@ -909,17 +933,15 @@ def get_cute_tool_message(
return f"{line}{failure_suffix}"
if tool_name == "web_search":
verb = "Parallel search" if _used_free_parallel(result) else "search"
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
if tool_name == "web_extract":
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
if tool_name == "process":
@@ -1035,7 +1057,10 @@ def get_cute_tool_message(
if tool_name == "delegate_task":
tasks = args.get("tasks")
if tasks and isinstance(tasks, list):
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
detail = " | ".join(goals) if goals else "parallel"
count_label = task_count or len(tasks)
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
preview = build_tool_preview(tool_name, args) or ""

3
agent/errors.py Normal file
View File

@@ -0,0 +1,3 @@
class SSLConfigurationError(Exception):
"""Raised when SSL/TLS certificate bundle configuration fails."""
pass

View File

@@ -46,11 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
@@ -104,12 +99,6 @@ def is_write_denied(path: str) -> bool:
if resolved.startswith(prefix):
return True
# Hermes control-plane files: block both the ACTIVE profile's view
# (hermes_home) AND the global root view. Without the root pass, a
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
# writable — letting a prompt-injected write_file overwrite the global
# files that every profile inherits from (same shape as #15981).
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
mcp_tokens_dir_name = "mcp-tokens"
hermes_dirs = []
@@ -122,12 +111,6 @@ def is_write_denied(path: str) -> bool:
continue
for base_real in hermes_dirs:
for name in control_file_names:
try:
if resolved == os.path.realpath(os.path.join(base_real, name)):
return True
except Exception:
continue
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):

View File

@@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def bare_gemini_model_id(model: str) -> str:
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
name = (model or "").strip()
lowered = name.lower()
for prefix in ("google/", "gemini/"):
if lowered.startswith(prefix):
return name[len(prefix):].strip() or name
return name
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
normalized = str(base_url or "").strip().rstrip("/").lower()
@@ -330,7 +340,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"parts": [{"text": joined_system}]}
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
return contents, system_instruction
@@ -914,6 +924,7 @@ class GeminiNativeClient:
thinking_config=thinking_config,
)
model = bare_gemini_model_id(model)
if stream:
return self._stream_completion(model=model, request=request, timeout=timeout)

View File

@@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -44,6 +45,66 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
existing_tool_names = {
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
}
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
valid_tool_names = getattr(agent, "valid_tool_names", None)
if valid_tool_names is None:
valid_tool_names = set()
agent.valid_tool_names = valid_tool_names
added = 0
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
existing_tool_names.add(tool_name)
added += 1
return added
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------
@@ -370,16 +431,37 @@ class MemoryManager:
# -- Prefetch / recall ---------------------------------------------------
@staticmethod
def _strip_skill_scaffolding(text: str) -> Optional[str]:
"""Return memory-worthy user text, or None to skip the turn.
When a user invokes a /skill or /bundle, Hermes expands the turn into
a model-facing message that embeds the entire skill body. Feeding that
verbatim to memory providers pollutes their stores/embeddings with
prompt scaffolding instead of what the user actually asked. We recover
just the user's instruction here, once, for every provider — so this
is fixed for the whole provider fan-out, not per backend.
- Non-skill messages pass through unchanged.
- Skill turns with a user instruction return that instruction.
- Bare skill invocations (no instruction) return None → callers skip
the turn, since there is no user content worth remembering.
"""
return extract_user_instruction_from_skill_message(text)
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others.
"""
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return ""
parts = []
for provider in self._providers:
try:
result = provider.prefetch(query, session_id=session_id)
result = provider.prefetch(clean_query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
@@ -400,10 +482,14 @@ class MemoryManager:
if not providers:
return
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return
def _run() -> None:
for provider in providers:
try:
provider.queue_prefetch(query, session_id=session_id)
provider.queue_prefetch(clean_query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
@@ -455,6 +541,11 @@ class MemoryManager:
if not providers:
return
clean_user_content = self._strip_skill_scaffolding(user_content)
if not clean_user_content:
return
user_content = clean_user_content
def _run() -> None:
for provider in providers:
try:

View File

@@ -5,6 +5,7 @@ and run_agent.py for pre-flight context checks.
"""
import ipaddress
import json
import logging
import os
import re
@@ -16,7 +17,7 @@ from urllib.parse import urlparse
import requests
import yaml
from utils import base_url_host_matches, base_url_hostname
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
from hermes_constants import OPENROUTER_MODELS_URL
@@ -111,6 +112,57 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
def _get_model_metadata_cache_path() -> Path:
"""Return path to the OpenRouter model metadata disk cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "openrouter_model_metadata.json"
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
"""Return disk-cache age in seconds, or None if freshness is unknown."""
try:
cache_path = _get_model_metadata_cache_path()
if not cache_path.exists():
return None
age = time.time() - cache_path.stat().st_mtime
if age < 0:
return None
return age
except Exception:
return None
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
"""Load processed OpenRouter metadata cache from disk."""
try:
cache_path = _get_model_metadata_cache_path()
with cache_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
return {
str(key): value
for key, value in data.items()
if isinstance(value, dict)
}
except Exception as e:
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
return {}
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
"""Save processed OpenRouter metadata cache to disk atomically."""
try:
atomic_json_write(
_get_model_metadata_cache_path(),
data,
indent=0,
separators=(",", ":"),
)
except Exception as e:
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
# Descending tiers for context length probing when the model is unknown.
# We start at 256K (covers GPT-5.x, many current large-context models) and
# step down on context-length errors until one works. Tier[0] is also the
@@ -209,7 +261,13 @@ DEFAULT_CONTEXT_LENGTHS = {
# https://platform.minimax.io/docs/api-reference/text-chat-openai
"minimax-m3": 1000000,
"minimax": 204800,
# GLM
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
# ensures "glm-5.2" resolves to 1M while older variants still hit the
# generic 202K fallback.
"glm-5.2": 1_048_576,
"glm": 202752,
# xAI Grok — xAI /v1/models does not return context_length metadata,
# so these hardcoded fallbacks prevent Hermes from probing-down to
@@ -627,6 +685,15 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
return _model_metadata_cache
if not force_refresh:
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
_model_metadata_cache_time = time.time() - disk_age
return _model_metadata_cache
try:
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
response.raise_for_status()
@@ -648,12 +715,24 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
_model_metadata_cache = cache
_model_metadata_cache_time = time.time()
_save_model_metadata_disk_cache(cache)
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
return cache
except Exception as e:
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
return _model_metadata_cache or {}
if _model_metadata_cache:
return _model_metadata_cache
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None:
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
else:
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
return _model_metadata_cache
return {}
def fetch_endpoint_model_metadata(

View File

@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
if "type" in node and node["type"] not in {None, ""}:
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``

View File

@@ -8,6 +8,7 @@ import json
import logging
import os
import threading
import contextvars
from collections import OrderedDict
from pathlib import Path
@@ -508,13 +509,22 @@ PLATFORM_HINTS = {
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram has NO table syntax — prefer bullet lists or labeled "
"key: value pairs over pipe tables (any tables you do emit are "
"auto-rewritten into row-group bullets, which you can produce "
"directly for cleaner output). "
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
@@ -949,6 +959,52 @@ CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
def _get_context_file_max_chars() -> int:
"""Return the configured context-file truncation limit.
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
fallback. Users with larger context windows can raise
``context_file_max_chars`` in config.yaml without patching Hermes.
"""
try:
from hermes_cli.config import load_config
val = load_config().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return CONTEXT_FILE_MAX_CHARS
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# =========================================================================
# Skills prompt cache
# =========================================================================
@@ -1155,7 +1211,7 @@ def build_skills_system_prompt(
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
disabled = get_disabled_skill_names()
disabled = get_disabled_skill_names(_platform_hint or None)
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
@@ -1454,10 +1510,19 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
"""Head/tail truncation with a marker in the middle."""
if max_chars is None:
max_chars = _get_context_file_max_chars()
if len(content) <= max_chars:
return content
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"increase context_file_max_chars or trim the file!"
)
logger.warning(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]

View File

@@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name

View File

@@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
# model-facing message that embeds the full skill body plus scaffolding. That
# expanded text is what flows into the agent loop — and into memory providers
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
# otherwise capture the entire skill body instead of what the user actually
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
# user's instruction so memory stays clean.
#
# These markers MUST stay byte-identical to the builders below
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
# agent/skill_bundles.py). They are co-located with the single-skill builder
# on purpose, and the bundle markers are asserted against the bundle builder in
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns:
- The original string unchanged when it is NOT skill scaffolding
(a normal user message passes straight through).
- The extracted user instruction when the scaffolding carried one.
- ``None`` when the content is skill scaffolding with no user
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
memory providers should skip the turn in that case — there is no
user content worth storing.
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
runtime_idx = instruction.find(_RUNTIME_NOTE)
if runtime_idx >= 0:
instruction = instruction[:runtime_idx]
instruction = instruction.strip()
return instruction or None
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundle format puts the user instruction before the loaded skills, so the
# first occurrence is the user-provided one.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
if first_skill_idx >= 0:
instruction = instruction[:first_skill_idx]
instruction = instruction.strip()
return instruction or None
def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering.

View File

@@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
)
)
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool:
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
"""True if *path* should be skipped by active skill scanners.
Use this on every SKILL.md path produced by ``rglob`` to prune
dependency, virtualenv, VCS, and cache directories. Centralising the
check here keeps every skill-scanning site in sync with the shared
exclusion set.
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
support-package paths. Centralising the check here keeps every
skill-scanning site in sync with the shared exclusion set.
Accepts a Path or string.
"""
@@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
except AttributeError:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
# ── Lazy YAML loader ─────────────────────────────────────────────────────
@@ -272,27 +307,65 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
# ── Disabled skills ───────────────────────────────────────────────────────
_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {}
def _raw_config_cache_clear() -> None:
"""Test hook — drop the shared raw config cache."""
_RAW_CONFIG_CACHE.clear()
def _load_raw_config() -> Dict[str, Any]:
"""Read config.yaml with a shared mtime+size keyed cache.
This module intentionally avoids importing ``hermes_cli.config`` on the
skill prompt/build path. A tiny local cache gives the same repeated-read
win without pulling the heavier CLI config stack into startup.
"""
config_path = get_config_path()
if not config_path.exists():
return {}
try:
stat = config_path.stat()
cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size)
except OSError:
cache_key = None
if cache_key is not None:
cached = _RAW_CONFIG_CACHE.get(cache_key)
if cached is not None:
return cached
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
if not isinstance(parsed, dict):
return {}
if cache_key is not None:
_RAW_CONFIG_CACHE.clear()
_RAW_CONFIG_CACHE[cache_key] = parsed
return parsed
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
"""Read disabled skill names from config.yaml.
Args:
platform: Explicit platform name (e.g. ``"telegram"``). When
*None*, resolves from ``HERMES_PLATFORM`` or
``HERMES_SESSION_PLATFORM`` env vars. Falls back to the
global disabled list when no platform is determined.
``HERMES_SESSION_PLATFORM`` env vars. Returns the global
disabled list, unioned with the platform-specific list when a
platform is resolved (a globally-disabled skill stays disabled
on every platform).
Reads the config file directly (no CLI config imports) to stay
lightweight.
"""
config_path = get_config_path()
if not config_path.exists():
return set()
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return set()
if not isinstance(parsed, dict):
parsed = _load_raw_config()
if not parsed:
return set()
skills_cfg = parsed.get("skills")
@@ -305,13 +378,14 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
or os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
)
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
resolved_platform
)
if platform_disabled is not None:
return _normalize_string_set(platform_disabled)
return _normalize_string_set(skills_cfg.get("disabled"))
return global_disabled | _normalize_string_set(platform_disabled)
return global_disabled
def _normalize_string_set(values) -> Set[str]:
@@ -336,6 +410,7 @@ _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {}
def _external_dirs_cache_clear() -> None:
"""Test hook — drop the in-process cache."""
_EXTERNAL_DIRS_CACHE.clear()
_raw_config_cache_clear()
def get_external_skills_dirs() -> List[Path]:
@@ -368,11 +443,8 @@ def get_external_skills_dirs() -> List[Path]:
# Return a copy so callers can't mutate the cached list.
return list(cached)
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception:
return []
if not isinstance(parsed, dict):
parsed = _load_raw_config()
if not parsed:
return []
skills_cfg = parsed.get("skills")
@@ -584,15 +656,7 @@ def resolve_skill_config_values(
current values (or the declared default if the key isn't set).
Path values are expanded via ``os.path.expanduser``.
"""
config_path = get_config_path()
config: Dict[str, Any] = {}
if config_path.exists():
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
config = parsed
except Exception:
pass
config = _load_raw_config()
resolved: Dict[str, Any] = {}
for var in config_vars:
@@ -632,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
directories so dependencies cannot register nested skills.
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):

94
agent/ssl_guard.py Normal file
View File

@@ -0,0 +1,94 @@
"""Preventive SSL CA certificate checks for Hermes Agent.
This module catches broken CA bundle paths before OpenAI/httpx turns them into
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
"""
from __future__ import annotations
import logging
import os
import ssl
from pathlib import Path
from agent.errors import SSLConfigurationError
logger = logging.getLogger(__name__)
_CA_BUNDLE_ENV_VARS = (
"HERMES_CA_BUNDLE",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
)
_SKIP_VALUES = {"1", "true", "yes", "on"}
def _skip_ssl_guard_enabled() -> bool:
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
def _repair_hint() -> str:
return (
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
"If you configured a custom corporate CA bundle, fix or unset the "
"broken CA bundle environment variable."
)
def _ssl_err(message: str) -> SSLConfigurationError:
"""Create a consistent, user-actionable SSL configuration error."""
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
path = Path(value).expanduser()
if not path.exists():
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
if not path.is_file():
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
if require_substantial and path.stat().st_size < 1024:
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
try:
ctx = ssl.create_default_context(cafile=str(path))
except Exception as exc:
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
if not ctx.get_ca_certs():
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
def verify_ca_bundle() -> None:
"""Verify configured and bundled CA certificates are present and loadable.
Raises:
SSLConfigurationError: If an explicit CA-bundle environment variable
points at a bad path, or if certifi's bundled ``cacert.pem`` is
missing/corrupt.
"""
if _skip_ssl_guard_enabled():
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
return
for env_var in _CA_BUNDLE_ENV_VARS:
value = os.getenv(env_var)
if value:
_validate_bundle_path(env_var, value)
try:
import certifi
except Exception as exc:
raise _ssl_err(f"certifi is not importable: {exc}") from exc
ca_bundle = str(certifi.where())
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
def verify_ca_bundle_with_fallback() -> None:
"""Backward-compatible wrapper for older call sites.
The old PR name mentioned a platform fallback, but allowing startup with a
broken certifi bundle still leaves httpx/OpenAI and requests call sites
failing later. Keep the wrapper name but enforce the same check.
"""
verify_ca_bundle()

View File

@@ -40,6 +40,7 @@ from agent.prompt_builder import (
TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
@@ -400,7 +401,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
warm across turns.
"""
parts = build_system_prompt_parts(agent, system_message=system_message)
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
for warning in drain_truncation_warnings():
agent._emit_status(warning)
return joined
def invalidate_system_prompt(agent: Any) -> None:

View File

@@ -186,10 +186,21 @@ class AnthropicTransport(ProviderTransport):
def validate_response(self, response: Any) -> bool:
"""Check Anthropic response structure is valid.
An empty content list is legitimate when ``stop_reason == "end_turn"``
— the model's canonical way of signalling "nothing more to add" after
a tool turn that already delivered the user-facing text. Treating it
as invalid falsely retries a completed response.
An empty content list is legitimate for terminal stop reasons that
carry no text payload:
- ``end_turn`` — the model's canonical "nothing more to add" after a
tool turn that already delivered the user-facing text.
- ``refusal`` — the model declined to respond (Claude 4.5+). The
Messages API returns an empty ``content`` list with this stop
reason. Treating it as invalid sends a deterministic refusal into
the invalid-response retry loop, which reproduces the refusal on
every attempt and surfaces a misleading "rate limited / invalid
response" error instead of the refusal. ``normalize_response`` maps
``refusal`` → ``content_filter`` so the agent loop's refusal handler
can surface it.
Treating either as invalid falsely retries a completed response.
"""
if response is None:
return False
@@ -197,7 +208,7 @@ class AnthropicTransport(ProviderTransport):
if not isinstance(content_blocks, list):
return False
if not content_blocks:
return getattr(response, "stop_reason", None) == "end_turn"
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
return True
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:

View File

@@ -531,6 +531,7 @@ class ChatCompletionsTransport(ProviderTransport):
supports_reasoning=params.get("supports_reasoning", False),
qwen_session_metadata=params.get("qwen_session_metadata"),
model=model,
base_url=params.get("base_url"),
ollama_num_ctx=params.get("ollama_num_ctx"),
session_id=params.get("session_id"),
)
@@ -664,8 +665,42 @@ class ChatCompletionsTransport(ProviderTransport):
if rd:
provider_data["reasoning_details"] = rd
# OpenAI structured-refusal field. When a model declines, the SDK
# populates ``message.refusal`` with the explanation and leaves
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
# Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via
# ``finish_reason="content_filter"`` — instead of the native
# ``stop_reason="refusal"``. Without capturing it the refusal looks
# like an empty response, so the agent loop retries a deterministic
# refusal three times and gives up with "no content after retries".
# Promote it to content + a ``content_filter`` finish reason so the
# loop's refusal handler surfaces it clearly and stops. ``refusal`` is
# ``None`` for normal responses, so this is a no-op in the common case.
content = msg.content
refusal = getattr(msg, "refusal", None)
if refusal is None and hasattr(msg, "model_extra"):
_msg_extra = getattr(msg, "model_extra", None) or {}
if isinstance(_msg_extra, dict):
refusal = _msg_extra.get("refusal")
if isinstance(refusal, str) and refusal.strip():
# Record the refusal explanation regardless — it's useful provider
# metadata even when the model also returned a usable payload.
provider_data["refusal"] = refusal
_has_text = isinstance(content, str) and content.strip()
_has_tool_calls = bool(tool_calls)
# Only promote to a terminal ``content_filter`` when the refusal is
# the *sole* payload — no visible text and no tool calls. A response
# that carries real content (or tool calls) alongside a refusal note
# is a normal, usable turn: surfacing it as a failed safety refusal
# would discard the model's actual work. In the empty-payload case,
# adopt the refusal as content so the loop has something to show.
if not _has_text and not _has_tool_calls:
content = refusal
if finish_reason in (None, "stop"):
finish_reason = "content_filter"
return NormalizedResponse(
content=msg.content,
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
reasoning=reasoning,

View File

@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
prompt_cache_key = kwargs.get("prompt_cache_key")
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:

View File

@@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
install_safe_stdio,
@@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@nous-research/ui": "0.16.0",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -40,8 +40,8 @@
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^7.3.1"
"vite": "^8.0.16"
}
}

View File

@@ -3,8 +3,9 @@
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 1. wait for the old Hermes desktop process to fully exit (so both the
//! venv shim and packaged app.asar are free; otherwise `hermes update`
//! or repair bootstrap can race locked files),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
/// How long to wait for the old desktop process to release files under the
/// install tree before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// aborts with exit 2. If it still holds the packaged app.asar,
// install.ps1's repair/re-clone path cannot move/remove the install tree.
// Give both handles a bounded window to clear.
wait_for_install_locks_free(&install_root, &app, "update").await;
// ---- stage 1: hermes update -----------------------------------------
// Pass --branch so `hermes update` targets the branch this installer was
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// already exited and waited for the install locks to clear before launching
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
let lock_targets = install_lock_probe_paths(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
let locked = locked_paths(&lock_targets);
if locked.is_empty() {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
// itself) is still holding one of the update-sensitive files. The
// desktop should have reaped its tree before handing off, but
// SIGTERM races / detached grandchildren / AV handles can leave a
// straggler. Rather than "proceed anyway" straight into uv's
// "Access is denied" or install.ps1's locked app.asar failure,
// force-kill every Hermes.exe except ourselves, then give the OS a
// beat to unload the image.
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] Hermes still holding the venv shim; force-killing stragglers…",
&format!(
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
format_locked_paths(&locked)
),
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
let locked_after_kill = locked_paths(&lock_targets);
if locked_after_kill.is_empty() {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
"[handoff] install files freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
&format!(
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
format_locked_paths(&locked_after_kill)
),
);
}
return;
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
}
}
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
let mut paths = vec![venv_hermes(install_root)];
paths.extend(desktop_app_payload_paths(install_root));
paths
}
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "windows") {
vec![
release.join("win-unpacked").join("resources").join("app.asar"),
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
]
} else if cfg!(target_os = "macos") {
vec![
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
]
} else {
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
}
}
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
paths.iter().filter(|p| is_locked(p)).cloned().collect()
}
fn format_locked_paths(paths: &[PathBuf]) -> String {
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
@@ -891,6 +934,29 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(
probes.iter().any(|p| p == &venv_hermes(root)),
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
#[test]
fn locked_paths_ignores_missing_payloads() {
let root = Path::new("/nonexistent/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(locked_paths(&probes).is_empty());
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",

View File

@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
---

View File

@@ -67,6 +67,16 @@ function buildDesktopBackendPath({
)
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
@@ -97,5 +107,6 @@ module.exports = {
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey
}

View File

@@ -7,6 +7,7 @@ const {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey
} = require('./backend-env.cjs')
@@ -66,6 +67,21 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
'/Users/test/.hermes'
)
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',

View File

@@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}
if (parsed.searchParams.has('profile')) {
return path
}
parsed.searchParams.set('profile', scopedProfile)
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -247,6 +280,7 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,

View File

@@ -24,6 +24,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {

View File

@@ -38,7 +38,8 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
@@ -62,6 +63,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -240,8 +242,18 @@ if (INSTALL_STAMP) {
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
function resolveHermesHome() {
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
if (IS_WINDOWS) {
// A GUI app launched from Explorer inherits the environment block captured
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
// process.env even though the CLI (a fresh shell) sees it. Without this the
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
// inference provider configured" despite a valid configured home (#45471).
// Consult the live User-scoped registry value before the default below.
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
}
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
const legacy = path.join(app.getPath('home'), '.hermes')
@@ -1835,6 +1847,44 @@ async function applyUpdates(opts = {}) {
}
}
async function handOffWindowsBootstrapRecovery(reason) {
if (!IS_WINDOWS || !IS_PACKAGED) return false
const updater = resolveUpdaterBinary()
if (!updater) return false
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = directoryExists(path.join(updateRoot, '.git'))
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
: configuredBranch || DEFAULT_UPDATE_BRANCH
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
setTimeout(() => {
app.quit()
}, 600)
return true
}
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
// the install we're updating, fall back to `hermes` on PATH.
function resolveHermesCliBinary(updateRoot) {
@@ -2432,6 +2482,14 @@ async function ensureRuntime(backend) {
if (backend.kind === 'bootstrap-needed') {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
throw handoffError
}
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
@@ -5026,65 +5084,75 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
})
)
return win
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
)
return win
})
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
function createWindow() {
@@ -5271,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5540,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const profile = request?.profile
const connection = await ensureBackend(profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
@@ -5563,11 +5641,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) return false
new Notification({
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
const notification = new Notification({
title: payload?.title || 'Hermes',
body: payload?.body || '',
silent: Boolean(payload?.silent)
}).show()
silent: Boolean(payload?.silent),
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
})
notification.on('click', () => {
if (!mainWindow || mainWindow.isDestroyed()) return
focusWindow(mainWindow)
if (payload?.sessionId) {
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
}
})
notification.on('action', (_actionEvent, index) => {
if (!mainWindow || mainWindow.isDestroyed()) return
const action = actions[index]
if (action?.id) {
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
}
})
notification.show()
return true
})

View File

@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -94,6 +95,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)

View File

@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer

View File

@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0

View File

@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})

View File

@@ -0,0 +1,76 @@
// windows-user-env.cjs
//
// Read a User-scoped environment variable straight from the Windows registry
// (HKCU\Environment).
//
// A GUI app launched from Explorer inherits the environment block captured at
// login, so a variable set via `setx` AFTER login is invisible in process.env
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
// the live registry value closes the gap. See #45471.
const { execFileSync } = require('node:child_process')
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
//
// HKEY_CURRENT_USER\Environment
// HERMES_HOME REG_SZ F:\Hermes\data
//
// Returns the raw value string (spaces inside the value preserved), or null when
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
if (match && match[1].toLowerCase() === name.toLowerCase()) {
return match[2]
}
}
return null
}
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
// the common F:\... case. Unknown references are left verbatim.
function expandWindowsEnvRefs(value, env = process.env) {
if (!value) return value
return value.replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
return key != null && env[key] != null ? env[key] : whole
})
}
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
if (platform !== 'win32' || !name) return null
let stdout
try {
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000
})
} catch {
// `reg` missing, or value absent (reg exits 1) — caller falls back.
return null
}
const raw = parseRegQueryValue(stdout, name)
if (raw == null) return null
const expanded = expandWindowsEnvRefs(raw, env).trim()
return expanded || null
}
module.exports = {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
}

View File

@@ -0,0 +1,90 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
test('parseRegQueryValue matches the name case-insensitively', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
})
test('parseRegQueryValue preserves spaces inside the value', () => {
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
})
test('parseRegQueryValue returns null when the value line is absent', () => {
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
})
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
})
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
let spawned = false
const exec = () => {
spawned = true
return ''
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
assert.equal(spawned, false)
})
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
const calls = []
const exec = (cmd, args) => {
calls.push([cmd, args])
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
}
const value = readWindowsUserEnvVar('HERMES_HOME', {
platform: 'win32',
env: { DRIVE: 'F:' },
exec
})
assert.equal(value, 'F:\\Hermes')
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
})
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
const exec = () => {
throw new Error('reg exited 1')
}
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})
test('readWindowsUserEnvVar returns null for an empty value', () => {
const exec = () => ' HERMES_HOME REG_SZ \r\n'
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
})

View File

@@ -20,6 +20,7 @@
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -36,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -134,6 +135,7 @@
},
"build": {
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",

View File

@@ -0,0 +1,59 @@
const fs = require('node:fs')
const path = require('node:path')
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
const marker = 'hermes-macos-electron-binary-fallback'
const needle = ` await Promise.all([
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
// Electron.app without its main MacOS/Electron binary before this rename.
// Restore it from the installed Electron runtime so local desktop installs
// do not fail with ENOENT during macOS arm64 packaging.
const macosDir = path.join(contentsPath, "MacOS");
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
if (sourceBinary == null) {
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
}
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
}
await Promise.all([
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
if (!fs.existsSync(electronMacPath)) {
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
process.exit(0)
}
const source = fs.readFileSync(electronMacPath, 'utf8')
if (source.includes(marker)) {
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
process.exit(0)
}
if (!source.includes(needle)) {
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
process.exit(0)
}
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
console.log('[patch-electron-builder] applied macOS Electron binary fallback')

View File

@@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value

View File

@@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
@@ -66,6 +67,7 @@ export function ComposerControls({
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
@@ -81,8 +83,10 @@ export function ComposerControls({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<ModelPill disabled={disabled} model={state.model} />
{/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<Tip label={steerTip}>
<Button
aria-label={steerLabel}
@@ -96,6 +100,8 @@ export function ComposerControls({
<SteeringWheel size={16} />
</Button>
</Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)}
{showVoicePrimary ? (
<Tip label={c.startVoice}>

View File

@@ -85,6 +85,8 @@ import {
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
@@ -135,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
return 'command'
}
/** A `/` query is at its arg stage once it's past the command name. */
const slashArgStage = (query: string) => query.includes(' ')
/** The `/command` token of a slash query (`personality x` → `/personality`). */
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -532,48 +540,6 @@ export function ChatBar({
})
}, [])
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
document.execCommand('insertText', false, pastedText)
const nextDraft = composerPlainText(event.currentTarget)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
}
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
@@ -610,7 +576,15 @@ export function ChatBar({
}
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
const found = detectTrigger(before ?? composerPlainText(editor))
// The arg-stage popover is only useful for commands with an options screen.
// For a no-arg command it would dead-end on "No matches", so drop it — the
// directive is already complete.
const detected =
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
? null
: found
setTrigger(detected)
@@ -650,6 +624,46 @@ export function ChatBar({
flushEditorToDraft(event.currentTarget)
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
if (imageBlobs.length > 0) {
event.preventDefault()
if (onAttachImageBlob) {
triggerHaptic('selection')
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
}
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
}
event.preventDefault()
insertPlainTextAtCaret(event.currentTarget, pastedText)
flushEditorToDraft(event.currentTarget)
}
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@@ -665,6 +679,12 @@ export function ChatBar({
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
@@ -675,6 +695,25 @@ export function ChatBar({
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
// Commit the literally-typed `/command arg` as a directive chip — used when
// the completion list is empty because the arg is already fully typed (the
// backend completer drops exact matches). Reuses the chip path via a
// synthetic item whose serialized form is the verbatim text.
const commitTypedSlashDirective = () => {
if (trigger?.kind !== '/') {
return
}
const text = `/${trigger.query.trimEnd()}`
replaceTriggerWithChip({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
})
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const editor = editorRef.current
@@ -793,6 +832,18 @@ export function ChatBar({
return
}
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
return
}
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
@@ -822,7 +873,15 @@ export function ChatBar({
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
// Enter / Tab / Space all accept the highlighted item: a no-arg command
// commits its directive chip, an arg-taking command expands to its
// options step, and an arg option commits the full `/cmd arg` chip. Space
// is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
if (accept) {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
@@ -843,6 +902,24 @@ export function ChatBar({
}
}
// Arg stage with nothing left to suggest — a fully-typed arg the backend
// completer no longer echoes (it drops the exact match), e.g.
// `/personality creative`. Space/Tab still commit what's typed as a single
// directive chip; Enter falls through to submit (send it as-is).
if (
trigger?.kind === '/' &&
!triggerItems.length &&
(event.key === ' ' || event.key === 'Tab') &&
slashArgStage(trigger.query) &&
trigger.query.trim()
) {
event.preventDefault()
triggerKeyConsumedRef.current = true
commitTypedSlashDirective()
return
}
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
@@ -1765,7 +1842,7 @@ export function ChatBar({
ref={composerRef}
>
{showHelpHint && <HelpHint />}
{trigger && (
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}

View File

@@ -0,0 +1,86 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
} from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector — the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const [open, setOpen] = useState(false)
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const label = (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Button
aria-label={copy.openModelPicker}
className={PILL}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -3,12 +3,24 @@ import { describe, expect, it } from 'vitest'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
const caretIn = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
@@ -59,3 +71,64 @@ describe('insertInlineRefsIntoEditor', () => {
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
})
describe('insertPlainTextAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
editor.remove()
})
it('replaces the selected span', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'abXYef'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 2)
range.setEnd(text, 4)
selection.removeAllRanges()
selection.addRange(range)
insertPlainTextAtCaret(editor, 'cd')
expect(composerPlainText(editor)).toBe('abcdef')
editor.remove()
})
})
describe('deleteSelectionInEditor', () => {
it('clears a non-collapsed range and leaves a collapsed caret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hello world'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
expect(deleteSelectionInEditor(editor)).toBe(true)
expect(composerPlainText(editor)).toBe('')
expect(selection.getRangeAt(0).collapsed).toBe(true)
expect(deleteSelectionInEditor(editor)).toBe(false)
editor.remove()
})
})

View File

@@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) {
appendComposerContents(target, text)
}
/** Caret range when the selection lives inside `editor`; else null. */
function composerSelectionRange(editor: HTMLElement) {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
return null
}
return { range, selection }
}
/** Insert plain text at the caret (replacing any selection). Pastes use this
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
* ~O(n²) on large multiline blobs. */
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendTextWithBreaks(fragment, text)
const tail = fragment.lastChild
if (hit) {
hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)
}
if (tail) {
const caret = document.createRange()
caret.setStartAfter(tail)
caret.collapse(true)
const selection = hit?.selection ?? window.getSelection()
selection?.removeAllRanges()
selection?.addRange(caret)
}
}
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
export function deleteSelectionInEditor(editor: HTMLElement) {
const hit = composerSelectionRange(editor)
if (!hit || hit.range.collapsed) {
return false
}
hit.range.deleteContents()
hit.range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(hit.range)
return true
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0

View File

@@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer'
@@ -22,6 +24,8 @@ export interface ChatBarState {
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }

View File

@@ -42,6 +42,7 @@ import {
$sessions,
sessionPinId
} from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@@ -61,6 +62,7 @@ import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
modelMenuContent?: React.ReactNode
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
onCancel: () => Promise<void> | void
@@ -119,10 +121,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId)
: false
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@@ -249,6 +251,7 @@ function ChatRuntimeBoundary({
export function ChatView({
className,
gateway,
modelMenuContent,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
@@ -302,7 +305,10 @@ export function ChatView({
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -342,6 +348,7 @@ export function ChatView({
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
},
tools: {
@@ -354,7 +361,7 @@ export function ChatView({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
)
// Drop files anywhere in the conversation area, not just on the composer

View File

@@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
function pendingApproval() {
$activeSessionId.set('sess-1')
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
}
afterEach(() => {
cleanup()
clearAllPrompts()
resetThreadScroll()
$activeSessionId.set(null)
})
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
// control's hidden (parked-at-bottom) state.
describe('ScrollToBottomButton', () => {
it('stays hidden while parked at the bottom', () => {
render(<ScrollToBottomButton />)
expect(screen.queryByRole('button')).toBeNull()
})
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
expect(screen.queryByText('Approval needed')).toBeNull()
})
it('morphs into the approval pill when scrolled up with a pending approval', () => {
pendingApproval()
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
expect(screen.getByText('Approval needed')).toBeTruthy()
})
it('does not morph while a pending approval is still in view (at bottom)', () => {
pendingApproval()
render(<ScrollToBottomButton />)
// Parked at bottom → control hidden, so it can't claim "approval needed".
expect(screen.queryByRole('button')).toBeNull()
})
it('re-arms sticky-bottom on click', () => {
const handler = vi.fn()
const stop = onScrollToBottomRequest(handler)
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
fireEvent.click(screen.getByRole('button'))
expect(handler).toHaveBeenCalledTimes(1)
stop()
})
})

View File

@@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
/**
@@ -15,6 +16,13 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
* When the turn is BLOCKED on an approval, this same control morphs into an
* "Approval needed" pill — the only response surface is the inline Run/Reject
* bar on the parked tool row, which is always the bottom-most content, so the
* existing scroll-to-bottom action lands the user right on it. One control, no
* collision, no second scroll path (native scrollIntoView would scroll
* overflow:hidden ancestors that can't scroll back and wreck the layout).
*
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
@@ -23,6 +31,11 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
export function ScrollToBottomButton() {
const { t } = useI18n()
const visible = useStore($threadJumpButtonVisible)
const request = useStore($approvalRequest)
// Scrolled away while an approval is pending → the inline Run/Reject bar is
// below the fold. Relabel so the user knows the session needs them, not just
// that there's more to read.
const approval = visible && Boolean(request)
const hasShownRef = useRef(false)
if (visible) {
@@ -30,15 +43,17 @@ export function ScrollToBottomButton() {
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
return (
<button
aria-hidden={!visible}
aria-label={t.assistant.thread.scrollToBottom}
aria-label={label}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
approval
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
!visible && 'pointer-events-none'
)}
data-state={state}
@@ -52,7 +67,8 @@ export function ScrollToBottomButton() {
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size="1rem" />
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
{approval && <span className="text-xs font-medium">{label}</span>}
</button>
)
}

View File

@@ -284,6 +284,7 @@ export function ProfileRail() {
selectProfile(name)
}}
open={createOpen}
profiles={profiles}
/>
<RenameProfileDialog

View File

@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { writeClipboardText } from '@/components/ui/copy-button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
@@ -49,26 +49,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const pinItem: ItemSpec = {
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
}
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
}
},
{
disabled: !sessionId,
icon: 'copy',
label: r.copyId,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
...(canOpenSessionWindow()
? [
{
@@ -122,13 +113,28 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
}
]
const renderItems = (Item: MenuItem) =>
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
))
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
<Codicon name={icon} size="0.875rem" />
<span>{label}</span>
</Item>
)
const renderItems = (Item: MenuItem) => (
<>
{renderMenuItem(Item, pinItem)}
<CopyButton
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
key={r.copyId}
label={r.copyId}
onCopyError={err => notifyError(err, r.copyIdFailed)}
text={sessionId}
/>
{items.map(spec => renderMenuItem(Item, spec))}
</>
)
const renameDialog = (
<RenameSessionDialog

View File

@@ -37,6 +37,7 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { respondToApprovalAction } from '../store/native-notifications'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@@ -76,6 +77,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
@@ -269,6 +271,26 @@ export function DesktopController() {
}
}, [])
// Notification click: the main process already focused the window; jump to its session.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(sessionId))
}
})
return () => unsubscribe?.()
}, [navigate])
// Notification action button (Approve/Reject) — resolve in place, no navigation.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or
@@ -443,6 +465,17 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
// only real windows bother.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
@@ -678,7 +711,9 @@ export function DesktopController() {
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
// Force: the new profile has its own default, so reseed even if the composer
// already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
@@ -826,7 +861,6 @@ export function DesktopController() {
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
freshDraftReady,
openCommandCenterSection,
@@ -948,6 +982,7 @@ export function DesktopController() {
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}

View File

@@ -37,6 +37,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,

View File

@@ -527,7 +527,7 @@ const PLATFORM_INTRO: Record<string, string> = {
wecom_callback:
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
weixin:
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',

View File

@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
export function CreateProfileDialog({
onClose,
onCreated,
open
open,
profiles = []
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
profiles?: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setSoul('')
setError(null)
setStatus('idle')
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
@@ -127,7 +138,7 @@ export function CreateProfileDialog({
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>

View File

@@ -12,6 +12,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
async (name: string, cloneFrom: null | string) => {
const trimmed = name.trim()
if (!isValidProfileName(trimmed)) {
throw new Error(p.nameHint)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
await createProfile({ name: trimmed, clone_from: cloneFrom })
notify({ kind: 'success', title: p.created, message: trimmed })
setSelectedName(trimmed)
await refresh()
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
open={createOpen}
profiles={profiles ?? []}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
function CreateProfileDialog({
onClose,
onCreate,
open
open,
profiles
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
open: boolean
profiles: ProfileInfo[]
}) {
const { t } = useI18n()
const p = t.profiles
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
@@ -472,7 +476,7 @@ function CreateProfileDialog({
}
setName('')
setCloneFromDefault(true)
setCloneFrom('default')
setError(null)
setSaving(false)
}, [open])
@@ -493,7 +497,7 @@ function CreateProfileDialog({
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
await onCreate(trimmed, cloneFrom)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : p.failedCreate)
@@ -528,18 +532,25 @@ function CreateProfileDialog({
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">{p.cloneFromDefault}</span>
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">

View File

@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { $connection, setCurrentCwd } from '@/store/session'
import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
installBridge()
})
afterEach(() => {
cleanup()
$connection.set(null)
setCurrentCwd('')
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
})
})

View File

@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
// Sidebar palette + hover-reveal: header actions stay reachable while moving
// from the project label to the action buttons.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
@@ -158,7 +158,7 @@ function FilesystemTab({
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="peer/project-label flex min-w-0 flex-1">
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
@@ -216,7 +216,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {

View File

@@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
/** A command queued to run in the embedded terminal. The terminal pane flushes
* (and clears) it once its session is live, so a value set before the pane
* mounts still runs. Cleared after flush so a later remount can't replay it. */
export const $terminalInjection = atom<null | string>(null)
/** Open the terminal pane and run a command in it. Used to disconnect external
* (CLI-managed) providers, which Hermes can't clear via the API — the user
* sees exactly what runs instead of Hermes silently deleting their creds. */
export const runInTerminal = (command: string) => {
const trimmed = command.trim()
if (!trimmed) {
return
}
setTerminalTakeover(true)
$terminalInjection.set(trimmed)
}

View File

@@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
@@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
// Flush a queued command (e.g. a provider-disconnect) into the live session.
// Only active while open; the subscribe fires immediately, so a command set
// before this pane mounted runs as soon as the session is ready. Clearing the
// atom after writing stops a later remount from replaying a stale command.
useEffect(() => {
if (status !== 'open') {
return
}
return $terminalInjection.subscribe(command => {
const id = sessionIdRef.current
if (!command || !id) {
return
}
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()
})
}, [status])
return {
addSelectionToChat,
hostRef,

View File

@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { translateNow } from '@/i18n'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -15,6 +16,7 @@ import {
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import {
dedupeGeneratedImageEchoesInParts,
@@ -25,8 +27,10 @@ import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -43,6 +47,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -330,6 +335,8 @@ export function useMessageStream({
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
@@ -635,19 +642,26 @@ export function useMessageStream({
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
}
if (shouldHydrate) {
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}
if (document.hidden && sessionId === activeSessionIdRef.current) {
void window.hermesDesktop?.notify({
title: 'Hermes finished',
body: text.slice(0, 140) || 'The response is ready.'
})
}
dispatchNativeNotification({
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
kind: 'turnDone',
sessionId,
title: translateNow('notifications.native.turnDoneTitle')
})
},
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
[hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const failAssistantMessage = useCallback(
@@ -822,6 +836,8 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
clearSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
if (isActiveEvent) {
@@ -867,12 +883,11 @@ export function useMessageStream({
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
triggerHaptic('streamDone')
}
playCompletionSound()
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)
@@ -903,10 +918,7 @@ export function useMessageStream({
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (
!sessionInterrupted(sessionId) &&
(payload?.name === 'terminal' || payload?.name === 'process')
) {
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
void refreshBackgroundProcesses(sessionId)
}
}
@@ -958,6 +970,13 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: question,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'approval.request') {
// Dangerous-command / execute_code approval. The Python side is blocked
@@ -966,17 +985,31 @@ export function useMessageStream({
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
const command = typeof payload?.command === 'string' ? payload.command : ''
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
command,
description,
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
actions: [
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
],
body: command || description,
kind: 'approval',
sessionId,
title: translateNow('notifications.native.approvalTitle')
})
} else if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
@@ -988,6 +1021,13 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'secret.request') {
// Skill credential capture (tools/skills_tool.py). Blocked on
@@ -995,16 +1035,26 @@ export function useMessageStream({
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
envVar,
prompt: promptText,
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: promptText || envVar || translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
@@ -1022,9 +1072,12 @@ export function useMessageStream({
})
}
} else if (event.type === 'status.update') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
if (sessionId && payload?.kind === 'process') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)
compactedTurnRef.current.add(sessionId)
} else if (sessionId && payload?.kind === 'process') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'error') {
@@ -1036,8 +1089,17 @@ export function useMessageStream({
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
sessionId,
title: translateNow('notifications.native.turnErrorTitle')
})
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {

View File

@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
@@ -13,12 +13,51 @@ import {
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
}))
describe('useModelControls.refreshCurrentModel', () => {
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
desktop: {
modelSwitchFailed: 'Model switch failed'
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
}))
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const controls = useModelControls({
activeSessionId,
queryClient: new QueryClient(),
requestGateway
})
onReady(controls)
return null
}
describe('useModelControls', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
@@ -74,4 +114,85 @@ describe('useModelControls.refreshCurrentModel', () => {
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
it('routes active-session picker changes through config.set with an explicit provider', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
provider: 'anthropic'
})
).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('stores a no-session pick as UI state with no gateway or global write', async () => {
const requestGateway = vi.fn()
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
provider: 'anthropic'
})
).resolves.toBe(true)
// The pick is plain UI state; session.create ships it later. Nothing touches
// the gateway or the profile default here.
expect($currentModel.get()).toBe('claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')
expect(requestGateway).not.toHaveBeenCalled()
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('seeds an empty composer model from global but never clobbers a pick', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
// Empty → seeds the default.
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
// draft / session events.
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
})

View File

@@ -1,7 +1,7 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
@@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
model: string
persistGlobal: boolean
provider: string
}
@@ -28,6 +27,7 @@ interface ModelControlsOptions {
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const { t } = useI18n()
const copy = t.desktop
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient]
)
const refreshCurrentModel = useCallback(async () => {
// Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
try {
if ($activeSessionId.get()) {
return
}
if (!force && $currentModel.get()) {
return
}
const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
if ($activeSessionId.get() || (!force && $currentModel.get())) {
return
}
@@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
}
}, [])
// Returns whether the switch succeeded so callers can await it before
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
// on the right active model — bail rather than write to the previous one).
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live
// session it's just stored (and shipped on the next session.create); with one
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected.
@@ -78,41 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
if (!activeSessionId) {
return true
}
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return true
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
return true
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
notifyError(err, copy.modelSwitchFailed)
return false
}
},
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }

View File

@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -1141,6 +1142,81 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}

View File

@@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$messages,
$sessions,
$yoloActive,
@@ -42,6 +46,7 @@ import {
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
@@ -406,13 +411,13 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
// The composer's model/effort/fast is sticky UI state (persisted in
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
// back to the profile default, so we deliberately don't reset it here. The
// profile default still owns first-run seeding and profile switches (see
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
@@ -442,11 +447,23 @@ export function useSessionActions({
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
})
const stored = created.stored_session_id ?? null
@@ -472,6 +489,9 @@ export function useSessionActions({
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.
broadcastSessionsChanged()
}
setFreshDraftReady(false)

View File

@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -20,6 +20,7 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { NotificationsSettings } from './notifications-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'notifications',
'sessions',
'about'
]
@@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
/>
)
})}
<OverlayNavItem
active={activeView === 'notifications'}
icon={Bell}
label={t.settings.nav.notifications}
onClick={() => setActiveView('notifications')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
@@ -220,11 +228,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : (
<SessionsSettings />
)}

View File

@@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
vi.mock('@/store/onboarding', () => ({
@@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View File

@@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
getAuxiliaryModels(),
getHermesConfigRecord()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View File

@@ -0,0 +1,150 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Bell, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import {
$nativeNotifyPrefs,
NATIVE_NOTIFICATION_KINDS,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { CONTROL_TEXT } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
function Caption({ children, className }: { children: ReactNode; className?: string }) {
return <p className={cn(CAPTION, className)}>{children}</p>
}
function ToggleRow(props: {
checked: boolean
description: string
disabled?: boolean
label: string
onChange: (on: boolean) => void
}) {
return (
<ListRow
action={
<Switch
aria-label={props.label}
checked={props.checked}
disabled={props.disabled}
onCheckedChange={on => {
triggerHaptic('selection')
props.onChange(on)
}}
/>
}
description={props.description}
title={props.label}
/>
)
}
export function NotificationsSettings() {
const { t } = useI18n()
const prefs = useStore($nativeNotifyPrefs)
const completionSoundVariantId = useStore($completionSoundVariantId)
const copy = t.settings.notifications
const runTest = async () => {
triggerHaptic('open')
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
}
return (
<SettingsContent>
<SectionHeading icon={Bell} title={copy.title} />
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
<ToggleRow
checked={prefs.enabled}
description={copy.enableAllDesc}
label={copy.enableAll}
onChange={setNativeNotifyEnabled}
/>
<div className="my-1 h-px bg-border/30" />
{NATIVE_NOTIFICATION_KINDS.map(kind => (
<ToggleRow
checked={prefs.enabled && prefs.kinds[kind]}
description={copy.kinds[kind].description}
disabled={!prefs.enabled}
key={kind}
label={copy.kinds[kind].label}
onChange={on => setNativeNotifyKind(kind, on)}
/>
))}
<div className="my-1 h-px bg-border/30" />
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{copy.completionSoundPreview}
</Button>
</div>
}
description={copy.completionSoundDesc}
title={copy.completionSoundTitle}
/>
<div className="mt-4 flex flex-col gap-2">
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
<Bell />
{copy.test}
</Button>
<Caption>{copy.focusedHint}</Caption>
</div>
</SettingsContent>
)
}

View File

@@ -0,0 +1,100 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OAuthProvider } from '@/types/hermes'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
const getEnvVars = vi.fn()
const startManualProviderOAuth = vi.fn()
const onboarding = atom({ manual: false })
vi.mock('@/hermes', () => ({
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
getEnvVars: () => getEnvVars(),
listOAuthProviders: () => listOAuthProviders()
}))
vi.mock('@/store/onboarding', () => ({
$desktopOnboarding: onboarding,
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
}))
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
return {
cli_command: `hermes auth add ${id}`,
disconnectable: true,
docs_url: '',
flow: 'device_code',
id,
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
status: {
logged_in: loggedIn
},
...patch
}
}
beforeEach(() => {
onboarding.set({ manual: false })
getEnvVars.mockResolvedValue({})
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
listOAuthProviders.mockResolvedValue({
providers: [provider('nous', true), provider('minimax-oauth', false)]
})
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
}
describe('ProvidersSettings', () => {
it('disconnects a connected provider account and refreshes the accounts list', async () => {
await renderProvidersSettings()
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
fireEvent.click(remove)
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
})
it('keeps provider selection separate from account removal', async () => {
await renderProvidersSettings()
fireEvent.click(await screen.findByText('Nous Portal'))
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
})
it('does not offer removal for externally managed providers', async () => {
listOAuthProviders.mockResolvedValue({
providers: [
provider('qwen-oauth', true, {
cli_command: 'hermes auth add qwen-oauth',
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
disconnectable: false,
flow: 'external',
name: 'Qwen (via Qwen CLI)'
})
]
})
await renderProvidersSettings()
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
})

View File

@@ -1,18 +1,22 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { listOAuthProviders } from '@/hermes'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronDown, KeyRound } from '@/lib/icons'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
@@ -21,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
@@ -85,7 +103,19 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
function OAuthPicker({
disconnecting,
onDisconnect,
onTerminalDisconnect,
onWantApiKey,
providers
}: {
disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void
providers: OAuthProvider[]
}) {
const { t } = useI18n()
const p = t.settings.providers
const [showAll, setShowAll] = useState(false)
@@ -97,7 +127,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
@@ -126,16 +156,22 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{p.connected}
</p>
<GroupLabel>{p.connected}</GroupLabel>
{connected.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
<ConnectedProviderRow
disconnecting={disconnecting === p.id}
key={p.id}
onDisconnect={onDisconnect}
onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p}
/>
))}
</>
)}
{showOthers && (
<>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
@@ -158,6 +194,80 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
)
}
function ConnectedProviderRow({
disconnecting,
onDisconnect,
onSelect,
onTerminalDisconnect,
provider
}: {
disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
// command we can run in the embedded terminal (Electron shell only).
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
{copy.connected}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{showHint && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p>
)}
</button>
<div className="flex items-center gap-1 pr-2">
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
{canDisconnect && (
<Button
aria-label={`${t.common.remove} ${title}`}
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</div>
)
}
function NoProviderKeys() {
const { t } = useI18n()
@@ -168,25 +278,31 @@ function NoProviderKeys() {
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
useEffect(() => {
if (onboardingActive) {
return
}
const refreshOAuthProviders = useCallback(async () => {
// OAuth providers are best-effort — a failure here just hides the panel.
const { providers } = await listOAuthProviders()
setOauthProviders(providers)
}, [])
useEffect(() => {
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
if (onboardingActive) {
return
}
try {
const { providers } = await listOAuthProviders()
@@ -201,6 +317,49 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return () => void (cancelled = true)
}, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
}
async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
return
}
setDisconnecting(provider.id)
try {
await disconnectOAuthProvider(provider.id)
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
await refreshOAuthProviders().catch(() => undefined)
} catch (err) {
notifyError(err, t.settings.providers.failedRemove(name))
} finally {
setDisconnecting(null)
}
}
if (!vars) {
return <LoadingState label={t.settings.providers.loading} />
}
@@ -237,7 +396,13 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return (
<SettingsContent>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
<OAuthPicker
disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={handleTerminalDisconnect}
onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders}
/>
</SettingsContent>
)
}
@@ -253,6 +418,7 @@ interface ProviderKeyGroup {
}
interface ProvidersSettingsProps {
onClose: () => void
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View File

@@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type SettingsView =
| 'about'
| 'gateway'
| 'keys'
| 'mcp'
| 'notifications'
| 'providers'
| 'sessions'
| `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {

View File

@@ -80,6 +80,10 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
@@ -162,7 +166,9 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
@@ -183,7 +189,9 @@ export function AppShell({
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
{/* The compact pop-out drops the statusbar — it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}

View File

@@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
@@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
ChevronDown,
Clock,
Command,
Hash,
@@ -19,7 +17,6 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -30,16 +27,11 @@ import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
$yoloActive,
setModelPickerOpen,
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
@@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean
@@ -83,7 +74,6 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
freshDraftReady,
@@ -97,10 +87,6 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@@ -416,37 +402,6 @@ export function useStatusbarItems({
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
{
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
: copy.openModelPicker,
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
@@ -465,11 +420,6 @@ export function useStatusbarItems({
contextBar,
contextUsage,
copy,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,

View File

@@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})

Some files were not shown because too many files have changed in this diff Show More