Compare commits

...
Author SHA1 Message Date
ethernet 476d8d9ccb Pluginify provider/platform/terminal backends
Move provider adapters (anthropic, bedrock, azure-foundry),
platform adapters (telegram, slack, discord, feishu, dingtalk, matrix),
and terminal backends (daytona, modal, vercel) into standalone uv
workspace plugin packages under plugins/.

Wire-format code shared between core and providers lives in
agent/anthropic_format.py + agent/anthropic_aux.py + agent/transports/.
No plugin→plugin deps; shared wire-format code belongs in core.

CI: uv sync --all-extras for all plugin deps including matrix.
[all] extra intentionally excludes lazy-installable deps.
2026-06-02 15:26:29 -04:00
ethernet 3e6b68252f Merge pull request #37518 from NousResearch/bb/desktop-installer-running-instances
Clarify desktop install retry guidance
2026-06-02 13:13:39 -04:00
ethernet 091ef7d304 Merge pull request #37484 from NousResearch/ethie/gui-docs
fix(docs): update desktop app docs
2026-06-02 13:11:36 -04:00
Brooklyn Nicholson 0c29cfd1a6 Clarify desktop install retry guidance 2026-06-02 12:08:39 -05:00
Austin PickettandCursor 6d14a24b79 feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)
* feat(dashboard): nous-blue theme, bulk sessions, schedule picker

Batch of related dashboard improvements gathered on
austin/fix/dashboard-changes:

* Nous Blue theme — faithful port of the LENS_5I overlay system onto
  the existing DashboardTheme. Lifts the foreground inversion layer to
  z-index 200 to fix the long-standing hover / loading visual artifact,
  adds an explicit swatchColors slot so the theme picker shows the
  post-inversion preview, and migrates the legacy "lens-5i" theme key
  from localStorage / API to "nous-blue" on first read.
* Theme-aware series colors: new --series-input-token /
  --series-output-token CSS vars consumed by Analytics + Models
  charts; ToolCall + ModelInfoCard switched to semantic
  --color-success for diff lines and the Tools capability badge.
* Analytics + Models headers: consolidate period selector + refresh
  next to the page title and drop the redundant period badge.
* Bulk session management — "Delete empty (N)" button + per-row
  checkboxes with shift-click range select and a bulk-delete action
  bar. Backed by SessionDB.delete_sessions() /
  delete_empty_sessions() plus POST /api/sessions/bulk-delete and
  DELETE /api/sessions/empty (registered before the templated
  /api/sessions/{session_id} family so they don't get shadowed).
  Hard cap of 500 IDs per bulk request. Full pytest coverage.
* Cron page — human-readable schedule picker (every-interval / daily
  / weekly / monthly / once / custom) replaces the raw cron
  expression input; the job list now renders "Weekly on Mon, Wed,
  Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals
  for monthly schedules so non-English locales don't get incorrect
  suffixes.
* example-dashboard plugin moved from plugins/ to tests/fixtures/ so
  stock installs no longer ship the demo. Tests install it
  dynamically via a pytest fixture that also reorders the FastAPI
  routes.
* i18n: 40+ new keys for the bulk-select UI and schedule
  picker/describer translated across all 16 locales.

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

* refactor(dashboard): dedupe memory provider picker

The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".

/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.

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

* docs(dashboard): address Copilot review on PR #37383

- Backdrop layer-stack comment claimed LENS_5I-style themes override
  --component-backdrop-bg-blend-mode to multiply, but our only
  LENS_5I-style theme (nous-blue) keeps the default difference.
  Reword to describe what the code actually does and present the
  var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
  echo back the list of deleted IDs, but the implementation only
  returns {ok, deleted}. Tighten the docstring to match the wire
  format; the client already knows what it asked to delete, so the
  IDs aren't needed.

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

* fix(dashboard): address copilot review on cron describe + bulk-select checkbox

- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
  expressions. The backend `parse_schedule` also accepts the 6-field
  `min hour dom month dow year` form, and humanising those by
  destructuring only the first five fields would silently drop the year
  (e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
  expressions now fall through to the raw-string fallback so the user
  sees what's actually scheduled.

- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
  ``onClick`` directly instead of attaching it to a parent ``<span>``
  with a no-op ``onCheckedChange``. Radix forwards onClick to the
  underlying ``<button role=checkbox>``, so the same handler now drives
  both mouse clicks (preserving shift-key state for range select) and
  keyboard activation (Space on the focused checkbox, which the browser
  synthesises as a click on the <button>). Improves a11y / keyboard UX
  without changing the controlled-selection model.

- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
  ``onRename`` / ``onExport`` props introduced on main so the row's
  destructured prop types resolve after the merge.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 12:37:40 -04:00
ethernet 7450bee8bc fix(docs): update desktop app docs 2026-06-02 11:52:33 -04:00
ethernet a6b6afdff4 Merge pull request #36864 from maxmilian/fix/tui-reset-terminal-input-modes-on-exit
fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks
2026-06-02 11:30:50 -04:00
brooklyn! 23c0578bd7 Merge pull request #37462 from NousResearch/bb/desktop-update-throttle
fix(desktop): throttle the update-available toast
2026-06-02 10:26:52 -05:00
Teknium 3eb6bd7f92 docs: add Desktop App guide (#37457)
The native Electron desktop app shipped (PR #20059 and follow-ups) but the
docs only told people how to download it, not what it is or how to use it.

Adds website/docs/user-guide/desktop.md covering install (installer +
prebuilt + Windows GUI), the chat-first UI and management panes, the
hermes desktop CLI flag reference, self-update, how-it-works, and
troubleshooting. Sourced from apps/desktop/README.md, routes.ts, and the
real argparse. Wired into sidebars.ts under Interfaces after the TUI.
2026-06-02 08:09:42 -07:00
brooklyn! f58db77cd0 Merge pull request #37379 from NousResearch/bb/desktop-session-list
feat(desktop): session-list overhaul + cancellable install
2026-06-02 09:56:31 -05:00
brooklyn!andCopilot Autofix powered by AI 8977bf282e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 09:51:51 -05:00
Brooklyn Nicholson 267e7fd395 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-session-list 2026-06-02 09:27:34 -05:00
Brooklyn Nicholson d183f75ee0 chore: uptick 2026-06-02 09:27:28 -05:00
Brooklyn Nicholson 4239230957 feat(desktop): cancellable first-launch install
The install overlay had no way to stop a running install — the runner already
supported an abortSignal, but nothing drove it. Wire it end to end:

- main.cjs holds an AbortController for the active runBootstrap and aborts it
  on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling
  mid-install actually kills install.sh/ps1 instead of orphaning it.
- runBootstrap bails before spawning anything if the signal is already aborted.
- Install overlay gains a "Cancel install" button while a bootstrap is active;
  a cancel surfaces the recovery overlay (retry/repair).

Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early
return (no spawn) via `node --test`.
2026-06-02 08:50:45 -05:00
Jeffrey Quesnelle 927fa7a980 Merge pull request #37330 from NousResearch/desktop/consolidate-models-into-settings
refactor(desktop): move model management from Command Center into Settings
2026-06-02 09:43:10 -04:00
Teknium afea650e16 fix(model-picker): OpenAI shows curated models; OpenRouter no longer phantom-shows (#37404)
The model picker now matches `hermes model` for OpenAI, and OpenRouter
stops appearing as authenticated when only OPENAI_API_KEY is set.

- models.py: provider_model_ids() for the default api.openai.com endpoint
  intersects the live /v1/models dump (120+ entries incl. embeddings,
  whisper, tts, dall-e, moderation, legacy chat) with the curated agentic
  list, preserving curated order. Custom OpenAI-compatible endpoints keep
  the live list verbatim so discovery still works.
- providers.py: drop extra_env_vars=("OPENAI_API_KEY",) from the openrouter
  overlay. list_authenticated_providers reads extra_env_vars to decide
  whether a provider is authenticated, so any OpenAI user saw a phantom
  OpenRouter row. Runtime OpenRouter credential resolution still falls back
  to OPENAI_API_KEY (runtime_provider.py), independent of the overlay.
- Regression tests for both paths.
2026-06-02 06:31:37 -07:00
Teknium 195c4d2a98 feat(streaming): per-platform streaming defaults (Telegram on, Discord off) + dashboard toggles (#37303)
Streaming quality differs sharply by platform: Telegram has native animated
draft streaming (sendMessageDraft) which is smooth, while Discord/Slack only
have edit-based streaming (repeated editMessage) which visibly flickers. Ship
defaults that match reality instead of one global flag.

- hermes_cli/config.py: DEFAULT_CONFIG display.platforms now ships
  telegram.streaming=true and discord.streaming=false (was empty {}). These
  are gap-fillers — config deep-merge has user values win, so anyone who
  explicitly sets discord.streaming=true keeps it. The global
  streaming.enabled master switch still gates everything; these per-platform
  flags only take effect once streaming is on.
- Dashboard exposure comes for free: the web settings schema is generated
  from DEFAULT_CONFIG, so display.platforms.telegram.streaming and
  .discord.streaming now surface as editable boolean toggles in the UI with
  no frontend change. (Previously the per-platform tree was {} and invisible.)
- tests: pin the defaults, the resolver outcome (telegram on / discord off /
  unlisted platforms follow global), user-override-wins, and dashboard schema
  exposure.

No _config_version bump: deep-merge fills the gap for existing installs; no
value migration needed.
2026-06-02 05:52:54 -07:00
Brooklyn Nicholson 5b71f7dd72 feat(desktop): session search in the sidebar
Adds a search box above the session list. Loaded sessions match instantly
client-side; a debounced full-text search (existing /api/sessions/search FTS)
covers the rest so all sessions stay findable at 699+. Results replace the
pinned/agents sections while a query is active and resume on click.
2026-06-02 07:21:03 -05:00
Brooklyn Nicholson 135c65093a feat(desktop): stable in-workspace ordering + No-workspace default
- Sidebar: rows within a workspace group now sort by creation time instead of
  last activity, so they stop reshuffling every time a message lands (muscle
  memory). Groups still float up by recency.
- Sessions only persist a workspace cwd when one was explicitly chosen; an
  auto-detected launch directory is no longer stamped on the row, so untargeted
  sessions group under "No workspace" instead of "desktop". The agent still
  runs in the detected directory.
2026-06-02 07:18:47 -05:00
Brooklyn Nicholson de8bdf529d fix(desktop): keep pinned + recent sessions visible across compression
Long-running sessions auto-compress: the gateway ends the original session
and surfaces the live continuation under a new id (list_sessions_rich projects
the root forward to its tip). Two symptoms fell out of the id rotation:

- A pinned session "vanished" — the pin is stored as the pre-compression root
  id, but the sidebar only matched on the live id, so it was filtered out.
  Pins now resolve on the durable lineage-root id (`_lineage_root_id`, already
  surfaced by the projection): the sidebar indexes sessions by both ids, pin/
  unpin and reorder operate on the durable id, and `sessionPinId()` is shared
  with the Cmd+P toggle. Existing pins keep working with no migration.

- A freshly-continued session was missing from the list until you ungrouped +
  "load 50 more" — the list paginated by original start time, so an old-but-
  active conversation sat past the first page. The desktop now requests
  `order=recent` (GET /api/sessions gains an `order` param backed by the
  existing recency CTE), surfacing live continuations on the first page.
2026-06-02 07:12:05 -05:00
Ben Barclay c10ccaaf51 feat(dashboard-auth): rotate dashboard sessions via refresh token (#37247)
* feat(dashboard-auth): rotate dashboard sessions via refresh token

The dashboard auth-code grant now issues a 24h rotating refresh token
(server side: NousResearch/nous-account-service#293). This wires up the
Hermes client half so an expired access token is transparently refreshed
instead of bouncing the user to /login every 15 minutes.

plugins/dashboard_auth/nous:
- refresh_session() now POSTs grant_type=refresh_token to Portal's token
  endpoint and returns a Session carrying the ROTATED refresh token (was
  an unconditional RefreshExpiredError under the old "no RT in V1"
  contract). The RT is sent in BOTH the request body (Portal's schema
  requires it there) and the X-Refresh-Token header (log redaction) —
  verified against the #293 preview deploy: header-only is rejected as
  invalid_request, body is accepted.
- A 400 from Portal (expired / revoked / reuse-detected) maps to
  RefreshExpiredError so the middleware forces a clean re-login; network
  errors map to ProviderError; empty RT fast-fails without a network call.
- complete_login now captures the initial refresh token Portal returns
  (forward-tolerant: empty string if a deploy omits it).
- Extracted the shared token-response handling into
  _token_response_to_session, parameterised on the 400 exception type so
  the auth-code path raises InvalidCodeError and the refresh path raises
  RefreshExpiredError.
- revoke_session stays a best-effort no-op: Portal exposes no public
  token-endpoint revocation grant (revocation is the authenticated
  /sessions UI, keyed by sessionId+userId), so logout is cookie-clearing
  and the 24h session expires on its own. Documented for a future
  revoke grant.

hermes_cli/dashboard_auth/middleware:
- On an expired/invalid access token the gate now attempts refresh via
  the session's RT BEFORE forcing re-login. On success it serves the
  request and re-sets the rotated cookies on the response (mandatory:
  Portal rotates the RT every refresh and reuse-detects, so a stale RT
  cookie would revoke the whole session on the next refresh). On
  RefreshExpiredError (or no RT) it falls through to clear-and-relogin.
- ProviderError during refresh (Portal unreachable) forces a clean
  re-login rather than 500-ing the request.
- Uses the existing REFRESH_SUCCESS / REFRESH_FAILURE audit events.

Validation:
- 176 dashboard-auth unit/integration tests pass.
- Live E2E against the #293 preview deploy: refresh_session(bad rt) ->
  RefreshExpiredError through the real token endpoint; live JWKS fetch +
  RS256 verification rejects a forged token; empty-RT fast-fail. The
  successful happy-path rotation is covered by unit tests (a live run
  needs an interactive browser OAuth round trip + registered agent:*
  client).

Depends on: NousResearch/nous-account-service#293 (server-side RT issuance).

* fix(dashboard-auth): use Portal's x-nous-refresh-token header name

The refresh-token header must match Portal's REFRESH_TOKEN_HEADER exactly
("x-nous-refresh-token"); the initial cut used "X-Refresh-Token", which
Portal silently ignores (harmless since the RT is also in the body, which
is what the schema requires — but the header redaction was a no-op).
Confirmed against the NAS token route + re-validated live against the
#293 preview deploy.

* fix(dashboard-auth): refresh session when access-token cookie has been evicted

The gated middleware bounced users to /login the instant the access-token
cookie was absent, without ever consulting the refresh token:

    at, _rt = read_session_cookies(request)
    if not at:
        return _unauth_response(...)   # bailed here

This made transparent refresh effectively dead for the common case. The
access-token cookie is set with Max-Age = access_token_expires_in (~15 min),
so a real browser EVICTS hermes_session_at the moment the token lapses while
hermes_session_rt persists (30-day Max-Age). From that point the browser
sends only the refresh-token cookie — and the old guard rejected it before
_attempt_refresh could run. The _attempt_refresh path only fired for a
present-but-invalid access token, which never happens in a browser.

Fix: only hard-bounce when NEITHER cookie is present. A request carrying
just the refresh token now skips verification (no AT to verify) and flows
into the existing refresh path, which rotates both cookies and serves the
request transparently. A dead/expired RT still raises RefreshExpiredError
and falls through to clear-and-relogin.

This failure mode escaped the original tests + manual refresh button because
both kept the access-token cookie present; only a real browser evicting the
cookie at Max-Age exposes it. Added 3 regression tests covering: AT-evicted +
RT-present (transparent refresh), no-cookies (still bounces), and RT-only with
a dead RT (clean 401, no 500).
2026-06-02 21:16:41 +10:00
emozilla 5e55b35cc8 refactor(desktop): move model management from Command Center into Settings
Command Center's Models section and Settings > Model rendered the same
model state with identical persistence semantics — both write config and
apply to new sessions only (POST /api/model/set). The Command Center UI
was strictly better (provider catalog, curated model lists, friendly
auxiliary-task labels, Nous-gateway auto-routing on main-provider switch),
while Settings > Model was three barebones config fields.

Extract that UI into a shared settings/model-settings.tsx (restyled with
Settings primitives) and render it at the top of Settings > Model: main
model picker via setModelAssignment + the 9 auxiliary task slots with
per-task set-to-main / change / reset-all. model_context_length and
fallback_providers stay as config fields below it; the raw auxiliary.*
keys are dropped from Advanced (now covered by the panel).

Strip the Models section from Command Center entirely (section, state,
handlers, render, nav, search entry) leaving it focused on Sessions /
System / Usage, and move the live store-sync callback (onMainModelChanged)
from CommandCenterView to SettingsView. The composer's per-session model
picker (the only live hot-swap, via /model) is unchanged.
2026-06-02 05:53:15 -04:00
Jeffrey Quesnelle c6501c0f49 Merge pull request #37310 from NousResearch/desktop/consolidate-skills-tools-pane
refactor(desktop): consolidate skills + tools management into one pane
2026-06-02 05:21:15 -04:00
emozilla a2b8e430e8 refactor(desktop): consolidate skills + tools management into one pane
The left-nav Skills pane and Settings > Skills & Tools rendered the same
getSkills()/getToolsets() data with the same helpers and toggles — genuine
duplication that drifted (different default category labels, sort orders).

Make the left pane the single home: it keeps its category-tabbed browsing
and now gains the functional bits it lacked — a real toolset enable/disable
switch (was a read-only pill) and the expandable ToolsetConfigPanel for
provider selection + per-key credential config. Remove the Tools section
from Settings (nav item, view branch, query slot, type union entries) and
delete tools-settings.tsx, migrating its toggle coverage into the skills
pane test. Relabel the entry point to 'Skills & Tools' in the sidebar and
command center.
2026-06-02 05:11:52 -04:00
Teknium d78d77e460 feat(config): surface gateway streaming block in DEFAULT_CONFIG (#37285)
The gateway reads top-level streaming.* with StreamingConfig defaults when the
block is absent, so streaming was invisible — a user with no streaming block
sees responses arrive as single messages and has no way to discover the toggle
short of reading source. This materializes the block in config.yaml so it's
discoverable, with values byte-identical to the dataclass defaults (no behavior
change).

- DEFAULT_CONFIG gains a root-level streaming block (enabled, transport,
  edit_interval, buffer_threshold, cursor, fresh_final_after_seconds), each
  documented inline. Values match gateway/config.py StreamingConfig() exactly.
- _KNOWN_ROOT_KEYS gains 'streaming' so the validator accepts the root key.
- No _config_version bump: load_config deep-merges DEFAULT_CONFIG over user
  YAML, so existing installs pick up the default automatically; no value
  migration needed.

Does NOT touch the setup wizard — streaming stays opt-in, just discoverable.
2026-06-02 01:22:24 -07:00
Jeffrey Quesnelle 89db6c8534 Merge pull request #37283 from NousResearch/fix-toolset-provider-selection-display
fix(desktop): reflect active toolset provider in config panel
2026-06-02 04:05:52 -04:00
Teknium 787936d133 feat(gateway): structured stream-event protocol + Telegram draft formatting parity (#37250)
Introduce a typed agent→gateway delivery contract so the gateway (not the
agent) decides how each streaming event is rendered per platform. Moves toward
smart-agent/smart-gateway separation while reproducing today's behavior exactly
in the base class.

- gateway/stream_events.py: typed event vocabulary (MessageChunk/Stop,
  Commentary, ToolCallChunk/Finished, LongToolHint, GatewayNotice).
- gateway/stream_dispatch.py: GatewayEventDispatcher routes events through the
  adapter; adapters can eat events they can't render (e.g. tool chrome on
  plain-text platforms).
- gateway/platforms/base.py: render_message_event + format_tool_event default
  hooks reproduce the historical emoji/preview tool formatting and consumer
  delegation 1:1; adapters override for native rendering.
- gateway/platforms/telegram.py: send_draft now applies MarkdownV2 (format_message
  + parse_mode) with a plain-text fallback on BadRequest, fixing the jarring
  raw-text→formatted shift when the draft finalizes as a real sendMessage.
- gateway/config.py: default streaming transport edit → auto. Safe globally:
  adapters without draft support report supports_draft_streaming()==False and
  transparently use edit, so only Telegram DMs gain native drafts.

Presentation-only contract — nothing rendered here is persisted to conversation
history, preserving cache/message-flow invariants.
2026-06-02 00:33:50 -07:00
Teknium 2c0d648397 fix(cron): sanitize invisible unicode in vetted skill content instead of hard-blocking (#37245)
A stray zero-width space (U+200B), BOM, or bidi control in loaded skill
markdown permanently killed any cron that loaded it. The skills-attached
assembled-prompt scan hard-blocked on any invisible-unicode char, even
though skill bodies are already install-time vetted by skills_guard.py and
the chars commonly appear in copy-pasted unicode docs / code examples.

The skills path now strips invisibles (logging the codepoints) and runs the
cleaned prompt. The raw user-prompt path (_scan_cron_prompt) keeps the hard
block — that is the actual #3968 injection surface, where a small directive
prompt with a ZWSP is a smoking gun, not prose. Stripping does not let a real
injection slip through: the directive still matches after sanitization.

_scan_cron_skill_assembled now returns (cleaned_prompt, error).
2026-06-02 00:29:44 -07:00
emozilla 134643a2fa fix(desktop): reflect active toolset provider in config panel
The toolset config panel highlighted the first keyless provider (e.g.
Nous Portal) on load instead of the provider actually written to config.
The /api/tools/toolsets/{name}/config endpoint never reported which
provider was active, so the GUI's default-expand logic fell back to
"first configured" — and keyless providers are always "configured".

Backend now annotates each provider with is_active (via the same
_is_provider_active helper the CLI 'hermes tools' picker uses) plus a
top-level active_provider summary. The panel prefers that signal before
falling back to first-configured/first.

Adds a frontend regression test (active provider is expanded on load)
and backend coverage (config reports is_active/active_provider; selecting
a provider round-trips into the next config read).
2026-06-02 03:25:46 -04:00
Teknium 3c1d066a8a feat(dashboard): Channels page — set up every gateway messaging channel from the browser (#37211)
The /api/messaging/platforms endpoints (catalog, configure, test) shipped
with the desktop app but never got a dashboard UI; the recent admin-panel
PRs covered MCP/webhooks/hooks/system but skipped messaging channels. This
adds the missing page so all 20+ channels (Telegram, Discord, Slack, Matrix,
Mattermost, WhatsApp, Signal, BlueBubbles, Email, SMS, DingTalk, Feishu,
WeCom, WeChat, QQ Bot, Yuanbao, plugin platforms, etc.) can be configured,
enabled/disabled, tested, and connected entirely from the browser.

- web/src/pages/ChannelsPage.tsx: per-platform list with live status, enable
  Switch, Test, and a Configure modal that renders each platform's exact
  setup fields (secrets masked, required validated, redacted display).
- web/src/lib/api.ts: MessagingPlatform types + get/update/test client fns.
- web/src/App.tsx: /channels route + nav tab (Radio icon, after MCP).
- docs: Channels section + REST endpoints + screenshot.

Frontend-only — reuses the existing env-write + config-enable backend, which
auto-enables a platform once its required env vars are present and the
gateway restarts. No core changes, no new tool schema.
2026-06-01 23:41:35 -07:00
Spider-Versandalaamohanad169-ship-it 15cb4e2279 fix(docker): install python3-venv so ensurepip fallback works (closes #36813) (#36905)
Co-authored-by: alaamohanad169-ship-it <alaamohanad169-ship-it@users.noreply.github.com>
2026-06-02 16:39:32 +10:00
Teknium 0269eca7e1 test(minimax): assert M3 stale-cache guard contract, not a brittle 1M literal (#37220)
test_stale_m3_cache_dropped_and_reresolves_to_1m hardcoded
assert ctx == 1_000_000. The test re-resolves M3 through the live models.dev
registry (the seeded stale entry is dropped, so nothing short-circuits the
lookup), and models.dev now reports MiniMax-M3 at 512,000 — a change-detector
failure unrelated to any code change.

The guard's actual contract is: a stale <=204,800 catch-all value for an M3
slug must be DROPPED and re-resolved to M3's real (large) context. Both
sources satisfy that (hardcoded catalog 1,000,000; models.dev 512,000), so
assert the invariant (ctx > 204,800, stale value gone) instead of a literal
that external data can move. Renamed accordingly.

47/47 in test_minimax_provider.py pass.
2026-06-01 23:35:23 -07:00
Evi Nova 81dd43a8eb fix(docker): preserve Docker -w workdir in main-wrapper (#35472) (#36259)
Save the original working directory before init scripts cd to
/opt/data, then restore it before exec'ing the user command, so
the container starts in the Docker -w directory instead of /opt/data.

Adds regression test verifying cwd save/restore ordering in
main-wrapper.sh.
2026-06-02 16:13:44 +10:00
Teknium 272c2f30aa fix(kanban): kanban_create inherits the spawning worker's task workspace (#37182)
When a dispatcher-spawned worker (HERMES_KANBAN_TASK set) calls
kanban_create without an explicit workspace, the new child now inherits
the worker's own running-task workspace_kind/workspace_path instead of
defaulting to scratch. A worker editing a dir:/worktree project that
spawns a follow-up child keeps it in that project.

Orchestrators (kanban toolset, no HERMES_KANBAN_TASK) and CLI/dashboard
callers still default to scratch. An explicit workspace arg always wins.
2026-06-01 21:26:29 -07:00
Teknium bd8e2ec1a6 feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
2026-06-02 00:16:11 -04:00
Ben Barclay 40ae170647 ci(docker): use registry-backed build cache for arm64 (#37129)
The arm64 PR build ran fully uncached because the previous gha cache
backend's short-lived Azure SAS token expired mid-build on slow
cold-cache arm64 runs and crashed before the smoke test. Uncached arm64
PR builds were ~45% slower than amd64 (median 553s vs 382s), making the
arm64 job the one most often cancelled on supersede — surfacing as a red
X in PR checks and reading as 'the arm64 build keeps failing'.

Switch arm64 to a registry-backed cache on ghcr.io
(type=registry, ref ghcr.io/nousresearch/hermes-agent:buildcache-arm64).
Its credential is the job-lifetime GITHUB_TOKEN, not a time-boxed SAS
token, so the cold-build-outlives-token failure mode cannot recur.

- PR builds: cache-from only (read-only) — warm layers, no write races,
  no cache-ref pollution from rapid PR pushes.
- main/release builds: cache-from + cache-to (mode=max) to populate the
  cache for subsequent PR/main builds and let the digest push reuse the
  smoke-test build's layers.
- Add packages: write permission and a ghcr.io login for the cache.

amd64 keeps its gha cache: it builds fast enough to stay inside the SAS
token's lifetime, so it never hit this failure mode.
2026-06-02 14:03:40 +10:00
whyhkzkandBen 1495f0cc38 fix(file-safety): extend sandbox-mirror guard to cover inner-container path (#32049) (#32407)
* fix(file-safety): extend sandbox-mirror guard to cover inner-container path (#32049)

Brian's shape-based guard (#32213) catches paths that still carry the
full sandboxes/<backend>/<task>/home/.hermes/… prefix on the host side.
The inner-container case is not covered: when file tools execute inside
Docker the bind-mount strips that prefix, so the guard receives plain
/root/.hermes/… and passes through. The root:root ownership on the
divergent SOUL.md in #32049 confirms this is the primary failure mode.

Add a ContextVar (_CONTAINER_HERMES_MIRROR) set by DockerEnvironment
when persistent=True. classify_container_mirror_target / get_container_
mirror_warning detect any write whose resolved path falls under that
prefix, using the same warning format and cross_profile=True bypass
contract as the existing guards. Chain the new guard in
_check_cross_profile_path after the two existing detectors.

* fix(file-safety): derive Docker mirror guard from task

---------

Co-authored-by: Ben <ben@nousresearch.com>
2026-06-02 14:03:37 +10:00
Stephen Chin a5aecf26fa feat(kanban): gate notifier watcher on dispatch_in_gateway
Non-dispatch gateways no longer open per-board kanban DBs for notifier
polling. Mirrors the existing dispatcher gate (config
kanban.dispatch_in_gateway, default True; env override
HERMES_KANBAN_DISPATCH_IN_GATEWAY) so multi-gateway setups collapse to a
single process holding kanban.db file descriptors.

Salvaged from PR #31964 by @steveonjava; tests and docs trimmed during
salvage.
2026-06-01 20:30:24 -07:00
xxxigm c35ede789f refactor(cli): normalize note and avoid blank lines in prepend helper
Adopt the cleaner handling from PR #37080: coerce/strip the note and
skip the extra newlines when the underlying message (or text part) is
empty, while keeping the safer fail-open behavior for unknown shapes.
2026-06-01 20:30:08 -07:00
xxxigm a26a12ad07 test(cli): cover _prepend_note_to_message str/list handling
Regression coverage for the multimodal-message TypeError: note folding into
text parts, image-only insertion, empty-note passthrough, and unknown-shape
fail-open.
2026-06-01 20:30:08 -07:00
xxxigm 043350dfd3 fix(cli): prepend queued notes safely to multimodal messages
Sending an image to a vision model turns the user message into a list of
OpenAI-style content parts. When a /model or /reload-skills note was queued
for the same turn, the CLI did `note + "\n\n" + agent_message`, crashing the
agent thread with:

    TypeError: can only concatenate str (not "list") to str

Repro: `/model gpt-5.5 --provider openai-codex`, then paste+send an image.

Add _prepend_note_to_message(), which folds the note into the first text
part of a content-parts list (or inserts a leading text part for image-only
messages) and keeps the plain-string path unchanged. Used for both the
model-switch and skills-reload notes.
2026-06-01 20:30:08 -07:00
Teknium 21f55af769 fix(model-picker): stop routing OpenAI selection to OpenRouter (#37175)
The /model picker emitted a standalone slug=openai row (gated on
OPENAI_API_KEY). Selecting it ran resolve_provider_full("openai"),
which resolved the legacy providers.py alias openai->openrouter BEFORE
checking the user's own providers.openai config — silently switching
users onto OpenRouter (HTTP 401 when they have no OR key).

- model_switch.list_authenticated_providers: skip vendor names that are
  aliases to an aggregator (isolates openai->openrouter; copilot/kimi/etc.
  are real providers and unaffected). Kills the phantom picker row.
- providers.resolve_provider_full: user-config providers.<name> now wins
  over the built-in alias table, so providers.openai (api.openai.com)
  beats the alias.
- model_switch PATH A: user-config providers resolve credentials via
  their own endpoint instead of the name-based runtime resolver that
  doesn't know user-config slugs; plus a fail-loud guard for explicit
  unauthed-aggregator hops.

Verified E2E with the reporter's config (no OR key): selecting OpenAI +
gpt-4o-mini now resolves to api.openai.com instead of openrouter.ai.
2026-06-01 20:27:41 -07:00
Teknium 72e82f88c0 fix(kanban): decompose children inherit root workspace instead of forcing scratch (#37172)
decompose_triage_task hardcoded every fan-out child to workspace_kind
'scratch', ignoring the root task's workspace. A code-gen task created
with a dir:/worktree: workspace would fan out into throwaway scratch tmp
dirs (GC'd on archive), so generated code never landed in the project.

Children now inherit the root's workspace_kind + workspace_path. A child
dict may still override with its own workspace_kind/workspace_path; the
path only carries over when kinds match. Scratch roots are unchanged.
2026-06-01 20:26:57 -07:00
teknium1andGlucksberg fa3b06b035 refactor(telegram): generalize observed-media caching into a reusable primitive
Collapse the per-type observed-media dispatch into one platform-agnostic
cache_media_bytes() helper in gateway/platforms/base.py. Any adapter can now
hand it raw attachment bytes + a filename/MIME hint; it classifies against the
shared MIME registries, routes to the right cache_*_from_bytes helper,
sandbox-translates the path, and returns a CachedMedia with a ready
context_note(). Telegram's observed-group path shrinks to: size-gate, download,
call the helper, annotate. Also dedupes the addressed-media type ladder into
_media_message_type().

Net: contributor's Telegram-only +595 LOC becomes a +210/-32 production change,
with the reusable primitive available to Discord/Slack/Signal/etc.

Co-authored-by: Glucksberg <markuscontasul@gmail.com>
2026-06-01 20:18:41 -07:00
Glucksberg f768e75ecf fix(telegram): cache observed group media 2026-06-01 20:18:41 -07:00
teknium1 34468ed0d4 fix: normalize terminalBackground default and drop unrelated lockfile churn
Follow-up to the salvaged terminalBackground commit:
- align the CSS-var fallback and type doc to the runtime default (#000000)
- revert web/package-lock.json to main (the original commit stripped peer
  flags as an npm-version artifact, unrelated to the feature)
2026-06-01 20:13:56 -07:00
davidgut1982 fc995634cc feat(dashboard): add terminalBackground field to DashboardTheme
Wires the xterm.js terminal pane background color into the theme
system. Previously hardcoded as #0d2626; now reads from
DashboardTheme.terminalBackground with #000000 as default.

Users can override via ~/.hermes/dashboard-themes/*.yaml:
  terminalBackground: "#1a0a2e"
2026-06-01 20:13:56 -07:00
Stephen Schoettler f24b7ed9d9 fix: make Honcho startup fail open 2026-06-01 20:13:42 -07:00
Teknium 59510d7b44 feat(skills): fix browse cap, add source links + copy buttons + category cleanup (#37143)
Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:

CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
  'hermes-index' key — when the centralized index is available the router
  skips external APIs and serves only the index, so the default-50 fallthrough
  silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
  5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
  can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
  inherits the same index-aware source-skip (was double-counting); expose
  identifier in its output.

Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
  skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
  explicit extra.detail_url/source_url/repo_url. Expanded cards now show
  'View source' for community skills (was nothing) and keep 'View full
  documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
  the 45MB catalog fetches.

Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
  categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
  Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
  tag->category map so common community tags route to real buckets. 430 -> 173
  categories, top 20 all meaningful.

Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
2026-06-01 19:52:28 -07:00
Zyrixtrex 0cd5867bbb fix(whatsapp): honor dm_policy and group_policy open at the gateway 2026-06-01 19:51:21 -07:00
kyssta-exe d4b533de4e fix: batch of small robustness/correctness fixes from @kyssta-exe
Salvages 8 distinct fixes from a batch of PRs by @kyssta-exe, reapplied
onto current main (original branches were stale) with a few refinements.

- cron(jobs.py): load_jobs() validates top-level JSON shape — a bare
  list auto-repairs into the {"jobs": [...]} dict; scalars/null raise a
  clear RuntimeError instead of an uncaught AttributeError that took
  down the whole cron subsystem (#37065, closes #36867).
- web(web_server.py): close the per-action log file handle after Popen
  so the parent stops leaking one fd per spawned action (#36843).
- web(web_server.py): DELETE /api/env returns 400 for invalid key names
  instead of a misleading 500, mirroring PUT /api/env (#36840).
- gateway(gateway.py): read /proc/<pid>/cmdline inside a with-block so
  the fd is released immediately instead of relying on GC (#36804).
- web-tools(web_tools.py): include "xai" in check_web_api_key() so a
  configured X.AI web backend reports as available (#36802).
- compression(conversation_compression.py): mark the feasibility check
  done only after it completes, and default the gate to "not checked"
  if the attribute is missing (#36803).
- completion(completion.py): replace `ls` with directory globbing in the
  generated bash/zsh/fish profile listers — handles names with spaces
  and skips non-directory entries (#36806).
- terminal-tool(terminal_tool.py): drop a duplicate `import threading`
  (#36808).
- claw(claw.py): the migrate recommendation now points at the real
  `hermes gateway stop` command instead of the non-existent
  `hermes stop` (#36795, #36796, closes #36771).
- tests: guard against a leaked HERMES_CRON_SESSION breaking gateway
  approval tests — add it to the hermetic conftest unset list (root
  cause, protects every test) and pop it in the affected test's
  setup_method (#36796).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-01 19:51:03 -07:00
64f7f36713 fix(mcp): make non-MCP HTTP endpoint fast-fail robust and non-retryable
Reworks the content-type preflight so a misconfigured HTTP MCP url (a web-app
root serving HTML) fails in <1s instead of hanging the full 60s connect_timeout
— and does so non-retryably, which neither original PR achieved.

- Allow-list detection (application/json, text/event-stream) instead of a
  text/html-only denylist — catches text/plain, application/xml, etc.
- New NonMcpEndpointError(ConnectionError); run() catches it in the same
  top-level fast-fail block as InvalidMcpUrlError, so it returns before the
  reconnect-backoff loop (truly non-retryable) and the probe runs once, not
  on every reconnect.
- Probe runs on its own httpx client OUTSIDE the SDK anyio task group, so the
  error propagates as itself rather than wrapped in an ExceptionGroup (the
  trap that made the in-SDK event-hook approach a no-op).
- Forwards ssl_verify + client_cert + headers; HEAD->GET fallback on 405/501;
  best-effort pass-through on missing content type, non-2xx, and network
  errors; skips SSE transport. CancelledError is never swallowed.
- Replaces the malformed test file (which never imported the real method and
  failed CI) with 21 tests driving the actual _preflight_content_type against
  a real local HTTP server, plus full run() integration verifying <1s
  non-retryable failure.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: uzunkuyruk <egitimviscara@gmail.com>
2026-06-01 19:49:50 -07:00
liuhao1024 c914e4a371 fix(mcp): fail fast on HTML content-type instead of waiting full connect_timeout
A misconfigured MCP server URL that returns text/html (e.g. pointing at
a web app root instead of an MCP endpoint) causes the MCP SDK to block
for the full connect_timeout (default 60 s) before surfacing
CancelledError.

Add a lightweight HEAD pre-flight check that detects text/html responses
in ≤5 s and raises ConnectionError with an actionable message. Non-HTML
responses, missing headers, and network errors pass through silently so
the normal MCP handshake proceeds unaffected.

Fixes #36052
2026-06-01 19:49:50 -07:00
brooklyn!andCopilot Autofix powered by AI fabca0bdd8 feat(tui): single /model command + unified Sessions overlay (#37112)
* feat(tui): single /model command + unified Sessions overlay

Collapse the redundant `/provider` alias so `/model` is the only name
everywhere (it already drove the same 2-step ModelPicker in the TUI).

Merge the separate `/resume` (cold history browser) and `/sessions` (live
switcher) surfaces into one Sessions overlay reached by `/resume`,
`/sessions`, `/session`, and `/switch`. It pins a "+ new" row at the top
(always visible), lists live sessions with status, and lists resumable
history below — dispatching session.activate for live rows vs resume for
cold ones, with close/delete in place. Fixes `/session` opening an empty
live-only switcher and the hidden new-session affordance.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(tui): address Copilot review on the Sessions overlay

- Track the armed history-delete by session id instead of row index so the
  1.5s live-status poll re-indexing rows can't redirect the second `d` to a
  different session.
- Re-add the busy-session guard to immediate `/resume <id>` and `/sessions new`
  actions (browsing the bare overlay stays allowed) so resuming/switching can't
  corrupt an in-flight turn's streaming/busy state.

* fix(tui): guard cold-resume (not live-switch/new) from the Sessions overlay

Copilot flagged that overlay actions bypassed the busy guard. Only cold
resume actually closes the current session, so only it is guarded — both
from the slash path and now from the overlay (appActions.resumeById).
Switching between live sessions and starting a `+ new` live session keep
the current session running in the background, so they stay unguarded:
that concurrency is the orchestrator's whole purpose. Also dropped the
over-broad guard on `/sessions new` for the same reason.

* fix(tui): address Copilot review (history dedup + desktop /provider)

- The 1.5s poll now re-derives the resumable list from the RAW session.list
  results (rawHistoryRef) against the current live set, so a session hidden
  while live reappears in history once it closes — instead of being lost
  until a full reload. Delete also prunes the raw ref.
- Drop the dead `/provider` entry from the desktop PICKER_OWNED_COMMANDS now
  that the alias is gone, so the desktop client no longer advertises it.

* fix(tui): surface session.list errors + keep selection stable across polls

- A garbled session.list response now surfaces an error and preserves the
  last good raw history, instead of silently blanking the resumable section.
- The 1.5s poll re-anchors the selection to the same row by session id
  (live or history) when the live list grows/shrinks, so the highlight no
  longer drifts to a different row mid-interaction.

* fix(tui): degrade session.list independently + cover overlay helpers

- Fetch active_list and session.list via Promise.allSettled so a failing
  session.list no longer rejects the whole load: live sessions still render
  and only the resumable history degrades (with an error).
- Add unit tests for the new helpers (sessionRowKindAt row ordering,
  resumableHistory dedupe, sessionsCountLabel, relativeSessionAge).

* test(tui-gateway): assert /provider alias is gone, /model remains

The CI test_complete_slash_includes_provider_alias asserted the removed
`/provider` alias still autocompleted. Flip it to lock in the removal:
`/pro` no longer offers `provider`, and `/mod` still completes `model`.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 22:28:36 -04:00
Zyrixtrex f7a3509b25 fix(gateway): honor WECOM_ALLOWED_USERS in env-only WeCom DM allowlist 2026-06-01 19:20:36 -07:00
brooklyn! 7d51cd7516 Merge pull request #37115 from NousResearch/bb/tui-statusbar-responsive
fix(tui): prioritize status/model over cwd in the status bar on narrow terminals
2026-06-01 21:10:18 -05:00
Brooklyn Nicholson 13a2350c8d fix(tui): pass indicatorStyle into FaceTicker so render matches reservation
FaceTicker now takes the indicator style as a prop (same value used by
busyIndicatorWidth) instead of reading the store independently, so the
rendered busy indicator and its reserved width can't desync on /indicator
changes.
2026-06-01 21:02:32 -05:00
Jeffrey Quesnelle f600352e43 Merge pull request #37123 from NousResearch/installer-optional-commit-pin
feat(installer): make commit pinning opt-in, default to branch-follow
2026-06-01 22:01:57 -04:00
Julien Talbot 8104b20269 fix(xai): route video models by modality 2026-06-01 19:00:30 -07:00
eee32cdd52 fix(gateway): fall back to in-process heartbeat when s6 sleep is missing (#36208) (#37120)
Inside an s6 container, `gateway run` redirects to the supervised
gateway and then keeps the CMD process alive as a no-op heartbeat so
/init doesn't start stage-3 shutdown. That heartbeat is
`os.execvp("sleep", ["sleep", "infinity"])`, which does a PATH lookup
for the `sleep` binary. When PATH was empty/truncated/clobbered at that
point — e.g. after user customizations rewrote PATH, or on a minimal
image without `sleep` on PATH — the exec raised FileNotFoundError,
killing the CMD process and causing /init to tear down every service:
the container failed to start (issue #36208, a regression in the s6
image from 2026.5.28).

Wrap the exec in try/except OSError: on success it still replaces the
process with the cheap `sleep` heartbeat (no resident Python
interpreter, and the existing process-tree/recursion contract is
preserved); on failure it falls back to `_block_until_terminated()` —
a SIGTERM handler (clean 128+signum exit on `docker stop`) plus a
signal.pause() loop, which needs no external binary and so can't fail
on PATH state. A threading.Event().wait() fallback covers platforms
without signal.pause().

Keeping execvp as the primary path (rather than replacing it outright)
preserves the `sleep infinity` heartbeat that the docker integration
tests assert (test_gateway_run_supervised.py) and avoids leaving a
full Python interpreter resident for the container's lifetime.

Verified end-to-end on a built image: with execvp forced to fail,
_block_until_terminated() blocks cleanly instead of raising
FileNotFoundError; normal boot still runs the cheap `sleep infinity`
heartbeat; the 6 test_gateway_run_supervised.py integration tests pass.

Salvages the two community fixes for this issue — the fallback design
from #36221 (@Pluviobyte) and the signal.pause() heartbeat from #36267
(@karmeleon) — and adds regression tests for both the normal and
sleep-missing paths.

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: karmeleon <karmeleon@users.noreply.github.com>

Closes #36208.
2026-06-02 11:59:27 +10:00
Brooklyn Nicholson 899e8b9067 fix(tui): keep fmtCwdBranch default, cap cwd at the status-bar call site
Reverts the shared fmtCwdBranch default (28 → 40) so it isn't an API/
behavior change for other callers, and instead passes max=28 explicitly
from the status-bar caller where the tighter cap is intended.
2026-06-01 20:55:14 -05:00
teknium1 abe0e19c0a refactor(bluebubbles): simplify mention-gating helpers
Collapse the three mention-parsing helpers into one _compile_mention_patterns
that handles list/string/None inputs, and inline the require_mention bool
coercion to match the signal/dingtalk convention. Same behavior, 16 fewer
lines, no per-instance state in the staticmethod.
2026-06-01 18:52:05 -07:00
Trevin Chow d967e74427 chore: add contributor attribution mapping 2026-06-01 18:52:05 -07:00
Trevin Chow 05022066ea feat(bluebubbles): support group mention gating 2026-06-01 18:52:05 -07:00
Brooklyn Nicholson e25b2a6e18 fix(tui): address Copilot review on status-bar tail disclosure
- Render SpawnHud last in the tail so its un-budgeted (dynamic) width can
  only truncate itself, never push budgeted segments past leftWidth.
- Precompute kaomoji/emoji frame widths once at module load instead of
  rescanning FACES/EMOJI_FRAMES on every status render.
- Correct the tail-priority comment to match the actual fits() order
  (bar, duration, compressions, voice, session count, bg, cost).
2026-06-01 20:49:51 -05:00
Brooklyn Nicholson 9cb7d40d8d fix(tui): derive busy/duration reservation width from fmtDuration
fmtDuration renders a space between units (e.g. `59m 59s`), so the flat
6-col reservation under-counted and could let the elapsed-time tail shove
the model off-screen / break the whole-segment budget. Reserve the bounded
clock width from fmtDuration itself (MAX_DURATION_WIDTH) in both the busy
indicator reservation and the tail duration budget.
2026-06-01 20:42:04 -05:00
brooklyn!andCopilot Autofix powered by AI 85b65e29f0 feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099)
* feat(desktop): session hygiene, archive, media streaming + connecting overlay

Address a batch of desktop feedback:

- Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB
  row on every session.create (i.e. every launch/draft). Persist the row
  lazily on first prompt instead, and hide message-less rows in the sidebar.
- Archive/hide sessions: new `archived` column + set_session_archived, web
  API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item
  in the sidebar, and an "Archived Chats" settings panel to restore/delete.
- Videos load via a streaming `hermes-media://` protocol instead of capped,
  in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking.
- Background-process completions route to the session that launched them:
  the completion event now carries session_key and each poller only consumes
  its own.
- Sidebar: "Group by workspace" toggle is always visible; each workspace
  group gets a "+" to start a session in that directory; "New agent"/"Agents"
  relabeled to "New session"/"Sessions".
- New gateway connecting overlay (ascii decode → fade out) replacing the bare
  skeleton/"starting gateway" state.

* fix(desktop): bail connecting overlay on boot error

The shownRef latch kept the connecting overlay mounted behind
BootFailureOverlay after a hard boot failure. Return null on boot.error
so the failure recovery surface fully owns the screen.

* fix(desktop): address Copilot review

- /api/sessions: validate `archived` (400 on unknown) and return `archived`
  as a JSON boolean instead of SQLite's 0/1.
- PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no
  updatable fields; stop conflating a no-op with "not found".
- hermes-media protocol: drop `bypassCSP` — streaming only needs
  secure/standard/stream/supportFetchAPI.
- Sidebar workspace header: split the toggle and the "+" into sibling buttons
  so we no longer nest interactive elements inside a <button>.

* fix(desktop): address Copilot re-review

- hermes-media protocol: restrict streaming to an audio/video extension
  allowlist (415 otherwise) so it can't be used to read arbitrary local files.
- Connecting overlay: use z-[1200] instead of the non-standard z-1200 utility.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-01 20:41:34 -05:00
Ben Barclay ddc22866a3 chore(release): add whyhkzk to AUTHOR_MAP for PR #32407 (#37121) 2026-06-02 11:41:22 +10:00
emozilla 1d9aacbd00 feat(installer): make commit pinning opt-in, default to branch-follow
The bootstrap installer's build.rs unconditionally baked a commit pin via
`git rev-parse HEAD`, forcing every dev build to clone an exact SHA at
install time. That SHA had to be pushed to origin or the fresh-box clone
would fail.

Make the commit pin opt-in: by default build.rs bakes ONLY the detected
branch, so the installer follows that branch's HEAD at install time. Set
HERMES_BUILD_PIN_COMMIT (SHA, tag, or branch name) to bake an immutable
commit pin for reproducible/release builds; it is resolved to a SHA via
`git rev-parse --verify <ref>^{commit}` and fails loud on an unresolvable
ref. Runtime resolution already supported branch-only pins, so no changes
needed in bootstrap.rs / install_script.rs / install.ps1.
2026-06-01 21:35:46 -04:00
Brooklyn Nicholson 2f171743b7 fix(tui): pin status/model, whole-segment tail disclosure, smaller cwd
The previous reservation set the left box width but everything still
shared one flex row, so the lower-priority tail + cwd could still shrink
`ready`/model down to fragments ("re"). Pin the essentials (indicator +
model + context) in a non-shrinking group, and render the tail segments
(bar, duration, compressions, voice, session count, bg, cost) only when
the whole segment fits in the leftover space — in priority order — so
nothing truncates mid-segment and the low-value tail drops first.

Also shrink the cwd/branch label (max 40 → 28) so it stops dominating the
bar on roomy-but-not-huge terminals.
2026-06-01 20:32:27 -05:00
162c7856ca fix(file-safety): add sandbox-mirror soft guard for writes to per-task .hermes mirrors (#32213)
#32049 reports that under terminal.backend: docker, write_file / patch
calls to authoritative profile state (SOUL.md, memories, etc.) land on
the sandbox-local mirror at
``<HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...``
— a path the host Hermes process never reads. The tool reports success,
the user sees no behavior change, and on disk two divergent copies of
SOUL.md (or any other profile file) accumulate.

The existing classify_cross_profile_target guard does not catch this:
its parts[2] check sees "sandboxes" and returns None, and the path is
in-profile from the inner-mirror perspective so even a fixed version
would not fire.

Add a parallel sandbox-mirror classifier in agent/file_safety:

  * classify_sandbox_mirror_target() detects the
    ``…/sandboxes/<backend>/<task>/home/.hermes/…`` shape via path parts.
    Detection is path-shape only — backend-agnostic, does not require
    the file to exist, and works regardless of which HERMES_HOME resolves.
  * get_sandbox_mirror_warning() returns a model-facing warning that
    names the mirror root and the inner authoritative path the agent
    likely meant.

Wire both detectors through tools/file_tools._check_cross_profile_path
so the existing write_file and v4a patch call sites pick up the new
guard with no API change. The bypass kwarg (``cross_profile=True``)
remains shared between the two guards — same "I know what I'm doing"
escape valve after explicit user direction.

This is the defense-in-depth piece of the proposal in #32049 ("any
…/sandboxes/<backend>/…/home/…hermes/… path as sandbox-mirror"). It
catches the host-side speculation case where the agent writes a literal
sandbox-mirror path. The inner-container case (where the bind mount
strips the ``sandboxes/`` prefix from the agent's path view) is out of
scope for this surgical change — that requires either a dispatch-layer
host-side check before the container handoff, or the host-side
``profile_state`` / ``soul`` tool the issue also proposes.

Soft guard, NOT a security boundary — matches the existing
classify_cross_profile_target contract.

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-06-02 11:29:24 +10:00
Brooklyn Nicholson 1d7a1c00b4 fix(tui): make busy status-bar reservation /indicator-style aware
The left-content reservation used a flat constant for the busy face,
but its width varies by /indicator style: kaomoji is a wide glyph plus
a rotating verb, while unicode is a bare 1-col braille spinner with no
verb. Reserve the real width via busyIndicatorWidth(style, hasDuration)
so the model stays on-screen across styles without over-reserving the
unbounded elapsed-time tail.
2026-06-01 20:28:43 -05:00
Brooklyn Nicholson e59b815c04 fix(tui): prioritize status/model over cwd in the status bar on narrow terminals
The status rule reserved only 8 cols for the left segments, so the
cwd + git-branch label on the right could grow until the loading
indicator, model, and context read-out were crushed to almost nothing
(sometimes collapsing to a single illegible line) on small screens.

Reverse the priority: `statusRuleWidths` now reserves the display width
of the must-keep left content (status indicator + model + context) so
the cwd/branch segment truncates first. Add `statusBarSegments(cols)`
progressive disclosure — as the terminal narrows the low-priority tail
sheds in order (cost → bg → voice → compressions → duration → context
bar), and below the bar breakpoint the context read-out collapses to a
bare token count. Status and model are always guaranteed room.

Default `minLeftContent = 0` keeps `statusRuleWidths` byte-identical for
existing callers.
2026-06-01 20:26:41 -05:00
Ben Barclay 4f7fe9bcff fix(dashboard): surface Docker update guidance instead of generic failure (#34347) (#37085)
The dashboard Update button's backend guard (#36263) already returns a
structured {ok:false, error:"docker_update_unsupported", message,
update_command} envelope (HTTP 200) when running in a Docker install,
instead of surfacing a raw SystemExit. But the frontend ignored that
envelope: runAction() only branched on a thrown error, so the 200 fell
through to the action-status poll, which reported a generic
"Action failed (exit 1)" toast and never showed the actual guidance.

Now runAction() inspects the update response and, on the
docker_update_unsupported case, surfaces the backend's guidance message
plus the recommended re-pull command directly (success-styled, since it's
actionable guidance — not a crash) without starting the poll.

Closes #34347.
2026-06-02 10:36:10 +10:00
firefly 3a8d643d37 chore(release): map caojiguang@gmail.com in AUTHOR_MAP
The fix commit preserves @caojiguang's authorship (from #31853); the
release-notes AUTHOR_MAP gate requires their email to map to a GitHub
username.
2026-06-01 17:31:40 -07:00
firefly 765790a216 test(weixin): regression suite for _api_post/_api_get timeout migration 2026-06-01 17:31:40 -07:00
Cao Jiguang 566669013f fix(weixin): replace aiohttp ClientTimeout with asyncio.wait_for in _api_post/_api_get
Cron delivery to WeChat fails with 'Timeout context manager should
be used inside a task' because _api_post and _api_get use aiohttp's
ClientTimeout directly.  When the cron scheduler calls send() via
asyncio.run_coroutine_threadsafe(), aiohttp cannot find a running
task and raises RuntimeError.

_upload_media, _download_bytes, and _download_remote_media already
use asyncio.wait_for() to avoid this.  Apply the same pattern to
_api_post and _api_get — the two remaining iLink API helpers that
still use the raw ClientTimeout approach.

This fixes cron delivery errors seen on the WeChat platform adapter
when meyo-external cron jobs attempt to deliver output to WeChat.
2026-06-01 17:31:40 -07:00
firefly a1f76ba7e9 fix(gateway): recover extract-stripped tool responses on all platforms (#29346)
The extract pipeline (extract_media/extract_images/extract_local_files +
directive strips) can reduce a non-empty tool-using response to empty
text_content with no deliverable attachment. The 'if text_content' send
guard then silently skips delivery: a 'response ready' log with no
'Sending response', no error, and the answer never reaches the user.

- A2: snapshot the pre-extract response; when extraction yields empty text
  and no image/local/media attachment, deliver the recovered original from
  the post-extract_media body (so a spaced MEDIA path can't leak). Applies
  on ALL platforms (supersedes the Discord-only #33842 and the unsafe
  raw-fallback #29499).
- A3: loud delivery invariant - a non-empty response that produces nothing
  deliverable logs response_delivery_dropped at ERROR; every recovery logs
  response_delivery_recovered. No silent drop survives.
- Factor a _strip_media_directives helper for the [[...]] strips; MEDIA
  stripping stays owned by extract_media, whose grammar handles spaced and
  quoted paths.
- Salvaged + de-scoped the #33842 test harness to all platforms; added
  unrecoverable-drop and no-leak regression tests.
2026-06-01 17:31:32 -07:00
firefly 8bf498c21d fix(gateway): scope final-delivery flags to turn-final segment (#29346)
A streamed preamble ("Let me search...") finalized at a tool boundary
routed through _try_fresh_final, which unconditionally set
_final_response_sent=True even though it is a NON-final segment. The
gateway then reads that flag as "final delivered" and suppresses the
genuine final answer produced on the next API call, so the user silently
gets nothing. Only reproduces with fresh_final_after_seconds > 0.

- _try_fresh_final / _send_or_edit take is_turn_final; the segment-break
  call site passes is_turn_final=got_done so only the turn-final answer
  marks final-delivered.
- _reset_segment_state clears the final-delivery flags at every tool
  boundary as defense-in-depth against any future premature setter.
- Failing-first regression + happy-path no-duplicate test.
2026-06-01 17:31:32 -07:00
Teknium 92273e4f57 docs: add 25 new community user stories to the collage (#37048)
Sourced from X/Twitter, blogs (Medium/Substack/dev.to), and YouTube since the
last refresh. Deduped against the existing 237 entries by id, url, and author.
237 -> 262 stories.

Highlights: 24/7 Mac Mini agent at $21/mo (@witcheer), automated TikTok
slideshow factory (@cyrilXBT), per-client isolated profiles as an AI-ops
business (@IBuzovskyi), PM briefing 20->8min (@aakashgupta), Railway+Telegram
deploy gotchas (Tessa Kriesel), compounding-cost field report (chintanonweb),
18-agent Kanban fleet (Tonbi), and several daily-automation setups.
2026-06-01 17:01:18 -07:00
kshitijk4poor 0fdab53ef0 feat(cli): ranked fuzzy search in the curses model picker
Wires the salvaged search helpers into the shared curses menu driver and
turns on type-to-filter for the CLI model pickers (the 100+ model lists
that previously required scrolling).

- Search lives in the shared `_run_curses_menu` driver behind a
  `searchable` flag + `search_labels`, so both `curses_radiolist` and
  `curses_single_select` get it without per-menu duplication. `/` opens
  the filter, BACKSPACE edits, Ctrl+U clears, ESC clears the filter then
  cancels. Returned values are always original item indices.
- `_filter_indices` RANKS matches (best-first) via a Python port of the
  TS scorer in ui-tui/src/lib/fuzzy.ts and web/src/lib/fuzzy.ts. The port
  is byte-identical in score: same per-char bonuses, prefix (+8) and
  exact (+20) bonuses, camelCase/word-boundary detection (matching on the
  lowercased target, boundary on the original case), and the -len*0.01
  length tiebreak — so the CLI, TUI, and WebUI rank results identically.
  A cross-language parity test pins the exact scores.
- `_prompt_model_selection` (the canonical picker across the model flows)
  and the custom-provider model list pass `searchable=True`.
- Split `_decode_menu_key` out of `read_menu_key` so the search loop can
  peek the raw key (catch `/`) before nav decoding.
- ESC during active search now clears the query (restores the full list)
  so a no-match filter can't strand the user; printable-key capture is
  restricted to ASCII to avoid Latin-1 mojibake.
- Update two setup-menu tests whose mock signatures predate the new
  `searchable` kwarg; add ranked-scorer + parity + state-machine tests.
2026-06-01 16:58:58 -07:00
Harish Kukreja 53f598e7a2 feat(cli): add fuzzy search helpers for curses pickers
Pure, refactor-independent helpers for type-to-filter search in the
curses single-/radio-select menus: subsequence matching, filtered-index
mapping, cursor reconciliation, scroll clamping, and an active-search
key handler, plus unit tests.

Salvaged from #22758 (the curses event loop was since refactored into a
shared driver on main, so the integration is rebuilt in a follow-up
commit; these pure helpers and their tests carry over unchanged).
2026-06-01 16:58:58 -07:00
kshitijk4poor 7527e7aeac feat: fuzzy search for the model picker (WebUI + TUI)
Adds fuzzy subsequence matching with quality ranking to the model
pickers, replacing the WebUI's exact-substring filter and giving the
TUI a search where it previously had none.

- New fuzzy scorer (ui-tui/src/lib/fuzzy.ts + an identical copy at
  web/src/lib/fuzzy.ts, since the two are separate TS packages with no
  shared module). Matches a query as an ordered subsequence (so `g4o`
  matches `gpt-4o`), scores by quality (exact > prefix > word-boundary >
  contiguous > scattered) and returns matched character positions for
  highlighting. Multi-token AND semantics (`clad snnt` -> claude-sonnet).
  15 vitest tests cover the algorithm.

- WebUI ModelPickerDialog: ranked fuzzy filter on providers + models;
  matched characters in model rows are highlighted via <mark>.

- TUI modelPicker: type-to-filter on the provider and model stages with
  live ranking. Backspace edits the filter, Ctrl+U clears it, Esc clears
  a non-empty filter before navigating back. Persist-global / disconnect
  shortcuts moved from g/d to Ctrl+G / Ctrl+D so letters feed the filter.

Closes #30849
2026-06-01 16:58:58 -07:00
Tekniumandsbw2025 c45593ceae docs: expand quickstart Skills section (#37047)
* fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass

* fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes

Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>

* chore(release): map Subway2023 for PR #14639 salvage

* docs: expand quickstart Skills section

The Skills section was two bare commands with no framing — it never said
what a skill is, how skills load, or what the install slug means. Expanded
to explain the concept, the bundled catalog, install/browse/use flow, and
slash-command activation. Removed the inaccurate /skills chat-command hint
(skills become individual /<name> commands; hermes skills is the CLI verb).

---------

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 16:56:50 -07:00
firefly 128da68823 test(tools): characterize tool-surface TERMINAL_CWD contract (#29265)
Port PR #29365's tool-surface contract test: terminal/file/execute_code
already honor TERMINAL_CWD (out of scope for the resolver cluster). Pinning
the behavior makes the supersession of #29365 airtight and guards against a
future refactor silently regressing the workspace contract.
2026-06-01 16:55:04 -07:00
firefly ac0cce5f3f test(agent): pin whitespace-strip and OSError-propagation in runtime_cwd
Cover the two new hardening behaviors that were unpinned: whitespace-only
TERMINAL_CWD falling through to getcwd/None, and OSError from the getcwd
fallback arm propagating to the build_environment_hints try/except guard.
2026-06-01 16:55:04 -07:00
firefly 75f478750c docs(test): correct None-semantics comment in test_runtime_cwd (discovery not skipped) 2026-06-01 16:55:04 -07:00
firefly eadfeef60e docs(agent): correct resolve_context_cwd comment (None → caller getcwd fallback, not skip) 2026-06-01 16:55:04 -07:00
firefly f90777a6b8 refactor(prompt): route context-file cwd through runtime_cwd resolver 2026-06-01 16:55:04 -07:00
firefly c79b80a8a5 test(prompt): place cwd regression tests in TestEnvironmentHints (drop redundant docker case) 2026-06-01 16:55:04 -07:00
firefly 16047655b5 fix(prompt): show configured working directory in system prompt (closes #24882, #24969, #27383, #29265) 2026-06-01 16:55:04 -07:00
firefly 2564760d7a test(agent): pin context_cwd isdir-skip asymmetry and tilde expansion 2026-06-01 16:55:04 -07:00
firefly 4bc7296042 feat(agent): add runtime_cwd resolver (single source of truth for working dir) 2026-06-01 16:55:04 -07:00
teknium1 f1237aa95b chore(release): map maxcz79 author email for AUTHOR_MAP 2026-06-01 16:36:43 -07:00
maxcz79 32032e1e2d fix(simplex): avoid reconnecting healthy idle websocket
Do not treat lack of application-level SimpleX events as a stale WebSocket. The websockets client already uses protocol ping/pong for connection liveness, so quiet but healthy connections should not be closed by the health monitor.
2026-06-01 16:36:43 -07:00
Tekniumandsbw2025 e946f49ab5 fix(models): add gemini-3.5-flash to Gemini OAuth + API-key pickers (#37046)
* fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass

* fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes

Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>

* chore(release): map Subway2023 for PR #14639 salvage

* fix(models): add gemini-3.5-flash to Gemini OAuth + API-key pickers

#34581 swapped gemini-3-flash-preview -> gemini-3.5-flash in the
OpenRouter and Nous lists but missed the curated Gemini catalogs, so
the Google OAuth (google-gemini-cli) picker still offered the retired
gemini-3-flash-preview slug and gemini-3.5-flash was unselectable.

Per Google's docs gemini-3-flash-preview was renamed to gemini-3.5-flash
and is served via Cloud Code Assist, so this completes the rename for:
- google-gemini-cli (OAuth/Code Assist) picker
- gemini (API-key) picker
- gemini provider default_aux_model

copilot keeps gemini-3-flash-preview (separate backend, own slug).

---------

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 16:31:13 -07:00
Teknium 1ffa22ee6b fix(minimax): drop stale ≤204,800 cache entries for MiniMax-M3 (#36726)
M3 is 1M context, but pre-catalog builds resolved it via the generic
'minimax' catch-all (204,800) and persisted that to the context-length
cache. Step 1 of get_model_context_length returned the cached value
directly before reaching the 'minimax-m3' (1M) catalog entry, so users
who first probed M3 on an older build were stuck at 204K forever (e.g.
/new in the Telegram gateway showing 'Context: 204K tokens (detected)').

Mirror the existing Kimi/Codex stale-cache guards: when a cached entry
for a minimax-m3 slug is <= 204,800, drop it and re-resolve. M2.x slugs
(correctly 204,800) are untouched since they don't match the M3 name.
2026-06-01 14:59:07 -07:00
BenandClaude Opus 4.8 b9646276fd fix(utils): guard os.fchmod for Windows in atomic_json_write
os.fchmod is Unix-only; the Windows os module has no fchmod (only
chmod). Passing mode= (e.g. 0o600 when saving the Hindsight config
during `hermes memory setup`) crashed on Windows with:

    AttributeError: module 'os' has no attribute 'fchmod'

Guard the fchmod fast-path with hasattr(os, "fchmod"). Skipping it on
Windows is safe: mkstemp already creates the temp file as 0o600, and
the existing post-replace os.chmod(real_path, mode) — already wrapped
in try/except — applies the final mode durably (as far as Windows
honors it).

Adds regression tests: one simulating a Windows os module without
fchmod (must not raise), and one asserting the durable 0o600 mode on
POSIX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:57:10 -07:00
kshitij a5371b3e68 chore: add benfrank241 to AUTHOR_MAP (#36898)
Maps ben.bartholomew@vectorize.io -> benfrank241 so the contributor
attribution audit passes when their commit lands via #36824.
2026-06-01 16:47:07 +00:00
Max HsuandClaude Opus 4.8 038ed94a6c fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks
When the TUI exits via Ctrl+C, SIGTERM/SIGHUP, or a crash, prompt_toolkit's
teardown can be bypassed, leaving DEC 1004 (focus reporting) and 1000/1002/1003
(mouse tracking) enabled. The terminal then emits raw ESC[I/ESC[O focus events
and fragmented SGR mouse reports as visible text in whatever runs next in the
same tab.

_run_cleanup() — the once-only cleanup that runs on every catchable exit path
(atexit-registered + called on the normal/EOF/interrupt exit) — now emits
_TERMINAL_INPUT_MODE_RESET_SEQ (the same disable sequence the in-session leak
recovery already uses) as its FIRST step, so the terminal is usable immediately
on Ctrl+C and a later teardown step raising can't skip it.

The reset is gated on a new _tui_input_modes_active flag (set right before
app.run(), cleared once the modes are disabled) so non-TUI one-shot CLI runs —
which share _run_cleanup via atexit — don't emit codes for modes they never
enabled. Writes to sys.stdout when it's the terminal, else falls back to
/dev/tty. SIGKILL is uncatchable and the kanban worker's os._exit(0) bypasses
atexit, but both are non-TTY/non-TUI so there is nothing to reset there.

Adds tests/cli/test_tui_terminal_reset_on_exit.py (9): emits on a TTY when the
TUI ran, no-ops when the TUI never ran, /dev/tty fallback when stdout is
redirected, no-op when neither is available, swallows stdout errors, flag set
and cleared, and wired into _run_cleanup as the first step even when a later
step raises.

Fixes #36823

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:27:44 +08:00
teknium1 ef3a650f05 chore(release): map Subway2023 for PR #14639 salvage 2026-06-01 03:29:48 -07:00
teknium1andsbw2025 4e9d886d9d fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes
Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but
the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml
already tripped the project-config pattern, while `sed -i` and direct edits
slipped through with auto-approve. An unpaired write_file deny is theater per
SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the
mtime-keyed config cache reloads it mid-session.

config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist
live there), so it warrants real pairing, not a half-door. Add a
_HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into
_SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for
both config.yaml and .env. Pins 9 regression tests including no-regression
guards (reads pass, /tmp writes pass).

Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
2026-06-01 03:29:48 -07:00
sbw2025 8f2931e3ee fix(file_tools): block agent writes to ~/.hermes/config.yaml to prevent silent approval bypass 2026-06-01 03:29:48 -07:00
023149f665 fix(agent): stop reporting broken streams as output-length truncation (#36705)
A stream that drops mid-response after tokens are delivered (peer-closed
connection, stale-stream reconnect) is converted into a synthetic
finish_reason="length" stub. The conversation loop treated that network
stall as a max-output-tokens truncation: when the dropped content was a
tool call it retried exactly once, then hard-failed with "Response
truncated due to output length limit" — even on large-output models that
never hit any cap (e.g. Opus).

- Tool-call truncation now retries up to 3 times (was 1) with a
  progressive max_tokens boost, and is stub-aware: a PARTIAL_STREAM_STUB_ID
  stall prints "Stream interrupted mid tool-call — retrying (n/3)" instead
  of the false "model hit max output tokens", and the give-up message
  distinguishes a network drop from a real truncation.
- Length-continuation retries preserve the original request's output cap
  as a floor, so a high provider/model default isn't silently downshifted
  to 8K/12K on retry.
- Added _requested_output_cap_from_api_kwargs() helper.

Tests: stub-stall mid-tool-call recovery within 3 retries; continuation
preserves a large provider-default output cap.

Fixes #26425. Salvages the substance of #26427 (cap floor) and #9525
(retry bump), adapted to the post-refactor conversation_loop.py which
handles all three api_modes uniformly.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: ygd58 <ygd58@users.noreply.github.com>
2026-06-01 03:01:20 -07:00
Teknium b571ec298d feat(dashboard): full administration panel — MCP, pairing, webhooks, credentials, memory, gateway, ops (#36704)
* feat(dashboard): backend API for MCP, pairing, webhooks, credential pool, memory, gateway lifecycle

Adds REST endpoints so a remote admin can manage these without CLI access:
- MCP servers: list/add/remove/test (config.yaml parity with hermes mcp)
- Pairing: list/approve/revoke/clear-pending messaging codes
- Webhooks: list/subscribe/remove (hot-reloaded JSON store)
- Credential pool: list/add/remove rotation keys (via CredentialPool API)
- Memory provider: status/select/disable/reset
- Gateway lifecycle: start/stop (restart+update already existed)

Secrets redacted on read; usable values only reach the agent at session start.
All endpoints sit behind the existing dashboard auth gate.

* feat(dashboard): backend API for ops + skills hub

- Ops actions (spawned, log-tailed via /api/actions): doctor, security audit,
  backup, import, checkpoints prune
- Ops reads (structured JSON): hooks list + allowlist status, checkpoints list
  with per-session size
- Skills hub actions (spawned): install / uninstall / update
- Registers new action log files for all spawn-based endpoints

All gated by the existing dashboard auth middleware.

* feat(dashboard): admin pages for MCP, pairing, webhooks, and system ops

Adds four new dashboard pages + nav entries so a remote admin can manage
Hermes without CLI access:
- MCP: list/add/remove/test MCP servers
- Webhooks: list/create/delete subscriptions (one-time secret reveal)
- Pairing: approve/revoke/clear messaging pairing codes
- System: gateway start/stop/restart, memory provider + reset, credential
  pool add/remove, ops (doctor/audit/backup/import/skills update) with a
  live action-log viewer, checkpoints prune, shell-hooks status

api.ts: client methods + types for all new endpoints.
App.tsx: routes + sidebar nav (plain labels, no i18n key required).

Verified: tsc -b clean, production build succeeds, new pages lint clean,
zero new eslint errors in App.tsx.

* test(dashboard): cover admin API endpoints

20 tests across MCP, credential pool, memory, pairing, webhooks, ops, plus
an auth-gate parametrize that asserts every admin endpoint requires the
session token. Asserts request contract + CLI-config parity, not catalog
values (per the no-change-detector-tests rule).

* docs(dashboard): document MCP, Webhooks, Pairing, and System admin pages

Adds Pages sections for the four new admin tabs and an Admin-endpoints table
to the REST API reference. Updates the page description to reflect the
dashboard's expanded role as a full administration panel.
2026-06-01 02:58:02 -07:00
Teknium 2ed96372ad feat(skills): blank-slate skills — install --no-skills + opt-out/opt-in (#36228)
* feat(install): --no-skills flag for blank-slate default profile

Add an install-time --no-skills flag so the default ~/.hermes profile can
be created with zero bundled skills, matching what
`hermes profile create --no-skills` already does for named profiles.

The flag writes $HERMES_HOME/.no-bundled-skills and skips the install-time
seed. sync_skills() now honors that marker with an early return
(skipped_opt_out=True), so neither the installer, a later `hermes update`,
nor a direct sync re-injects bundled skills into a profile that opted out.

Previously the marker was only checked by seed_profile_skills() (named
profiles); the default profile had no opt-out and `hermes update` would
re-seed it every time.

Tests: TestNoBundledSkillsOptOut covers marker-present (no-op) and
marker-absent (normal seed) paths.

* feat(skills): hermes skills opt-out / opt-in for existing profiles

Adds an interactive counterpart to the install-time --no-skills flag so
an already-installed profile (default or named) can toggle the
.no-bundled-skills marker without reinstalling.

- `hermes skills opt-out` writes the marker (stop future seeding). Safe
  by default: nothing on disk is touched.
- `hermes skills opt-out --remove` ALSO deletes already-present bundled
  skills, but ONLY ones that are manifest-tracked AND byte-identical to
  their origin hash. User-edited bundled skills, hub-installed skills, and
  hand-written skills are never removed. Previews + confirms before
  deleting (--yes to skip).
- `hermes skills opt-in [--sync]` removes the marker and optionally
  re-seeds immediately.

Core logic lives in tools/skills_sync.py (set_bundled_skills_opt_out,
is_bundled_skills_opt_out, remove_pristine_bundled_skills) reusing the
existing manifest origin-hash machinery for the safety check.

Tests: TestOptOutToggleAndRemove covers marker toggle idempotency and
proves user-modified + non-bundled skills survive --remove.

* docs: blank-slate skills — install --no-skills + opt-out/opt-in

- features/skills.md: new 'Starting with a blank slate' section covering
  the install flag, profile-create flag, and runtime opt-out/opt-in, with
  a safe-by-default note.
- reference/cli-commands.md: document the new skills opt-out / opt-in
  subcommands + examples.
- reference/profile-commands.md: fix the marker filename (was .no-skills,
  actually .no-bundled-skills) and cross-link the runtime commands.

Validated with a full docusaurus build (exit 0); the three edited pages
compile clean with no new warnings.
2026-06-01 02:57:57 -07:00
Teknium 70e1571d89 feat(curator): prune built-in skills after inactivity + track usage for all skills (#36701)
Two related changes to the skill curator:

1. Built-in pruning. New curator.prune_builtins config (default on) lets the
   curator archive bundled built-in skills after the inactivity period, not
   just agent-created ones. A .curator_suppressed list tells the update-time
   re-seeder (tools/skills_sync) to leave pruned built-ins archived, so the
   prune is durable across `hermes update`. Built-ins are seeded with a
   baseline record on first sight, so the inactivity clock starts at upgrade
   time -- no mass-prune on the first run. Hub-installed skills are never
   pruned regardless of the flag. Restoring a built-in clears its suppression.

2. Usage tracking for all skills. Telemetry (view/use/patch) was wrongly gated
   behind curation-eligibility, so built-ins were tracked only when prunable
   and hub skills never. Telemetry is observability and is now decoupled from
   curation: every skill accrues usage counts regardless of provenance, while
   lifecycle mutators (set_state/set_pinned/mark_agent_created) stay
   curation-gated. New usage_report() + provenance() expose all skills with an
   agent/bundled/hub tag.
2026-06-01 02:07:32 -07:00
Teknium 0622a70eb4 feat(gateway): bring /undo [N] to messaging platforms (parity with CLI/TUI) (#36699)
Gateway /undo was wired into every platform but still ran the old
single-turn hard-truncate. Now it matches the CLI/TUI: /undo [N] backs
up N user turns (default 1, clamps to oldest), soft-deletes the
truncated rows on disk (active=0, kept for audit, hidden from re-prompts
and search) via SessionDB.rewind_to_message, evicts the cached agent so
the next turn rebuilds from the active-only transcript (the gateway's
equivalent of the CLI's in-place history surgery + memory invalidation),
and echoes the backed-up message text so the user can copy/edit and
resend — platforms have no editable composer to prefill.

- gateway/session.py: SessionStore.rewind_session(session_id, n) wraps
  the soft-delete primitive; load_transcript already returns active-only
- gateway/run.py: _handle_undo_command parses [N], calls rewind_session,
  evicts the agent, echoes target text; confirm-prompt detail is count-aware
- locales: undo.removed gains {turns}; new undo.invalid_count, all 16 langs
- tests: tests/gateway/test_undo_rewind_session.py (6 cases)
2026-06-01 02:04:14 -07:00
Teknium ba6ffd4ff1 fix(skills-guard): stop flagging benign skill content + honor skill ignore files (#36231)
The skill security scanner blocked legitimate community skills on three
intrinsic false-positive patterns:

- read_secrets_file matched `cat > file.env <<` heredocs (writing the
  user's own keys into their own local .env), not just `cat file.env`
  reads. Exclude output redirections.
- allowed-tools frontmatter is REQUIRED by the agent-skill spec; every
  compliant skill declares it. Drop from HIGH privilege_escalation to a
  LOW informational finding so it no longer drives the verdict.
- python_os_environ flagged `os.environ.get("CONFIG_VAR")` config reads
  as HIGH exfiltration. Exempt non-secret `.get()` reads; add a dedicated
  CRITICAL python_environ_get_secret pattern so secret-named reads
  (OPENAI_API_KEY etc.) are still caught.

Also: scan_skill() now honors a skill-provided .skillignore / .clawhubignore
(gitignore-style) so dev/docs artifacts shipped in a skill root are excluded
from both structural checks and pattern scanning. SKILL.md is never ignorable.

80 tests pass (64 existing + 16 new).
2026-06-01 01:58:48 -07:00
Teknium 9074a154c5 feat: explain Quick Setup vs Full setup inline in the first-time setup menu (#36227)
The setup-mode chooser showed two bare labels ('Quick Setup (Nous
Portal) — OAuth login, model & messaging' / 'Full setup — configure
everything') that didn't explain what Quick Setup actually is. Expand
both labels inline so each choice line carries a concise explanation:

  Quick Setup (Nous Portal) — free OAuth login, no API keys, model + tools
  Full setup — configure every provider, tool & option yourself (bring your own keys)

Single-file change to the choice labels; no new plumbing.
2026-06-01 01:58:30 -07:00
Teknium 92a567db2d fix(ci): regen model catalog + stop gui tests consuming macos-fixup subprocess calls (#36687)
Two pre-existing failures on main, unrelated to each other:

- test_model_catalog: website/static/api/model-catalog.json was stale vs
  _PROVIDER_MODELS — minimax/minimax-m2.7 was renamed to minimax/minimax-m3
  without regenerating the committed manifest. Ran scripts/build_model_catalog.py.

- test_gui_command: the macOS relaunchable-signing fixup
  (_desktop_macos_relaunchable_fixup) makes two subprocess.run calls (xattr +
  codesign) on darwin before launch. The two darwin GUI tests set
  sys.platform='darwin' and mock subprocess.run with a 2-element side_effect
  (pack + launch), so the fixup's calls drained the iterator -> StopIteration.
  Mock out the fixup in those two tests so the subprocess accounting stays
  focused on pack/launch.
2026-06-01 01:39:03 -07:00
Teknium e1951ce704 fix(memory): only forward rewound kwarg when set
The on_session_switch fan-out passed rewound=rewound unconditionally,
injecting rewound=False into every provider's **kwargs on the common
/resume, /branch, /new, and compression paths. Providers that capture
extra kwargs into an 'extra' dict (and the exact-dict-equality tests
guarding them) broke. Forward rewound only when truthy; /undo sets it
explicitly, everyone else stays clean.
2026-06-01 01:22:38 -07:00
TekniumandSaguaroDev 3f7d1c801d feat(undo): /undo [N] backs up N user turns with prefill + soft-delete
Extends the existing /undo command from a single in-memory exchange
removal into a full rewind: back up N user turns (default 1), soft-delete
the truncated rows in SessionDB (active=0, kept for audit, hidden from
re-prompts and search), notify memory providers, and prefill the composer
with the backed-up message text for editing — CLI and TUI.

Reuses the SessionDB rewind primitives, the on_session_switch(rewound=True)
memory hook, and the TUI command.dispatch prefill payload from SaguaroDev's
#21910 work, wired to /undo [N] instead of a separate /rewind picker.

- cli.py: undo_last(n, prefill) — in-memory truncate + SQLite soft-delete
  + agent surgery (system-prompt invalidate, flush-index reset) + memory
  notify + editable buffer prefill; /undo dispatch parses optional count;
  checkpoint-rollback caller passes prefill=False
- tui_gateway/server.py: command.dispatch undo branch (was rewind) parses
  count, picks Nth-from-last user turn, clamps to oldest
- commands.py: /undo gains [N] args_hint
- tests: rename + expand TUI suite (multi-turn, clamp, invalid-count)
- release.py: AUTHOR_MAP entry for SaguaroDev

Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com>
2026-06-01 01:22:38 -07:00
SaguaroDev 243e836dce feat(tui): wire /rewind through command.dispatch + prefill payload (#21910)
Adds the TUI half of the /rewind feature so the Ink terminal UI gets
the same affordance as the prompt_toolkit CLI.

Python side (tui_gateway/server.py):
- /rewind added to _PENDING_INPUT_COMMANDS so slash.exec rejects it
  and the TUI falls through to command.dispatch (the only path with
  access to live session state + memory hooks).
- New command.dispatch branch for name == "rewind":
  v1 auto-picks the most recent user turn (Claude-Code-style single-
  step undo), calls SessionDB.rewind_to_message, refreshes the
  in-memory history, fires _memory_manager.on_session_switch with
  rewound=True, and returns the new "prefill" payload.
- A dedicated picker overlay (multi-step rewind) is tracked as a
  follow-up to #21910.

TS side (ui-tui/src/):
- New "prefill" variant on CommandDispatchResponse + asCommandDispatch
  validator. Mirrors "send" but does NOT auto-submit; the client drops
  the message into the composer for editing.
- createSlashHandler renders the optional notice via sys() and calls
  ctx.composer.setInput(d.message), letting the user edit-and-resubmit
  the rewound turn — the core UX promised by the issue.

Tests:
- 7 new tui_gateway tests covering prefill payload shape, in-memory
  history truncation, DB soft-delete, memory-provider notification
  (rewound=True), busy-session refusal, missing-session error, and
  registry placement in _PENDING_INPUT_COMMANDS.
- Extended asCommandDispatch vitest covering the new prefill variant
  (with + without notice, and rejection of malformed payloads).

Out of scope for v1 (tracked as #21910 follow-up):
- Dedicated picker overlay in Ink (the multi-step rewind UI). v1 auto-
  picks the most recent user turn, matching the most common case.
- Gateway platforms (Telegram, Discord, etc.) — issue scopes v1 to
  CLI + TUI only.
2026-06-01 01:22:38 -07:00
SaguaroDev 31cfa08c66 feat(memory): add rewound kwarg to on_session_switch hook 2026-06-01 01:22:38 -07:00
SaguaroDev 3e59be0c41 feat(state): add messages.active flag + rewind primitives (#21910)
Schema v12 adds:
- messages.active (default 1) — soft-delete flag for /rewind
- sessions.rewind_count (default 0) — audit counter
- idx_messages_session_active deferred index

New SessionDB methods:
- rewind_to_message(session_id, target_message_id) — soft-deletes rows
  >= target_id, refuses non-user targets, increments rewind_count
- restore_rewound(session_id, since_message_id) — undo for stretch goal
- list_recent_user_messages — picker source

Existing methods get include_inactive kwarg (default False):
- get_messages, get_messages_as_conversation, search_messages.
  Rewound rows excluded from session_search by default — opt-in for audit.

The deferred index pattern (DEFERRED_INDEX_SQL run after _reconcile_columns)
avoids 'no such column: active' on legacy pre-v12 databases, since
executescript(SCHEMA_SQL) runs before column reconciliation.
2026-06-01 01:22:38 -07:00
kshitijk4poor 6c73e8ffaa fix(gateway): keep code blocks verbatim in cleaned text when media present
Self-review of the code-block masking fix: the cleanup path ran
media_pattern.sub('') over the _mask_protected_spans() copy of the text and
assigned that back to 'cleaned', so whenever a real MEDIA: tag was delivered
(if media: branch), every fenced code block / inline code / blockquote in the
reply was blanked to whitespace in the user-visible text.

Now mask only a length-equal copy of 'cleaned' to locate the real tag spans,
then delete those spans from the unmasked 'cleaned' — masking is a locator,
not a text rewrite. Protected spans survive verbatim. Strengthens the existing
mixed-code test (it only asserted 'Done.' survived, not the code block) and
adds an inline-code-survives regression test. Both fail on the old sub-based
code and pass now.
2026-06-01 00:00:26 -07:00
kshitijk4poor ec6261ae2f chore(release): add VinciZhu to AUTHOR_MAP for #16721 salvage 2026-06-01 00:00:26 -07:00
liuhao1024 3ccf4fdc6d fix(gateway): skip MEDIA: tags inside code blocks and blockquotes
extract_media() scanned the full response text without distinguishing
live delivery tags from example paths in fenced code blocks, inline code
spans, and blockquotes. This caused false positives where the agent's
explanation of MEDIA: syntax (or tool output containing example paths)
was stripped from user-visible text and the path was added to the media
delivery list.

Added _mask_protected_spans() helper that replaces protected regions
with equal-length whitespace before regex matching, preserving match
offsets. The helper skips backtick-quoted paths in MEDIA: tags to
maintain existing path extraction behavior.

Fixes #35695
2026-06-01 00:00:26 -07:00
VinciZhu 521d06975e fix(gateway): restrict auto-appended media to producer tools 2026-06-01 00:00:26 -07:00
kshitijk4poor fb1b681b3b fix(gateway): keep JSON-embedded MEDIA: text verbatim in cleaned output
Self-review of #34375 fix: the cleanup path ran media_pattern.sub('') over
the JSON-masked copy of the text, which baked the masking spaces into the
user-visible 'cleaned' string — a serialized tool result like
{"old":"MEDIA:/x.png"} came back as {"old":"          "}.

Now mask only a length-equal copy of 'cleaned' to locate the real tag spans,
then delete those spans from the unmasked 'cleaned'. Real tags are stripped;
JSON-embedded MEDIA: text reads back verbatim. Masking 'cleaned' (not the
original 'content') keeps offsets valid after the [[audio_as_voice]] /
[[as_document]] directives are removed. Adds two cleaned-text regression tests.
2026-05-31 23:51:42 -07:00
liuhao1024andkshitijk4poor e8827ef704 fix(gateway): skip MEDIA: inside serialized JSON string values
Serialized tool results frequently embed a prior reply's text, e.g.
{"result": "MEDIA:/path/stale.png"}. The bare-path branch of
MEDIA_TAG_CLEANUP_RE matched these and re-delivered stale files (#34375).

Adds BasePlatformAdapter._mask_json_string_media, which blanks (offset-
preserving) only MEDIA:<bare-path> tokens that sit inside a JSON value-
context string (opened by : , { or [). Legitimate tags at line start,
after prose, indented, MEDIA:"quoted" form, and two-line TTS output are
all left untouched.

Reworked from the approach in #34388 (a line-start regex anchor), which
no longer applied to current main and regressed same-line/indented tags.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-05-31 23:51:42 -07:00
NicolayandNic b3aaf2676b fix(docker): discover Playwright headless_shell browser (#35717)
Co-authored-by: Nic <nicsequenzy@gmail.com>
2026-06-01 16:06:44 +10:00
Ben Barclay e3998d4714 chore(attribution): map polnikale for PR #35717 (#36273)
Adds nicsequenzy@gmail.com -> polnikale to AUTHOR_MAP so the
check-attribution gate passes for the Playwright headless_shell browser
discovery fix (#35717).
2026-06-01 16:05:06 +10:00
Amin Vakil f106e58afa fix(docker): create s6 envdir before browser path export (#34601) 2026-06-01 15:44:30 +10:00
c1a531d063 fix(dashboard): guard update endpoint in Docker with structured guidance (salvage #34831) (#36263)
* fix: guard dashboard update in Docker

* fix(dashboard): align action response type

---------

Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com>
Co-authored-by: Donovan Yohan <34756395+donovan-yohan@users.noreply.github.com>
2026-06-01 15:39:35 +10:00
brooklyn! 359f2be12e feat(desktop): drop files anywhere in the chat area (#36262)
* feat(desktop): drop files anywhere in the chat area

File drops were only wired to the composer input. Add a reusable
useFileDropZone hook (enter/leave depth counting + capture-phase reset so
the affordance clears even when the composer claims the drop) and a
pointer-events-none ChatDropOverlay, wired onto the conversation viewport.
Drops funnel through the existing onAttachDroppedItems; composer drops keep
their own inline-ref behavior.

* fix(desktop): chat-area drops insert inline @file refs, not attachment cards

Match the composer-input drop behavior — funnel dropped paths through
droppedFileInlineRef + the composer insert bus so they render as inline
ref chips instead of attachment cards.

* fix(desktop): don't render bare file paths as tool images (404)

vision_analyze reports its input image as a local filesystem path, which
toolImageUrl handed straight to <img src>. In the renderer that resolves
against the dev-server origin and 404s. Restrict inline tool images to
fetchable sources (data: URLs and remote http(s)); bare paths now fall
back to the tool's codicon.
2026-06-01 00:30:39 -05:00
Ben Barclay e1eba6f8cc fix(dashboard-auth): drop /api/* paths from OAuth next= round trip (#36244)
When an unauthenticated SPA fetch hit a gated /api/* endpoint (e.g.
GET /api/analytics/models?days=30 fired from ModelsPage on mount or
after a session expiry), the gated middleware stamped the request's
own path into next= on the 401 envelope's login_url. The SPA's global
401 handler in web/src/lib/api.ts full-page-navigated to that URL,
the PKCE cookie carried the encoded /api/* value through the OAuth
round trip to Portal, and /auth/callback's _validate_post_login_target
accepted it as same-origin and redirected the user to the raw JSON
endpoint instead of the dashboard.

Symptom Ben reported: after the OAuth screen he kept landing on
$DOMAIN/api/analytics/models?days=30 (raw JSON) rather than /models.
The bug was deterministic per page — whichever /api/* call ModelsPage,
AnalyticsPage, or SessionsPage fired first owned the redirect race.

Fix: both validators now reject /api/* targets in addition to the
existing /login, /auth/, /api/auth/ exclusions:

  - _safe_next_target in middleware.py drops the value before it ever
    enters login_url, so the SPA's 401 handler navigates to a bare
    /login (which the SPA itself can return-from via its own
    sessionStorage["hermes.lastLocation"] fallback that was already
    saving the actual browser location).
  - _validate_post_login_target in routes.py drops it as second-line
    defence at the callback boundary, so a legacy cookie, a regressed
    middleware, or an attacker-crafted /auth/login?next=/api/... value
    can't smuggle the redirect through. Either layer alone is enough;
    pairing them means a regression in one is caught by the other.

The match is anchored: ``decoded == "/api"`` or
``decoded.startswith("/api/")``. SPA route lookalikes like /apidocs
or /api-keys remain valid landing targets — tests pin that.

Test additions in test_dashboard_auth_401_reauth.py:

  - TestApi401Envelope: rewrote test_login_url_carries_next_for_deep_
    api_path (which asserted the pre-fix behaviour) as
    test_login_url_drops_next_for_deep_api_path, plus added the
    specific analytics-models repro case from Ben's report.
  - TestNextSameOriginValidation: rejects-api-paths + does-not-reject-
    api-prefix-lookalikes (covers /apidocs, /api-keys).
  - TestAuthCallbackNext: end-to-end test_callback_with_api_next_
    lands_at_root drives /auth/login?next=/api/... through to the
    callback and asserts the user lands at "/", not the API URL.
  - TestValidatePostLoginTarget: new class covering the callback-side
    validator directly, including the URL-encoded ``%2Fapi%2F...``
    form the PKCE cookie actually carries.

Mutation-tested: reverting both validators causes exactly the 5 new
or rewritten /api/*-related assertions to fail (each fix layer is
independently tested), while the 31 other assertions in the file
remain green. Full tests/hermes_cli/ suite (288 files, 5,938 tests)
passes with the fix applied.
2026-06-01 15:10:20 +10:00
brooklyn! 7fbe9b79ab fix(desktop): add missing PATCH /api/sessions/{id} so rename works (#36249)
The desktop rename dialog sent PATCH /api/sessions/{id}, but the backend
only defined GET and DELETE for that path — FastAPI returned 405 Method
Not Allowed, surfaced to the user as "Rename failed". Add the PATCH route
backed by SessionDB.set_session_title (handles sanitization, uniqueness,
and clearing the title when empty).

Also fix a misleading notification: any 405 was summarized as an unrelated
"does not support that audio endpoint" message. Make it a generic 405 hint.
2026-06-01 00:01:28 -05:00
Ben Barclayandx1am1 bdceedf784 fix(docker): chown hermes-owned top-level state files on boot (#35098) (#36236)
The targeted data-volume chown in stage2-hook.sh only covers hermes-owned
*subdirectories*; loose state files living directly under $HERMES_HOME
(auth.json, state.db, gateway.lock, gateway_state.json, …) are missed.
When created or rewritten by `docker exec <container> hermes …` (root
unless `-u` is passed) they land root-owned, and the unprivileged hermes
runtime then hits PermissionError on next startup, producing a gateway
restart loop.

Fix: reset ownership of an explicit allowlist of hermes-owned top-level
files on every boot. The list mirrors the top-level file entries of
hermes_cli.profile_distribution.USER_OWNED_EXCLUDE plus the runtime lock
files.

This uses a targeted allowlist rather than the originally-proposed blanket
`find $HERMES_HOME -maxdepth 1 -user root` sweep, preserving the
targeted-ownership contract from #19788 / PR #19795: a bind-mounted
$HERMES_HOME may contain host-owned files Hermes does not manage, and
those must never be chowned. Verified end-to-end: allowlisted root-owned
files are reset to hermes on restart while a non-allowlisted host file
keeps its root ownership.

Co-authored-by: x1am1 <2663402852@qq.com>
2026-06-01 14:38:08 +10:00
brooklyn! 0bc616ecf9 fix(desktop): darken light-mode code comment color for legibility (#36234)
Shiki's github-light-default colors comments #6e7781 (~4.2:1 on the code
card background), which is borderline unreadable at the 11px code font
size — and worst for shell snippets, where a single `#` turns the rest
of the line into one long comment span. Remap light-mode comments to
GitHub's darker muted gray (#57606a, ~6.4:1) via per-theme
colorReplacements. Dark mode (~6.1:1) reads fine and is left untouched.
2026-05-31 23:21:58 -05:00
helix4u b14e15c48e fix(gateway): clean service restart notifications 2026-05-31 21:05:53 -07:00
Nacho Avecilla 380ce4789b Remove prviliges drop when you never ran as root (#34837) 2026-06-01 13:54:18 +10:00
Bartok 064875a540 fix(docker): support s6 /init images in terminal sandbox (#34628) (#34635)
s6-overlay images (e.g. hermes-agent:latest) use /init as PID 1 and exec
/run/s6/basedir/bin/init during stage0 startup. The Docker terminal backend
unconditionally added Docker --init and mounted /run as noexec, which broke
those images in two ways: --init created a second competing PID-1 init, and
the noexec /run made s6 stage0 fail with "exec: /run/s6/basedir/bin/init:
Permission denied" (exit 126), so the container died and terminal commands
reported a generic "container is not running" error.

Detect images whose entrypoint is /init via 'docker image inspect' and, for
those images only, skip Docker --init and mount /run with exec. All other
images keep the hardened --init + noexec defaults. Detection is best-effort:
any inspect failure falls back to the safe defaults.
2026-06-01 13:46:04 +10:00
BartokandCursor a60bff282e fix(docker): add /usr/bin/tini compatibility shim for legacy wrappers (#34192) (#34382)
#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup
with:

  /usr/bin/tini: No such file or directory

The image moved from tini to s6-overlay as PID 1 (/init) earlier in
2026. Orchestration templates that still pin /usr/bin/tini as the
entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no
binary to exec and the container crashes immediately.

Hermes has no control over the Hostinger catalog template, but we can
make the image backward-compatible by symlinking /usr/bin/tini -> /init
during the s6-overlay install step. External wrappers that exec
/usr/bin/tini will land on the same s6-overlay reaper they would have
landed on if they'd used the canonical /init entrypoint.

The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim
is purely for legacy external wrappers, not for the image's own
runtime path. Once affected catalogs are updated, the symlink can be
removed.

Other issues #34192 raises that are NOT addressed by this PR:

  * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by #33148
    (S6_KEEP_ENV=1) and #32412 (with-contenv shebangs). The Hostinger
    template likely needs to update its env-var propagation.

  * Problem #3 (incompatible session formats): RFC for pluggable
    SessionDB is tracked in #23717.

  * Problem #4 (Telegram polling conflict): an operations problem on
    Hostinger's side, not in this codebase.

This PR is scoped to the one issue that can be fixed inside
Dockerfile: the missing /usr/bin/tini binary.

Tests (3 in test_dockerfile_tini_compat_shim.py):

  - test_tini_compat_symlink_present
    Guard: the symlink line must exist in Dockerfile.
  - test_tini_compat_comment_explains_why
    The #34192 anchor comment must be present so future readers know
    why the shim is there (avoid accidental removal).
  - test_entrypoint_still_init_not_tini
    Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is
    only for external wrappers.

Refs: #34192
Partial fix: addresses the immediate tini-binary crash. Catalog-side
fixes still needed by Hostinger for the UID and session-format
problems documented in the issue.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:32:55 +10:00
BartokandCursor 740fb28d02 fix(config): chown ensure_hermes_home dirs to HERMES_UID/GID in Docker (#34107) (#34268)
Fixes #34107. When Hermes runs in Docker with HERMES_UID=1000 /
HERMES_GID=911, the entrypoint chowns the top-level HERMES_HOME once
at startup — but subdirectories created at runtime by
ensure_hermes_home() (especially for profile namespaces under
profiles/<name>/ spawned by kanban workers) were landing as root:root
and blocking subsequent uid-mapped worker invocations with:

  PermissionError: [Errno 13] Permission denied:
    '/opt/data/profiles/charles/logs/curator'

Fix: add _resolve_hermes_uid_gid + _chown_to_hermes_uid helpers that
read the env vars and apply chown after mkdir. Invoke from _secure_dir
which already runs after every directory creation in the home-init path,
so all newly-created subdirs (including the profile namespaces) get the
right ownership.

Safety properties:

- No-op when HERMES_UID/HERMES_GID unset (the dominant non-Docker path)
- No-op on Windows (os.chown doesn't exist; AttributeError swallowed)
- No-op when running as non-root (EPERM swallowed — the entrypoint's
  startup chown -R picks it up on next restart, and in most cases the
  dir was already correctly-owned by the calling user)
- Uses -1 sentinel for missing field so only the set value applies
- Empty-string env vars treated as unset

Adds 14 tests across:
- TestResolveHermesUidGid (7) — env-var parsing
- TestChownToHermesUid (5) — chown helper invariants
- TestSecureDirChown (2) — end-to-end through _secure_dir

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:27:30 +10:00
Teknium e3b3d4d75e feat(models): add MiniMax-M3 to native minimax providers + 1M context (#36214)
Add MiniMax-M3 to the minimax, minimax-oauth, and minimax-cn curated
lists (these are hardcoded — the native Anthropic-format endpoint has no
/v1/models listing and the providers aren't in _MODELS_DEV_PREFERRED, so
new models don't auto-pull). Add a DEFAULT_CONTEXT_LENGTHS key
'minimax-m3' -> 1,000,000 so M3 resolves to its 1M context on every
surface (native ID + OpenRouter/Nous slug) via longest-key-first
substring match, while the M2.x series stays at 204,800.
2026-05-31 20:18:05 -07:00
brooklyn! 79f7e7a1e9 fix(desktop): make locally-built macOS app relaunchable after in-place self-update (#36198)
On macOS the desktop app is built locally and ad-hoc signed (no Developer ID
on the user's machine). An ad-hoc bundle has no stable Designated Requirement,
so when the self-updater rebuilds it in place with a fresh build (new cdhash)
— plus the com.apple.quarantine flag inherited from the downloaded installer
process chain — Gatekeeper/LaunchServices treats the changed code as tampering
and macOS reports "Hermes is damaged and can't be opened," and the app fails to
relaunch. First launch works (fresh registration); the in-place update relaunch
is what breaks.

Fix: after building the desktop app locally, strip quarantine xattrs and
re-apply a clean deep ad-hoc signature (omitting the hardened-runtime flag,
which an ad-hoc build can't satisfy). Applied in both build entry points:
- hermes_cli/main.py cmd_gui (the `hermes desktop --build-only` path the
  updater drives) — so the fix ships via `hermes update` (git), no installer
  re-download needed.
- scripts/install.sh install_desktop (first install) for parity.

Both are no-ops on non-macOS and when a real signing identity (CSC_LINK /
APPLE_SIGNING_IDENTITY) is configured, so signed/notarized builds are untouched.
2026-05-31 21:27:23 -05:00
Teknium a8526a4159 chore(models): bump minimax to minimax-m3 in openrouter + nous lists (#36191)
Replace minimax/minimax-m2.7 with minimax/minimax-m3 in the OpenRouter
fallback snapshot and the Nous portal model list.
2026-05-31 19:24:17 -07:00
Simon Taggart a75a45414c fix(tools): fall back to .hermes/.env when forwarded secret is empty (#35583)
The docker_forward_env build loop only consulted the ~/.hermes/.env disk
fallback when a key was unset (value is None), not when it was present
but empty (""). A transient empty value in os.environ was therefore
forwarded into the sandbox container as `-e KEY=`, clobbering the correct
value on disk. Sandboxed workloads then read a zero-length secret and
failed auth (observed as intermittent Linear API 401s) with no gateway
restart and no .env rewrite.

Treat empty-string like unset (`if not value:` on the fallback) and never
forward a blank secret (`if value:` on the guard).

Fixes #35580
2026-06-01 12:20:00 +10:00
Ben Barclay e2ee9177f0 chore(attribution): map SiTaggart for PR #35583 (#36189)
Adds me@simontaggart.com → SiTaggart to AUTHOR_MAP so the
check-attribution gate passes for the docker_forward_env empty-secret
fix (#35583, fixes #35580).
2026-06-01 12:16:54 +10:00
ethernet 9a82cd33d8 Merge pull request #36190 from NousResearch/ethie/sign-win
add a github action to build& sign a windows installer
2026-05-31 22:10:45 -04:00
ethernet 4e530f1a27 add a github action to build& sign a windows installer 2026-05-31 22:09:44 -04:00
Foldblade 1031031dec fix(docker): skip unnecessary boot chown when volume ownership already matches remapped UID (#35027) 2026-06-01 11:59:43 +10:00
Tekniumandsprmn24 758454d1e4 fix(docker): validate HERMES_UID/GID to prevent privilege escalation in stage2-hook (#35340)
Co-authored-by: sprmn24 <oncuevtv@gmail.com>
2026-06-01 11:46:53 +10:00
Donovan YohanandDonovan Yohan dcbf62e26a fix(docker): seed s6 gateway state for legacy run cmd (#34829)
* fix(docker): seed s6 gateway state for legacy run cmd

* fix(docker): honor no-supervise during legacy gateway migration

---------

Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com>
2026-06-01 11:28:56 +10:00
Siddharth Balyan e1c7a9aa7b feat(tools): surface the free tool pool in entitlement + setup (#36153)
Read the Portal's tool_access claim (JWT + /api/oauth/account) into NousToolAccessInfo and gate managed Tool Gateway access on it: tool_gateway_entitled (paid OR live pool) and per-category tool_gateway_entitled_for(). The pool funds web/image/tts/browser but not video, so per-backend availability, the charge picker (ensure_nous_portal_access coverage_category), and managed defaults all respect coverage.

Setup: rebuild prompt_enable_tool_gateway as a per-tool checklist that renders whenever the pool is enabled, lists only pool-covered tools (video excluded for free-pool users), and is framed as the free tool pool for $0 subscribers rather than a paid subscription. get_gateway_eligible_tools now gates and filters off the entitlement snapshot.
2026-06-01 06:32:48 +05:30
brooklyn! fa4ebaa8b5 fix(install): build desktop in 'desktop' stage on macOS/Linux instead of silently skipping (#36134)
The thin installer (apps/bootstrap-installer) drives install.sh stage-by-stage,
each in its own process. The `desktop` stage never called check_node, so the
Hermes-managed Node provisioned earlier (at $HERMES_HOME/node/bin) wasn't on
PATH. install_desktop's `command -v npm` check then failed and the build was
skipped — yet the stage still reported {"ok":true,"skipped":false}, so the
installer showed "Installation Complete" and only failed at the end with
"Couldn't find a built Hermes desktop ... the desktop build step may have been
skipped or failed."

Fix:
- Call check_node in the `desktop` stage (mirrors every other Node-dependent
  stage) so the managed Node is on PATH (or installed).
- Make install_desktop self-provision via check_node and hard-fail (return 1)
  if npm is still unavailable, instead of a silent `return 0`. The desktop
  stage only runs when a build is explicitly requested (--include-desktop), so
  an unavailable toolchain is a real failure, not graceful degradation.

Verified on macOS arm64: the `desktop` stage now builds
release/mac-arm64/Hermes.app, which matches resolve_hermes_desktop_exe, so the
installer's "Launch Hermes" succeeds.
2026-05-31 19:03:10 -05:00
brooklyn! 77bb64813c fix(desktop): report desktop_contract in lazy session.create info (#36112)
The lazy session.create path hand-builds a partial info dict that omitted
desktop_contract. The desktop GUI reads a missing contract as undefined and
treats it as an out-of-date backend, so it surfaced a "Backend out of date"
toast on every launch even against a current backend. Carry the contract in
the lazy payload like _session_info already does for resume/branch.
2026-05-31 18:23:10 -05:00
brooklyn! 3ef97a61b9 fix(desktop): track main for self-update now that GUI merged (#36104)
The desktop self-update branch defaulted to bb/gui, the pre-merge feature
branch. Now that the desktop app is on main, flip DEFAULT_UPDATE_BRANCH to
main so freshly built apps check for updates against the right branch
instead of relying on the runtime self-heal fallback.
2026-05-31 17:53:35 -05:00
Teknium cd8aa389c9 Revert "fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657)" (#36096)
This reverts commit b1d34cf6e2.
2026-05-31 15:51:11 -07:00
brooklyn!emozillaCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Austin PickettCursorethernetcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
51c68d4ab1 Add Hermes desktop app (#20059)
* feat: better composer etc

* docs: add desktop and dashboard run instructions

* fix(desktop): address security scan findings

* fix(dashboard): resolve @nous-research/ui path under npm workspaces

The sync-assets prebuild step shelled out to 'cp -r
node_modules/@nous-research/ui/dist/fonts ...' with a path relative
to apps/dashboard/. That works only when the dep is installed
locally in the dashboard workspace, but 'npm install' at the repo
root (the documented setup — see apps/desktop/README.md) hoists
shared deps to the root node_modules under npm workspaces. The
relative cp then fails with 'No such file or directory', sync-assets
exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a
generic 'Web UI build failed' message.

Replace the shell one-liner with scripts/sync-assets.cjs, which
walks up from the dashboard directory looking for node_modules/
@nous-research/ui — working in both the hoisted (workspaces) and
co-located (standalone) layouts. Also guards against a missing
dist/fonts or dist/assets with a clearer error pointing at a
rebuild of the UI package rather than silently copying nothing.

* feat(desktop): support connecting to a remote Hermes backend

Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env
vars that, when set, short-circuit the local-child spawn in
startHermes() and connect the Electron renderer to an already-
running 'hermes dashboard' server reachable over the network.

Motivating use case: WSL2 users who want to run the Hermes core
(agent loop, tools, filesystem access) inside their WSL
distribution while rendering the Electron GUI on native Windows.
Before this change, the desktop app always spawned a local Python
child on the same host as the renderer, which doesn't cross the
WSL/Windows boundary.

The remote path reuses waitForHermes() as a liveness probe
(/api/status is in the backend's public endpoint allowlist), so
the connection is only returned once the backend is actually
ready. WebSocket URL derivation picks ws:// or wss:// based on
the input scheme. URL validation rejects non-http(s) schemes and
requires both env vars together to avoid a half-configured
connection that would silently fall through to the spawn path.

No behaviour change when the env vars are unset — the default
local-spawn flow is untouched.

Typical usage:

  # in WSL2
  hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure

  # on Windows
  set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119
  set HERMES_DESKTOP_REMOTE_TOKEN=<session token>
  set HERMES_DESKTOP_IGNORE_EXISTING=1
  (launch Hermes desktop)

* ci(desktop): automate desktop releases

Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths.

* feat: file tabs

* refactor(desktop): tighten right-rail tab close API

Promote closeRightRailTab/closeActiveRightRailTab as the single
public entry point. Drops the activeTabRef + handleCloseDocument
indirection in ChatPreviewRail, the unused $rightRailHasContent
atom, and the legacy dismissFilePreviewTarget alias. -70 LOC.

* feat(desktop): polish composer pill toward reference look

Solid foreground-on-background send/voice-conversation circle (black-on-white
in light, white-on-black in dark) anchors the right edge as the primary CTA
instead of the orange theme primary. Bumps the primary control to 2.125rem so
it visually outranks the ghost mic/plus controls. Opens up the surface padding
(0.625rem x / 0.5rem y) so the input row breathes around its controls, and
nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette.
LiquidGlass distortion is preserved.

* feat(desktop): add startup and onboarding flow

Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours.

* fix(desktop): gate prompts on provider setup

Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors.

* fix(desktop): surface provider onboarding from session warnings

Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors.

* fix(desktop): route gateway provider errors to onboarding

The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened.

Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell.

* fix(desktop): use strict runtime check to drive onboarding

setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding.

Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it.

* feat(desktop): OAuth-first onboarding using existing dashboard provider API

Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message.

* fix(desktop): polish onboarding provider list

Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron.

* refactor(desktop): split onboarding overlay into store + view

Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom.

* fix(desktop): external CLI providers + center mode tabs

External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge.

* fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state

Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action.

* refactor(desktop): tighten onboarding store + overlay

Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save.

In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows.

* fix(desktop): mount onboarding from frame 1 to kill the FOUT

Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount.

The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell.

* fix(desktop): top-align empty sessions placeholder

The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does.

* refactor(desktop): drop dead boot overlay

Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has).

* fix(desktop): hide pinned/recents sections until first session

A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged.

* feat(gui): route embedded TUI through dashboard gateway (#21979)

Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring.

* Add desktop remote gateway settings

Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables.

* feat(gui): first-class Messaging page + gateway menu redesign

- Add Messaging page to the desktop app with per-platform setup,
  status, and inline guidance. Catalog derives from gateway.config
  Platform enum + plugin registry, so every messaging adapter the CLI
  supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp,
  Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu,
  WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up
  without per-platform code.
- New REST endpoints: GET /api/messaging/platforms, PUT and POST
  /test on the same path. Secrets go through the existing .env
  pipeline; enable/disable writes config.yaml.
- Replace gateway statusbar dropdown with a richer panel: status row,
  icon-only restart + system-panel actions, recent activity (with
  timestamps trimmed in display, full text on hover), platform list.
- Auto-poll the messaging page every 6s (paused when hidden) so
  status updates without a manual check.
- Drop Settings / Command Center from the sidebar nav (still
  reachable via shortcuts and the titlebar cog).
- Flatten top corners on Messaging/Skills/Artifacts/Chat panes.
- Share new StatusDot component across messaging + gateway menu.
- Fix gateway/config.py so an explicit platforms.<name>.enabled=false
  in config.yaml is honored when env tokens are present.
- pb-9 on the chat content area for breathing room above the composer.

* Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* pin electron version

* hide application menu on non-mac systems

* interpret compactPreview for non-string vlaues as JSON or an empty string

* fix(desktop): keep composer contenteditable mounted across stacked toggle

The composer rendered {input} inside two different parent fragments
depending on `stacked`. When auto-expand flipped `stacked` (e.g. the
moment typed text wrapped past two lines), React reconciled the two
branches as different positions and unmounted/remounted the
contenteditable. The fresh mount started empty, so any in-flight
characters — most reliably reproduced by holding a key — were lost.

Replace the conditional with a single CSS Grid whose template-areas
swap on `stacked`. The three children (menu, input, controls) keep
stable identities across the toggle; only their grid placement
changes, which the browser handles without React tearing down the
editor.

* refactor(desktop): align install layout with install.ps1 / install.sh

Make the desktop app's runtime layout match what scripts/install.ps1 and
scripts/install.sh produce, so a desktop-only user and a CLI-only user end
up with the same files in the same places and can share one install.

Layout
- ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent  (was: process.resourcesPath/hermes-agent, read-only)
- VENV_ROOT          = HERMES_HOME/hermes-agent/venv  (was: userData/hermes-runtime)
- desktop.log        = HERMES_HOME/logs/desktop.log  (was: userData/desktop.log)
- HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere

The packaged .app/.exe still ships a read-only payload at
process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch
or after an installer-driven upgrade we sync factory -> active, then
provision the venv and run pip install -e . against the active root.

Key behaviors
- Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves
  to the same path resolveHermesHome() picked. Without this, Python falls
  back to ~/.hermes on every platform - fine on mac/linux, a split-state
  bug on Windows where our default is %LOCALAPPDATA%\hermes.
- Detect developer installs by .git presence at ACTIVE; never overwrite
  a user's checkout via factory sync.
- Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks
  pyproject hash + factory version + runtime schema version. depsFresh
  fast-paths when nothing changed.
- Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run
  their local edits, not whatever's under HERMES_HOME.
- Better error messages distinguish "no payload" from "no Python".
- Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes
  exists, so users with prior pip/manual installs aren't orphaned.

pyproject.toml
- Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and
  pywinpty (Windows) to main dependencies. The dashboard backend
  (hermes dashboard) needs them at runtime; the previous lazy-import
  fallback was a footgun for fresh installs.
- Empty the [pty] optional-extra; kept as a no-op back-compat alias for
  any existing pip install hermes-agent[pty] invocations.

Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the
desktop now installs whatever pyproject.toml says, single source of truth.

Files
- apps/desktop/electron/main.cjs:    runtime layout, HERMES_HOME pin,
                                      factory->active sync, marker v4
- apps/desktop/scripts/test-desktop.mjs:  track new venv location
- apps/desktop/README.md:            new Setup, Runtime Bootstrap, and
                                      Debugging sections
- pyproject.toml:                    fastapi/uvicorn/pty backends in main
                                      dependencies; [pty] extra emptied

Tested locally on Windows: npm run dev boots cleanly, sessions land at
the new location, type-check + lint + test:desktop:platforms all pass.
Verified end-to-end on a fresh Win11 VM via dist:win installer.

Known gaps (filed as follow-ups, not in this PR):
- Skills not seeded on packaged installs (sync_skills only runs in
  cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch.
- Git Bash not bundled or detected; agent's terminal tool errors out
  with a useful message but desktop bootstrapper should pre-flight it.
- install.ps1 / install.sh should be decomposed into composable phase
  libraries so the desktop bootstrapper can reuse them as a single
  source of truth across all install surfaces.

* feat(desktop): theme polish, prose chat typography, composer chrome

- DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose
- Composer liquid/radius utilities, thread font parity, tool/thinking cues
- File tree label scale, preview flex, thread retry loading + streaming tests

* feat(desktop): NSIS prereq detection page + auto-install via winget

The packaged Windows installer now detects Python 3.11+ and Git for Windows
at install time and offers to install missing prereqs via winget. Mirrors
the prereq logic scripts/install.ps1 already runs for CLI installs, so
desktop installer users get the same out-of-the-box experience as
install.ps1 users.

Why
- Hermes' terminal tool calls bash.exe directly (tools/environments/
  local.py); on Windows that's Git Bash from Git for Windows. Without it,
  the agent fails on the first terminal() call.
- Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper
  errors out at venv creation.
- Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python
  pre-installed but no Git, so the agent's first terminal call failed
  with "Git Bash isn't installed."
- install.ps1 has had Install-Git + Install-Uv functions for ages. The
  desktop installer was the asymmetric outlier.

How — NSIS prereq page
- New file: apps/desktop/installer/prereq-check.nsh (plugged into
  electron-builder via build.nsis.include)
- Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir
  hook (between the Directory page and InstFiles).
  - Group boxes for Python and Git, each showing detection status.
  - Pre-checked install checkboxes when winget is available.
  - Auto-skips silently if both prereqs are already installed.
  - Falls back to manual download URLs when winget itself is missing.
- Detection:
  - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python
    launcher. Microsoft Store "Python stub" (no py.exe) is correctly
    classified as not-installed.
  - Git: `where git`.
  - winget: `where winget` (Win10 1809+ / Win11 with App Installer).
- Install execution (in customInstall macro):
  - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user
    install, no UAC prompt, output streams to install log.
  - Git: ExecShellWait via Windows ShellExecute. Critical because Git
    always installs per-machine and triggers UAC; ShellExecute preserves
    the foreground focus chain across non-elevated → elevated process
    spawns, so UAC actually comes to the foreground. nsExec::ExecToLog
    breaks the chain because winget runs hidden.
  - Both pass `--disable-interactivity --accept-package-agreements
    --accept-source-agreements` to suppress winget's own dialogs.
- Verification: probes Git's standard install locations via FileExists
  rather than `where git`. NSIS's process inherits PATH at startup, so
  a freshly-installed Git won't be visible to `where` until restart.
- Silent installs (/S) skip the prompts; managed deploys handle prereqs
  out-of-band via Group Policy / Intune.

How — Electron-side safety net
- New findGitBash() in main.cjs, parallel to findSystemPython(). Probes
  the same locations as tools/environments/local.py:_find_bash() so a
  positive result here means the agent's terminal tool will work.
- ensureRuntime now throws a clear, actionable error on Windows when Git
  Bash isn't found, matching the existing "Python 3.11+ is required"
  error path.
- Catches users the NSIS page doesn't: .msi installer users (NSIS prereq
  page doesn't run for MSI), `npm run dev` users, manual installers,
  anyone who unchecked the install boxes on the NSIS prereq page.
- All gated on `IS_WINDOWS`; macOS / Linux unaffected.

NSIS build issue (resolved)
- electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer
  emits "warning 6010: function not referenced" for our page functions
  because Page custom directives don't count as references in its
  static-analysis pass. The functions ARE called at runtime when NSIS
  invokes the page; the optimizer just can't see it statically.
- Set `build.nsis.warningsAsErrors=false` in package.json so this
  spurious warning doesn't fail the build. (Documented option from
  electron-builder's nsisOptions.)

Out of scope (filed for future work)
- MSI prereq detection: Windows Installer custom actions are a different
  mechanism. Enterprise deploys typically handle prereqs via GP/Intune.
- Bundle PortableGit + python-build-standalone in extraResources for
  zero-network installs. ~80MB increase.
- Mac / Linux GUI prereq flows (different installer formats; Xcode CLT
  covers most macOS prereqs already; Linux is per-distro hard).

Files
- apps/desktop/installer/prereq-check.nsh   (new, ~290 lines NSIS)
- apps/desktop/package.json                 (build.nsis.include +
                                              warningsAsErrors)
- apps/desktop/electron/main.cjs            (findGitBash + preflight)
- apps/desktop/README.md                    (Runtime prerequisites
                                              section)

Cross-platform impact
- macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis
  config is ignored entirely; .nsh is dormant.
- npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS.
- scripts/install.ps1, scripts/install.sh: no reference to any new
  files; CLI install paths untouched.
- Hermes CLI / dashboard / gateway: no reference; runtime untouched.
- All checks: node --check on main.cjs and test-desktop.mjs pass;
  npm run test:desktop:platforms 4/4 passing; node --test green.

Tested
- npm run dist:win produces signed .exe and .msi without errors.
- Fresh Win11 VM (Python pre-installed, no Git): prereq page renders,
  Python check shows detected, Git checkbox pre-checked. Click Next →
  Git installs via winget with UAC prompt in foreground.
- After install completes, Hermes launches and the agent's terminal
  tool can run bash commands. Verified Git Bash is detected at
  `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight.

* feat: theme changes, composer tweaks, in app update ux, finesse

* fix(cli): seed bundled skills on dashboard + gateway entrypoints

`sync_skills(quiet=True)` was only being called from inside `cmd_chat`,
which meant `hermes dashboard` (the desktop GUI's backend) and `hermes
gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled
skill library into ~/.hermes/skills/.

This surfaced as "No skills found" in the desktop GUI's skills panel on
fresh installs, despite the agent having access to the full bundled
library when invoked via `hermes chat`. scripts/install.ps1 worked
around it by running skills_sync.py as part of Copy-ConfigTemplates,
but that's not part of the desktop installer's bootstrap chain.

Fix
- Extract the skills-sync block from cmd_chat into a module-level
  `_sync_bundled_skills_quietly()` helper.
- Call the helper from cmd_chat (preserving existing behavior),
  cmd_dashboard (after the --status/--stop early-return paths and
  fastapi import check, so we don't run skills_sync on management
  commands or when deps aren't installed), and cmd_gateway.

Why these three entrypoints
- cmd_chat: the user's primary CLI entrypoint
- cmd_dashboard: the desktop GUI's backend; this is what `hermes
  dashboard --tui` invokes when the desktop bootstrapper spawns Hermes
- cmd_gateway: long-running daemons where the user expects the agent
  to have full skill access

Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status,
etc.) are management commands that don't need skill discovery and were
never running skills_sync in the first place — leaving them alone.

Idempotence
- tools/skills_sync.py is manifest-based: skipped skills cost
  milliseconds. Calling it from multiple entrypoints adds no real
  cost, and users running `hermes chat` then `hermes dashboard` get
  two fast no-ops on the second call.

Failure handling
- Helper wraps skills_sync in try/except. Skills are an enhancement,
  not a hard dependency — Hermes runs fine with an empty skills/ dir.

Files
- hermes_cli/main.py:
  + new helper `_sync_bundled_skills_quietly()` at module level
  + cmd_chat: replace inline block with helper call
  + cmd_dashboard: add helper call after fastapi import succeeds
  + cmd_gateway: add helper call before delegating to gateway_command

* feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes

- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.

* fix(desktop): address CodeQL alerts on PR #20059

- settings/helpers.ts: harden setNested against prototype pollution.
  POLLUTING_PATH_PARTS check is now applied at every assignment site
  (loop + leaf) and uses Object.defineProperty so CodeQL can see the
  guard inline rather than via a helper function call.

- lib/markdown-preprocess.ts: rebuild the dangling-fence close regex
  from a fence-char + length instead of marker.replace(...). The marker
  is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes,
  but CodeQL was tracing tainted input text into the RegExp source and
  flagging hostname dots from input as part of the pattern (false
  positive js/incomplete-hostname-regexp on the test fixture URLs).
  Reconstructing from a literal char breaks the dataflow.

- scripts/notarize-artifact.cjs: drop args from the run() rejection
  message. Args carry --key-id / --issuer / key file path; the existing
  outer catch already squashes errors to a generic line, but CodeQL was
  flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID.

Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are
already addressed in 4dd9732a9 — innerHTML assignment was replaced with
renderComposerContents which builds DOM via replaceChildren / append
text nodes (no HTML interpretation).

* fix(desktop): inline prototype-pollution guard so CodeQL sees it

CodeQL's dataflow doesn't follow the helper-function guard inside
`safeSet`, so it kept flagging Object.defineProperty as prototype-
polluting. Inline the literal `__proto__`/`constructor`/`prototype`
check at the assignment site to break the dataflow.

Behavior unchanged — same set of disallowed keys, same throw.

* feat(ui-tui): resolve links to readable page titles

Mirror desktop pretty-link behavior in the TUI by resolving HTTP links to page titles with shared caching and safe fetch filters, plus slug-based fallbacks so chat links stay readable even when title fetch fails.

* fix(desktop): drop RegExp from dangling-fence close detection

Previous attempt tried to break the dataflow by reconstructing the
close-fence regex from a literal char + marker.length, but CodeQL still
traced marker.length back to input and kept flagging the test-fixture
URLs as hostname-regex sources (js/incomplete-hostname-regexp).

Replace `new RegExp(...)` + `closeRe.test(body)` with a string-only
hasCloseFenceLine() helper that splits on '\n' and uses ===. No regex
on this path now, so input data can no longer reach a RegExp source.

Behavior preserved: matches lines that are (whitespace + marker +
whitespace), which is what the original `\n[ \t]*${marker}[ \t]*(?=\n|$)`
matched. All 12 markdown-text tests still pass.

* fix(process-registry): suppress windows-footgun false positive on guarded killpg

Keep the existing POSIX-only process-group teardown path, but make the
signal selection explicit via getattr and add an inline windows-footgun
suppression marker on the guarded os.killpg line so the Windows footgun
check no longer blocks CI on this intentionally platform-gated code.

* feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).

* feat(desktop): add ripgrep to NSIS prereq page + polish layout

Add ripgrep as a third (recommended) prereq alongside Python and Git in
the NSIS prereq detection page, and clean up the page layout based on
on-VM testing.

Why ripgrep
- Hermes' search_files tool calls `rg` directly for content + filename
  search (tools/file_operations.py:1382). Falls back to grep/find from
  Git Bash when missing — works but slower and noisier (no .gitignore
  awareness).
- ~5MB winget install via `BurntSushi.ripgrep.MSVC --scope user` — no
  UAC prompt, parallel to how Python installs.
- scripts/install.ps1 already installs ripgrep as part of
  Install-SystemPackages; this brings the desktop installer to parity.

Why "recommended" not "required"
- Python and Git are hard requirements: without them the agent runtime
  or terminal tool refuses to start. The bootstrapper preflight throws.
- ripgrep is a performance enhancement: missing it just means slower
  searches. Page wording reflects this; failure to install is logged
  but doesn't show a MessageBox or block.

Layout polish (response to on-VM screenshot review)
- Wizard header now correctly reads "System Requirements" instead of
  the leftover "Choose Install Location" from the previous page. Set
  via `GetDlgItem $HWNDPARENT 1037/1038` + WM_SETTEXT — the standard
  NSIS pattern for overriding the page header on a custom Page.
- Removed redundant in-body title + verbose intro paragraph; the
  wizard header IS the title now. Body has one short intro line.
- Group boxes tightened to 26u with content positioned just below the
  groupbox title (not top-anchored status + bottom-anchored checkbox
  with empty space in the middle). All three panels + footer fit
  comfortably in 126u, well under the 140u page limit.
- Checkbox labels simplified: dropped "(per-user, no admin prompt)"
  and "(administrator approval required)" suffixes. The footer note
  still calls out UAC for Git when relevant.
- Footer text trimmed to fit cleanly without clipping.

Install order (in customInstall macro)
- Python → ripgrep → Git
- Python and ripgrep are silent and run first; Git's UAC prompt comes
  last so the user's approval interaction isn't interrupted by silent
  activity afterwards.

Skip behavior unchanged
- All three detected → page auto-skips via Abort
- Silent install (/S) → customInstall winget block skips
- User unchecks all → page advances without running winget

Files
- apps/desktop/installer/prereq-check.nsh: ripgrep detection block,
  ripgrep page panel + checkbox, ripgrep customInstall block,
  GetDlgItem header override, layout reflow
- apps/desktop/README.md: Runtime prerequisites section updated to
  list ripgrep as recommended, with manual winget command

* feat(desktop): add model-confirmation step to onboarding

After OAuth/API-key login completes, onboarding now shows a confirmation
card with the curated default model and a Change button before dropping
the user into chat. Closes the gap where the desktop's `model.default`
was empty after first launch and the agent had to fall back to whatever
heuristic happened to fire — leaving users wondering "why am I getting
sonnet-4 when I logged into Nous Portal?"

Why
- Desktop onboarding only persisted credentials, never `model.default`.
  The CLI's `hermes model` command pairs provider + model selection,
  but the desktop's onboarding skipped the model step entirely.
- Result: users saw whichever model the agent's auto-fallback picked,
  unpredictably and undocumented.
- For the BUILD demo we want users to land on the model they expect
  for their provider, with a clear "this is what you're getting" UI
  and a one-click path to change it before chatting.

How
- New `confirming_model` flow status carries the just-authenticated
  provider slug, current default model, label, and a saving flag.
- `completeWithModelConfirm()` runs after credentials succeed: reloads
  env, verifies runtime, fetches /api/model/options to find the curated
  first-model for the provider, persists it via /api/model/set, then
  transitions into `confirming_model`.
- If anything fails (no providers returned, network error), falls
  through to the previous behaviour — onboarding completes without
  the confirm step. Polish, not a hard requirement.
- All four credential paths (device_code OAuth, PKCE OAuth, external
  CLI flow, API key) now use completeWithModelConfirm instead of
  reloadAndConnect.

UI
- `ConfirmingModelPanel` shows: green "<provider> connected" banner,
  card with "Default model: <name>" + Change button, and a "Start
  chatting" CTA that finalises onboarding.
- Reuses the existing `ModelPickerDialog` (the same picker available
  from the chat shell) for the change-model UX. Search, filtering,
  multi-provider listing — all already built.
- Stacking: ModelPickerDialog defaults to z-130, which renders UNDER
  the onboarding overlay (z-1300) and breaks pointer events. Added
  optional `contentClassName` prop to ModelPickerDialog so callers
  can override; onboarding passes `z-[1310]`.

Provider-slug matching
- For OAuth flows: pass `provider.id` directly as the preferred slug.
- For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key
  prefix strip. Also includes the user-visible label as a fallback
  candidate.
- fetchProviderDefaultModel falls back to the first authenticated
  provider in the response if no preferred slug matches — so even a
  miss still surfaces a reasonable default.

Files
- apps/desktop/src/store/onboarding.ts:
  + new `confirming_model` flow variant
  + fetchProviderDefaultModel + completeWithModelConfirm helpers
  + setOnboardingModel (optimistic update + revert on failure)
  + confirmOnboardingModel (finalises onboarding from the card)
  - reloadAndConnect (replaced; the four call sites now go through
    completeWithModelConfirm)
- apps/desktop/src/components/desktop-onboarding-overlay.tsx:
  + ConfirmingModelPanel component
  + new branch in FlowPanel for status `confirming_model`
  + ModelPickerDialog usage with z-[1310] content class
- apps/desktop/src/components/model-picker.tsx:
  + optional `contentClassName` prop on ModelPickerDialog so the
    dialog can be stacked on top of other fixed overlays

Tested
- `npm run type-check` passes
- `npx eslint` clean on touched files
- Live test in `npm run dev`: cleared onboarding cache, walked
  through Nous device-code flow, saw confirm card with curated
  default, clicked Change → ModelPickerDialog rendered above the
  onboarding overlay with working pointer events, picked a different
  model, "Start chatting" persisted to ~/.hermes/config.yaml.

* fix(desktop): suppress generic provider warning in onboarding

Hide the red setup notice when the message is the generic missing-provider guidance, since onboarding already presents provider auth actions. Centralize provider-setup matching across desktop hooks and add coverage for the matcher.

* fix(desktop): add 2u clearance below prereq checkboxes

Group box bottom border was clipping the checkboxes by 1-2px.
Bumped each box height 26u→30u; checkboxes now sit 2u above the bottom border.

* fix(nix): refresh dashboard lockfile hash

Update the web npm deps hash in nix/web.nix to match the committed apps/dashboard/package-lock.json so bb/gui passes the nix lockfile check.

* fix(desktop): install TUI deps in release workflow

Ensure desktop release builds install the standalone ui-tui package before bundling the TUI payload.

* fix(desktop): run release builder from app package

Invoke the desktop builder through the package script so electron-builder uses apps/desktop/package.json.

* fix(desktop): expand release artifact names safely

Build desktop artifact names from workflow version/channel while preserving electron-builder platform macros.

* fix(desktop): use package artifact naming in release workflow

Let electron-builder's desktop package config provide platform-specific artifact extensions while the workflow injects the release version/channel metadata.

* fix(nix): fetch dashboard npm deps from package root

Point the dashboard npm dependency fetch at apps/dashboard so Nix can find the package lockfile after the dashboard move.

* fix(nix): build dashboard from package directory

Set the web package source root to apps/dashboard so npm patch/build phases run beside the dashboard lockfile while keeping apps/shared available as a sibling.

* feat(desktop): render LaTeX math via KaTeX after streaming completes

Add @streamdown/math plugin to the chat markdown renderer.
Inline ($x^2$) and block ($$...$$) math both supported with
singleDollarTextMath enabled. Plugin is gated to non-streaming state
to match the existing pattern for syntax highlighting — math renders
when the message completes, avoiding KaTeX re-render churn during
streaming. KaTeX CSS is imported in styles.css; ~30KB CSS + ~430KB
JS added to the bundle. Smoothness improvements during streaming
deferred to a follow-up.

* perf(desktop): memoize KaTeX renders so math streams without re-rendering

Wrap rehype-katex with a per-equation LRU cache (keyed by
displayMode + source text) and re-enable math during streaming.

Stock @streamdown/math runs rehype-katex on every markdown commit,
so each new token re-katexes every equation in the message. For
math-heavy responses (an equation derived step-by-step) that's
hundreds of ms of wasted work per token and the streaming UI
chokes. With memoization, each equation pays katex.renderToString
exactly once; subsequent tokens re-walk the tree but hit cache for
unchanged equations.

The wrapper mirrors rehype-katex's semantics exactly: same class
detection (language-math, math-inline, math-display), same
<pre>-walk-up for fenced math blocks, same parent.children.splice
replacement, same SKIP traversal, same strict-then-lenient render
strategy with VFile message reporting.

Cached children are structuredCloned on each splice so downstream
rehype plugins or toJsxRuntime can't mutate the cache.

* fix(desktop): declare katex-memo deps directly + drop per-app lockfile

katex-memo.ts (added in 112cad59b) imports hast-util-from-html-isomorphic,
hast-util-to-text, remark-math, katex, and unist-util-visit-parents but
those were never added to apps/desktop/package.json. They were silently
resolving via @streamdown/math at the workspace root, which broke the
moment `npm i --prefix apps/desktop` ran with the per-workspace lockfile
because that install only consults apps/desktop/package.json. Add them
as direct deps, plus unified/vfile/@types/hast for the type imports.

Also delete apps/desktop/package-lock.json — root package.json declares
workspaces: ["apps/*"], so npm manages all lockfile state at the root.
The stale per-app lockfile is what made `npm i --prefix apps/desktop`
diverge from the workspace install in the first place and left an empty
apps/desktop/node_modules/@assistant-ui/ stub that Vite's dep optimizer
then tried (and failed) to open at @assistant-ui/core/dist/internal.js.

* feat(desktop): disable Backdrop noise overlay by default

The noise overlay defaulted to on, which adds a busy speckle layer over
the whole window for every new user. Flip the Leva default to off; the
toggle stays in Backdrop / Noise for anyone who wants it back.

* fix(desktop): polish LaTeX rendering — currency, code blocks, brackets

Five distinct bugs surfaced from a math-heavy stress test:

1. Adjacent code fences glued together. scrubBacktickNoise's
   second-pass regex /``\s*``/g matched the LAST 2 backticks of
   one fence + whitespace + FIRST 2 backticks of the next, collapsing
   two blocks into one. Fixed with lookbehind/lookahead so we only
   match exactly 2 backticks not part of a longer run.

2. Whitespace eaten between fences and following content.
   stripPreviewTargets internally calls .trim() which strips leading/
   trailing whitespace from each split-segment. For segments between
   two fences this collapsed \n\n to '', gluing fence close to next
   block. Fixed by capturing leading/trailing whitespace at the call
   site and restoring it after the transform.

3. Currency dollar signs eaten as math. With singleDollarTextMath:true
   remark-math greedy-matched any pair of $, so '$5 ... $10' became
   one inline math span. Added escapeCurrencyDollars to escape $<digit>
   patterns to \$<digit> in prose segments (not in code). Trade-off:
   math expressions starting with a digit (rare — '$5x = 10$') get
   escaped too. Mirrors the convention in ChatGPT/Claude's UIs.

4. \(...\) and \[...\] LaTeX brackets unsupported. Models often
   emit these instead of $...$ / $$...$$. Added
   rewriteLatexBracketDelimiters preprocessor pass.

5. ```latex / ```tex blocks were being routed to KaTeX via a
   rewrite to ```math. Aligns with GitHub markdown convention:
   ```math = render as math; ```latex / ```tex = LaTeX/TeX
   source code (syntax highlighted, not rendered). Conflating them
   broke teaching/showing-source use cases. MATH_FENCE_LANGUAGES
   pruned to {'math'} only.

Also flipped parseIncompleteMarkdown to true (was !isStreaming) so
the math parser can't see $ inside streaming-but-not-yet-closed code
fences. Shiki was already deferred via defer={isStreaming} so this
doesn't introduce new tokenization cost.

Test: 18/18 existing tests still pass; one test updated to expect
escaped \$ in currency-prose-with-URL case.

* fix(desktop): detect Python via registry/filesystem; pin to 3.11–3.13

Two related fixes for Python detection on Windows:

1. py.exe (Python launcher) is missing from per-user installs that
   didn't check the launcher option, so 'py -3.X --version' alone
   misses real Python installs. User-reported case: clean Win11 +
   official Python.org 3.14 install -> 'where py' returned nothing,
   our installer offered to install Python again. Both NSIS prereq
   page and main.cjs now probe in this order:
     1. py.exe launcher (when present)
     2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath
     3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v>
   Crucially, we never fall back to running 'python.exe' from PATH
   on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\
   WindowsApps\python.exe is a redirector that opens the Microsoft
   Store window if no Store Python is installed. Triggering that
   during boot would be terrible UX. Registry/filesystem probes
   never execute the binary.

2. Drop 3.14 from the supported version set. Several Hermes deps
   (notably pywinpty, which carries Rust crates like
   windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels
   missing, 'pip install -e .' falls back to building from sdist,
   which needs a Rust toolchain — users see 'could not compile
   windows_x86_64_msvc build script' on first run. install.ps1
   sidesteps this by pinning to 3.11 via uv; the desktop installer
   doesn't yet have the same uv-managed-Python pathway, so for now
   we accept 3.11/3.12/3.13 and tell winget to install 3.11 if
   none of those are present. Revisit when the wheel ecosystem
   catches up to 3.14 (~early 2026).

* feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes

- Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring.
- Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d).
- Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing).
- Remove stray merge conflict markers from pyproject.toml optional deps.

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

* fix(title-bar): position sidebar toggle button

* feat(desktop): composer queue — queue many, edit/delete/cancel-edit, Cursor-style

Press Enter while busy with a draft to queue it; with no draft to interrupt
and send the next queued turn. Auto-drains one queued turn each time the
session settles, same as Cursor. Queue persists across reloads so an
interrupted-and-queued turn isn't lost on refresh.

Each queued row supports edit-in-composer (with explicit Save/Cancel),
send-now (↑), and delete. Drain skips only the entry currently being
edited so the rest of the queue keeps flowing.

Queue dequeue is transactional — an entry only leaves the queue after
`prompt.submit` is accepted, so a rejected submit doesn't drop the turn.

Also shrinks the `[interrupted]` marker to a muted one-liner and drops
its assistant footer so it stops looking like a real reply.

* fix(desktop): handle empty usage analytics totals

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

* fix(desktop): address PR review titlebar and usage races

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

* feat(desktop): add MCP settings and live subagent tree

Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands.

Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session.

* fix(desktop): move power-user views out of sidebar

Keep Cron and Profiles available through lower-prominence chrome entry points so the workspace sidebar stays focused on core chat navigation.

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

* refactor(desktop): subagent overlay reads like a live transcript, not a dashboard

Strip the card chrome and rewire /agents to feel like peeking into the
child agent's stream:

- subagents store: single `stream` of typed entries (thinking/tool/progress/
  summary) replaces the parallel notes/thinking/tools arrays. Drop unused
  fields (toolsets, depth, apiCalls, reasoningTokens, sessionId).
- agents view: no OverlayCards, no boxed stream, no per-row borders. Goal +
  status pill + indented stream lines, full row width.
- Group root spawns into "Delegation N" sections when batch shape + spawn
  time match — hides task-index interleaving and makes hierarchy obvious.
- Sort tree by spawn time, then task_index. Step indicator is one colored
  pill (primary while running, emerald when done) inside the row, not a
  trailing pill that wrapped under the chevron.
- Tree picks up `subagent.start` (not only `spawn_requested`) and prunes
  delegate-tool fallback rows once native subagent events land for the
  session — fixes duplicate "Delegated task" rows alongside the real ones.

* feat(desktop): Esc closes every OverlayView-based overlay

Lift the keyboard handler into the shared OverlayView so Agents, Settings,
Command Center — and anything we build on top of it later — all dismiss on
Esc by default. Nested Radix dialogs stop propagation themselves, so a
modal opened inside an overlay (e.g. model picker inside Settings) still
closes the modal first, not the overlay underneath.

Drop the now-redundant Esc handlers in Settings (kept Cmd/Ctrl+P) and
Command Center.

* fix(desktop): drop numbered step pill on subagent rows

The pill was getting clipped at the overlay edge anyway. Just use the
status glyph (●/✓/✗/■/○) — the delegation header already conveys
"3 workers, 3 active", and order in the list implies which step you're
looking at.

* fix(desktop): drop noisy "returned N items / empty object" stub strings

When a tool returns nothing useful, the row should be silent — the title
("Search Files", etc.) already tells the user what happened. Counting the
fields in an opaque payload is engineer-noise.

`formatToolResultSummary` and `minimalValueSummary` now return '' for
empty arrays / records / unrecognized values; tool-fallback already hides
the detail section when its body is empty.

* refactor(desktop): subagent rows borrow chat tool patterns (fade-in, lucide glyphs, shimmer)

Pull the agents view closer to how chat tool blocks render:
- statusGlyph() returns the same lucide BrailleSpinner / CheckCircle2 /
  AlertCircle vocabulary as tool-fallback's statusGlyph
- Stream lines fade-in via useEnterAnimation (one-shot WAAPI), keyed per
  entry so streamed deltas settle in instead of popping
- Subagent rows fade in too, and pick up the existing data-slot=tool-block
  spacing rules between blocks
- Active stream line trails a BrailleSpinner instead of a hand-rolled
  pulsing rectangle
- Goal text drops FadeText (which forces nowrap); keep FadeText only for
  the single-line meta subtitle
- Running rows shimmer the title — same affordance the chat thinking row
  uses

* refactor(desktop): make /agents subagent-only, drop sidebar + dead sections

Activity rail and History stub were both noise. Strip the split layout,
sidebar, route enum, and the rail/stub helpers — the overlay is now just
the spawn tree, centered in a max-w-3xl column so it stops claiming the
whole screen for one section's worth of content.

* feat: update cron modals

* Add dedicated GUI log stream for dashboard debugging.

Capture dashboard and PTY websocket lifecycle failures in gui.log and expose it via hermes logs.

* Improve desktop runtime UX by surfacing inference readiness in gateway status and hardening WSL link opening.

This also stabilizes markdown code/table block spacing and adds root-install guards so desktop dev runs use a healthy workspace dependency tree.

* Log detailed GUI websocket failure metadata.

Capture richer reject/disconnect/send/parse context for dashboard gateway websocket flows so GUI connection failures are diagnosable from logs.

* Default dashboard startup logging to GUI mode.

Detect the dashboard subcommand during early CLI bootstrap so gui.log is attached from process start and GUI startup failures are always captured.

* Clean up gateway status conditionals and logging bootstrap mode detection.

Simplify nested dashboard gateway status branches for readability and use a concise first-subcommand check when selecting early GUI logging mode.

* add logging to nsis installer

* feat: glass ui pass

* fix(desktop): persist inline assistant errors across hydrate/resume

- Detect provider failure text arriving via message.complete
  (HTTP 4xx, "API call failed after N retries", Provider/Gateway
  error: ...) and persist as an inline assistant error instead of
  regular completion text, blocking the hydrate that was wiping it.
- preserveLocalAssistantErrors: merge by id so same-id hydrated
  messages keep their local error, and preserve the optimistic
  user+error pair as a unit (with tail-user dedupe).
- Hook all hydrate/resume writers (use-session-actions resume +
  fallback, hydrateFromStoredSession, syncSessionStateToView) into
  the merge so stale snapshots can't clobber a failed turn.
- Add error to chatMessagesEquivalent so the resume diff actually
  sees error-only changes and paints them.
- editMessage on a failed turn now submits a plain resend (no
  truncate_before_user_ordinal) and retries plainly on the
  "no longer in session history" race.

Style polish on touched files:
- Inline error: text-only treatment (no card).
- User stop / edit-composer send: shared Tabler IconPlayerStopFilled
  glyph + shared icon-button class slot for parity.

* feat(desktop): theme xterm with active light/dark mode

The right-sidebar terminal hardcoded a light palette, which read poorly
on the dark glass surface. Subscribe to `useTheme().resolvedMode` and
hot-swap `term.options.theme` so Shift+X (and any other mode change)
updates the terminal in place without tearing down the PTY session.

Dark mode uses xterm's built-in defaults (white fg/cursor + vivid ANSI
16) with just a transparent background so the glass shows through;
light mode keeps the existing hand-tuned overrides for legibility on a
bright surface.

* feat(sidebar): right-click + drag-reorder sessions and workspaces

- Wire right-click on session rows to open the same actions menu;
  suppresses the OS-native context menu so Windows stops looking awful.
- Share dropdown + context menu items via useSessionActions() driving
  a single declarative ItemSpec[]; render polymorphic over MenuItem.
- New shadcn ContextMenu primitive mirroring DropdownMenu styling.
- Restore drag-and-drop reordering for Agents (lost during the cwd
  cleanup) and add reordering of workspace groups via a right-side
  grab handle. Pinned reorder unchanged.
- Generic orderByIds<T> replaces the duplicated session/group orderers;
  useSortableBindings() hook collapses the two Sortable wrappers.
- cursor-pointer on every actionable element; cursor-grab on handles.
- KISS pass: baseName() helper, AGE_TICKS table, single WORKSPACE_PAGE
  constant, flatter SidebarSessionsSection render.

* feat(desktop): solarize the xterm palette in both light & dark

xterm's default ANSI 16 is tuned for dark and reads candy-bright on the
light glass surface (vivid cyans/greens). Ship the canonical Solarized
palette (Schoonover) for both modes — same 16 accents either way, only
fg/cursor swap between `base00/01` (light) and `base0/1` (dark), so a
prompt's colors look uniform across a Shift+X toggle.

Background stays transparent in both modes — Solarized's cream/slate
backgrounds would fight the glass.

* feat(desktop): virtualize chat thread + sidebar via TanStack Virtual

Replaces `use-stick-to-bottom` and per-row session rendering with
`@tanstack/react-virtual`, matching what Cursor uses.

Chat thread (`thread-virtualizer.tsx`):
- Natural-flow virtualization (padding spacers, not absolute items) so
  `position: sticky` on the human bubble still resolves cleanly against
  the scroller.
- Custom at-bottom anchor: pins when armed, disarms on user-driven
  upward scroll, re-arms at bottom, jumps on session switch +
  `thread.runStart`.
- Loading indicator and `--thread-last-message-clearance` move to a
  real `[data-slot=aui_composer-clearance]` node; drops the brittle
  `:nth-last-child(1 of …)` rule that can't fire reliably under
  virtualization.

Sidebar (`virtual-session-list.tsx`):
- Flat agents list virtualizes at >=25 rows; pinned and
  workspace-grouped paths stay direct-render.
- `SortableContext` keeps all IDs; only the window mounts; dnd-kit's
  `setNodeRef` is merged with `virtualizer.measureElement` so rows
  participate in both DnD hit-testing and TanStack measurement.

Drops `use-stick-to-bottom`. Streaming test gets a global
`offsetWidth/offsetHeight` stub so the virtualizer's viewport sizing
works in jsdom; the scroll-up-doesn't-pull-back invariant still passes.

* feat: more ui qa

* fix(desktop): trim sidebar terminal startup spacer

Drop zsh's initial spacer row before writing the first terminal prompt so new sidebar terminal sessions do not open with a selectable blank line.

* chore: uptick

* feat(desktop): thin installer + first-launch install.ps1 bootstrap

Converges the Windows packaged desktop installer onto a single canonical
install topology: drop the Electron shell only (~80MB instead of ~500MB),
clone Hermes Agent at a build-time-pinned commit on first launch via
install.ps1's stage protocol, and treat the resulting git checkout at
%LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location
(same path the CLI installer uses).  Future updates flow through the
existing applyUpdates() git-pull path.

Replaces the previous fat-installer architecture where the .exe bundled
a pre-staged hermes-agent source tree under resources/hermes-agent/ that
was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated
factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT
mismatch on path resolve, isGitCheckout guard regressions, pyproject
hash drift detection inside the sync loop).

Architecture overview
---------------------

  Build time
    apps/desktop/scripts/write-build-stamp.cjs writes
    apps/desktop/build/install-stamp.json with {commit, branch, builtAt,
    dirty}.  Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to
    `git rev-parse HEAD` locally.

    apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset
    of @homebridge/node-pty-prebuilt-multiarch from the workspace-root
    node_modules into apps/desktop/build/native-deps/.  Workspace dedup
    hoists this dep to the root, out of reach of electron-builder's
    `files:`-restricted collector; staging gives us a deterministic
    path to extraResources.

    electron-builder ships both into resources/install-stamp.json and
    resources/native-deps/ respectively.

  Boot resolver (electron/main.cjs)
    Resolver order:
      1. HERMES_DESKTOP_HERMES_ROOT override
      2. SOURCE_REPO_ROOT (dev mode)
      3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete
         marker -- the post-install fast path
      4. `hermes` on PATH (CLI-installed user adding the desktop)
      5. pip-installed hermes_cli via system Python
      6. bootstrap-needed sentinel -> hand off to runBootstrap

    Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER /
    syncTreeExcludingVenv machinery (-200 lines).  The isGitCheckout
    guard that bit us in the install.ps1 PR is gone.

  First-launch bootstrap (electron/bootstrap-runner.cjs)
    1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else
       download from GitHub raw at INSTALL_STAMP.commit (cached at
       HERMES_HOME\bootstrap-cache\install-<sha>.ps1).
    2. Fetch the stage manifest via install.ps1 -Manifest -Commit X
       -Branch Y.
    3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json
       -Commit X -Branch Y per stage.
    4. On all stages green: write the .hermes-bootstrap-complete
       marker with {schemaVersion, pinnedCommit, pinnedBranch,
       completedAt, desktopVersion}.

    Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log.  Cancellation
    via AbortSignal.  Manifest cache so retries don't re-download.

  Install overlay (src/components/desktop-install-overlay.tsx)
    Mounted alongside the existing onboarding overlay; flexbox card
    with header (static) + middle (scrollable) + footer (failure-only,
    static).  Subscribes to hermes:bootstrap:event IPC + resyncs from
    hermes:bootstrap:get on mount/reload.  Renders:
      - 14-stage checklist with per-stage state icons
      - Overall progress bar + current-stage spotlight
      - Auto-expanded installer-output panel on failure
      - "Copy output" button (full ring buffer + error to clipboard)
      - "Reload and retry" wired through hermes:bootstrap:reset to
        clear main.cjs's latched failure
    Synthetic empty-manifest event from main.cjs flips the overlay to
    'active' immediately so the slow install.ps1 download doesn't
    leave the user staring at the generic Preparing splash.

  Failure latching (main.cjs)
    bootstrapFailure module-scope variable holds the rejection after
    install.ps1 fails.  startHermes() throws the latched error
    immediately when set, bypassing the entire ensureRuntime +
    runBootstrap chain.  Without this, the renderer's ensureGatewayOpen
    retries would re-run install.ps1 in a 5-10 min hot loop while the
    user was still reading the failure overlay.  Cleared via
    hermes:bootstrap:reset on user-driven retry.

  Unsupported-platform overlay (1F)
    macOS / Linux packaged builds (no install.sh stage protocol yet)
    emit an unsupported-platform event with a copy-pasteable install
    command + docs URL.  Dedicated overlay branch with "Copy command"
    + "I've run it -- retry" buttons.

install.ps1 additions (Phase 1F.3 + 1F.5)
-----------------------------------------

  New -Commit and -Tag string params.  Precedence Commit > Tag >
  Branch.  Honoured by all three code paths (update / fresh clone /
  ZIP fallback), with archive URL selection that handles each
  ref-type variant.  Detached-HEAD checkouts intentionally -- they're
  pins, not branches the user pulls into.

  EAP=Continue wrap around the new pin-step git invocations.  `git
  fetch origin <commit>` writes the routine 'From <url>' info line to
  stderr; under the script's global EAP=Stop that terminates the
  script even though fetch+checkout succeed.  Matches the established
  pattern in Install-Uv, Test-Python, _Run-NpmInstall.

Backend fix (hermes_cli/web_server.py)
--------------------------------------

  CORS allow_origin_regex now accepts Origin: 'null'.  Packaged
  Electron loads index.html via file://; Chromium sets the WebSocket
  upgrade Origin header to the opaque origin 'null', which the old
  regex rejected with HTTP 403 before gateway_ws() ever ran.  This
  failure mode was masked in the older FACTORY_HERMES_ROOT
  architecture because the resolver often found an existing hermes
  on PATH with different binding behavior.

  Security maintained: localhost-only bind keeps cross-machine pages
  out; per-process session token still gates every authenticated
  /api/ endpoint regardless of Origin.

Desktop QoL
-----------

  DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I).
  Field-debugging trade-off: tiny attack surface increase versus
  a much better support story when CSP / WS / theme issues surface.

  NSIS prereq-check page deleted (-767 lines).  The standard
  Welcome -> License -> Directory -> InstallFiles -> Finish wizard
  now installs without custom Python/Git/ripgrep detection -- those
  prereqs are install.ps1's job at first launch.

Test infrastructure (Phase 1G)
------------------------------

  apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform
  bundle validator (was darwin-only and asserted on dead factory-
  payload paths):
    NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard)
    POSITIVE: install-stamp.json carries a real commit + branch
    POSITIVE: node-pty native deps shipped under resources/native-deps
    POSITIVE: renderer dist/index.html reachable (asar or unpacked)
  New nsis mode and npm run test:desktop:nsis script.

Validated end-to-end on clean Win10 VM
--------------------------------------

  Confirmed: NSIS installer drops Electron shell, app launches,
  install overlay shows progress, install.ps1 clones the pinned
  commit, 14 stages run to completion, marker written, backend
  spawns, WebSocket connects, onboarding overlay asks for API key,
  main UI loads, integrated terminal works.

  Failures handled: bootstrap stays failed (no hot-loop retry),
  "Copy output" gives actionable transcript, "Reload and retry"
  explicitly re-runs install.ps1.

What's deferred
---------------

  - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest
    with runFullTrust, signed and submitted to Microsoft Store.
  - install.sh stage protocol parity (Phase 2): once shipped, the
    unsupported-platform overlay becomes drive-it-yourself and
    macOS/Linux packaged installers gain feature parity with Windows.

* feat(desktop): persistent terminal pane + fullscreen takeover

Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal
tab that takes over the chat pane area without unmounting the shell. The
xterm host is mounted once at the layout root and CSS-overlayed onto
whichever <TerminalSlot /> is currently active, so the PTY session,
scrollback, selection, focus, and WebGL renderer survive every toggle.

Also:
- WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint
  faithfully instead of muting through xterm's default DOM renderer
- File drag/drop from the project tree or OS into xterm — paths are
  shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY
- Solarized dark canvas with brights promoted to real accent variants
  (Schoonover's UI-gray brights washed out every TUI accent)
- Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty
  parents (CI runners, Cursor's agent shell) so the embedded shell gets
  truecolor regardless of how Electron was launched
- rAF-debounced ResizeObserver — running fit.fit() synchronously during
  sibling pane transitions crashed the WebGL texture-atlas rebuild

* fix(install.ps1): strip UTF-8 BOM regression that broke 'irm | iex'

The canonical install flow

    irm https://raw.githubusercontent.com/.../scripts/install.ps1 | iex

fails on PowerShell 5.1 with a cascade of 'The assignment expression
is not valid' errors at every param() default value:

    [string]$Branch = 'main',
                      ~~~~~~
    The assignment expression is not valid. The input to an assignment
    operator must be an object that is able to accept assignments...

Root cause: scripts/install.ps1 carries a UTF-8 BOM (0xEF 0xBB 0xBF)
as its first three bytes. 'irm' returns the response body as a string;
on PS 5.1 the BOM survives into that string as a leading \ufeff
character. 'iex' then evaluates the string and PS's parser chokes
on the invisible character before param() -- error recovery proceeds
into the body but every assignment is reported as broken.

This was the exact failure mode the install.ps1 hardening pass (PR
#27224) deliberately fixed by stripping the BOM and ensuring the
file body is pure ASCII. Commit 4279da4db ('fix(windows): make
PowerShell installer parse in 5.1') re-introduced the BOM later,
unintentionally undoing the irm|iex compatibility fix; the merge
that brought it into bb/gui carried it forward.

Fix: strip the three BOM bytes. File body is verified pure ASCII
(any-byte > 127 returns false), so PS 5.1 with no BOM falls back to
Windows-1252 decoding which is identical to ASCII for our content.
Both install paths now work:
  - 'irm ... | iex' (canonical CLI)
  - 'powershell -File install.ps1' (programmatic / desktop bootstrap)

* install.ps1: detect ARM64 Windows reliably for Node and Git stages

Add a Get-WindowsArch helper that reads Win32_Processor.Architecture
via CIM (invariant to PowerShell host bitness) with PROCESSOR_ARCHITEW6432
fallback. Use it in:

- Install-Git: previously only triggered the arm64 PortableGit asset
  when invoked from a native-ARM64 PowerShell host. WoW64 / emulated
  x64 hosts (the default powershell.exe on Windows-on-ARM) saw
  PROCESSOR_ARCHITECTURE=AMD64 and fell through to the x64 PortableGit
  build, leaving ARM64 users on emulated Git for Windows.

- Test-Node: previously hardcoded the Node download to win-x64 on any
  64-bit OS, so ARM64 users always got x64 Node under Prism emulation
  even though Node ships an arm64 build for Windows. The winget
  fallback now also passes --architecture arm64 on ARM64.

Python remains x86_64 by design: uv intentionally prefers
windows-x86_64 cpython on ARM64 hosts for ecosystem (wheel)
compatibility (see astral-sh/uv#19015).

* install.ps1: harden Install-SystemPackages against winget msstore failures

The previous winget invocation discarded stdout/stderr and trusted no
signal at all -- not the exit code (winget exits 0 even when it bails
"please specify --source"), not output (sent to Out-Null), not the
catch handler (winget returning 0 means no exception fires). The only
trust signal was a post-install Get-Command rg / Get-Command ffmpeg
check, which would also miss the package because %LOCALAPPDATA%\
Microsoft\WinGet\Links (where winget puts command aliases) is added to
PATH by AppExecutionAlias machinery only in fresh shells. End result on
machines where the msstore source has a cert problem (0x8a15005e --
common on Windows-on-ARM and some corporate networks): silent failure,
no log, no breadcrumb, and the user is told the install succeeded.

Specifically:

- Pin --source winget on every winget install call. Defeats the broken-
  msstore-source path. We ship nothing from msstore so this is safe and
  forward-compatible.

- Add --exact --id for a tighter package match.

- Capture each winget invocation's combined stdout/stderr + exit code to
  %TEMP%\hermes-winget-<pkg>-<n>.log instead of Out-Null. On the happy
  path the log is deleted after the post-install check confirms the
  binary is on PATH; on failure the log is kept and its path is named in
  a Write-Warn so the user has something to grep.

- Refresh PATH to include %LOCALAPPDATA%\Microsoft\WinGet\Links in
  addition to the User/Machine env-var hives, so Get-Command sees newly-
  installed winget aliases in the same process.

- No behavior change on the happy path. Same Write-Info/Success/Warn
  cadence, same fallback order (winget -> choco -> scoop -> manual),
  same $script:HasRipgrep / $script:HasFfmpeg outputs.

Verified end-to-end on a real Snapdragon ARM64 Windows host: ripgrep
uninstalled, stage re-run, [OK] ripgrep installed in 1.4s, ok:true.

* desktop: swap node-pty fork for upstream microsoft/node-pty 1.1.0

The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1,
publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x
betas (which do add an arm64 Windows build) ship no electron-vXXX-win32-
arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would
fail at runtime even on a successful npm install. Net effect: the
desktop's integrated terminal was unbuildable on Windows-on-ARM, in
both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64
prebuilt) and packaged builds (no Electron-ABI prebuilt exists).

The homebridge fork was originally created because upstream node-pty
shipped no prebuilds at all. That hasn't been true since node-pty@1.0
(April 2024), which:

- bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly
  inside the npm tarball -- no GitHub-Releases fetch, no missing-binary
  failure mode
- uses N-API (node-addon-api) for ABI stability across Node and Electron
  major versions, so the same pty.node binary loads under Node 22 (dev)
  and Electron 40+ (packaged) without per-ABI rebuilds
- is what VS Code, Hyper, and Theia actually ship

API surface is identical (spawn / onData / onExit / write / resize /
kill) -- no call-site changes needed.

Specifically:

- apps/desktop/package.json: replace the @homebridge fork with
  node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]`
  to also unpack `**/prebuilds/**`, because node-pty ships runtime-
  execed helpers alongside its .node files (darwin spawn-helper has no
  extension and would not be matched by `**/*.node`; conpty.dll,
  OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also
  exec'd at runtime and cannot live inside asar).

- apps/desktop/electron/main.cjs: update both require() strings to
  match the new package name and the new staged path under
  resources/native-deps/node-pty/.

- apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/
  node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/
  (not build/Release/), so update the include glob to copy that dir.
  Per-arch staging keeps the resource bundle small (target arch comes
  from npm_config_arch when electron-builder cross-builds, else
  process.arch). Explicitly enumerate file types in the prebuilds glob
  so the ~25 MB of .pdb debug symbols that prebuild-install bundles
  for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB
  staged on win32-arm64). Re-assert +x on the darwin spawn-helper
  defensively, since a stripped mode bit would manifest as a silent
  ENOENT at first pty.spawn().

- apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths()
  and its assertion site to look at prebuilds/<plat>-<arch>/ instead of
  build/Release/. Add an explicit spawn-helper-exists check on darwin
  so a regression in the asarUnpack glob would fail loudly in CI rather
  than at first PTY spawn.

Trade-off: Linux end-users lose prebuilts and fall back to building
node-pty from source on `npm install`. Acceptable because Hermes
ships no Linux desktop builds (desktop-release.yml matrix is mac + win
only, package.json declares no `linux` target), and Linux developers
hacking on the desktop already need a C++ toolchain for the rest of
the stack.

Verified on Windows 11 ARM64 (Snapdragon):
  npm install                                          -> exit 0
  node -e "require('node-pty').spawn(...)" round-trip  -> OK
  stage-native-deps                                    -> 27 files, 2.6 MB
  load from staged tree (simulates packaged fallback)  -> ConPTY
                                                           round-trip OK

* desktop+gateway: harden Slack socket recovery and Windows restart dedupe (#28873)

* desktop+gateway: harden Slack socket recovery and Windows restart dedupe

Fix Slack Socket Mode reliability by adding a watchdog/reconnect path so silent socket task drops no longer leave the adapter stuck. Harden Windows gateway lifecycle by avoiding desktop-binary path collisions, making gateway PID scans case/extension tolerant, and reusing in-flight restart actions to prevent duplicate gateway spawns.

* test(slack): add Socket Mode watchdog/reconnect behavioural coverage

Drive the new Slack Socket Mode self-healing logic through a fake AsyncSocketModeHandler so we can simulate the P0 silent-hang failure mode (task exit, transport disconnected, intentional shutdown, concurrent reconnect attempts) without touching real Slack.

* fix(slack,desktop): address Copilot review on watchdog races and path normalization

- connect(): explicitly cancel + await the prior socket watchdog before flipping _running, so an old monitor cannot exit between teardown and respawn (Copilot #1)
- _socket_watchdog_loop: wrap the body in try/except + add a done-callback that respawns on unexpected crash, so a transient bug cannot permanently disable self-healing (Copilot #2)
- normalizeExecutablePathForCompare: use the resolved path for realpathSync so non-string inputs cannot leak through (Copilot #3)
- Add tests for crash-recovery and atomic watchdog replacement across reconnects

* fix(slack): tighten connect() error path and clarify watchdog test intent

Address Copilot review round 2.

- connect(): wrap _start_socket_mode_handler/_ensure_socket_watchdog in a focused try/except so any failure rolls back partially-started handler/task state and leaves _running=False, ensuring the platform lock is always released by the outer finally
- Defer _running=True until after the handler is actually started so the watchdog observes a live socket task immediately and never spins against a half-built adapter
- Rename test_watchdog_self_restarts_after_unexpected_crash to test_watchdog_cancellation_does_not_respawn (matches what it actually asserts) and add test_watchdog_unexpected_exit_respawns_via_done_callback that drives a real RuntimeError through _on_socket_watchdog_done and verifies a fresh task replaces the crashed one

* fix(web_server): serialize action spawn check+store under a threading lock

Address Copilot review round 3.

FastAPI runs sync handlers on its threadpool, so two near-simultaneous /api/gateway/restart (or /api/hermes/update) requests could both observe "no live process" in _spawn_hermes_action's poll-based dedupe and double-spawn. Add a module-level _ACTION_SPAWN_LOCK around the entire check + Popen + _ACTION_PROCS store sequence so the dedupe is atomic across threads.

* fix: address Copilot review round 4

- slack.disconnect(): mirror connect()'s defensive cleanup — catch the broad Exception path on watchdog await so handler shutdown and lock release still run if the watchdog raised before cancellation took effect
- web_server._spawn_hermes_action: wrap subprocess.Popen in try/except so a missing executable / permission error closes the log file handle, writes a failure marker, and re-raises instead of leaking a file descriptor
- gateway._scan_gateway_pids: drop the over-broad "hermes.exe --profile" / "hermes.exe -p" patterns that would match any Hermes CLI subcommand using a profile flag (e.g. `hermes.exe --profile foo dashboard`); rely on the "hermes.exe gateway" + "hermes-gateway.exe" tokens instead
- tests: tighten _fake_create_task to assert coroutine input and return a real asyncio.Task that stays pending until pytest teardown, and update the three callsites whose mocked AsyncSocketModeHandler.start_async returned a non-coroutine value

* fix(slack): reset multi-workspace state on reconnect

Address Copilot review round 5.

connect() is reentrant (gateway restart, in-process reconnect), but it was leaving _bot_user_id / _team_clients / _team_bot_user_ids populated from the previous session. A reconnect that rotated the primary token or dropped a workspace would silently keep the stale bot user id and stale workspace client maps, leading to dispatch against gone workspaces.

Clear these three pieces of state right after _stop_socket_mode_handler() and before the auth_test loop, then let the loop repopulate from the current tokens. Add test_reconnect_refreshes_multi_workspace_state to lock it in.

* nix: package apps/desktop as .#desktop (#28964)

Adds nix/desktop.nix building the Electron renderer with buildNpmPackage
and wrapping nixpkgs' electron binary.  Reuses .#default by setting
HERMES_DESKTOP_HERMES to its hermes binary, so the desktop's resolver
picks up the fully-wired nix hermes (venv, bundled skills/plugins,
runtime PATH) without reimplementing agent resolution.

- nix/desktop.nix: renderer + electron wrapper
- nix/hermes-agent.nix: finalAttrs form, exposes hermesDesktop in passthru
- nix/packages.nix: exposes .#desktop + adds to fix-lockfiles
- apps/desktop/package-lock.json: standalone hermetic lockfile

nix build .#desktop && nix run .#desktop both clean.

* fix(desktop): probe steps 4 & 5 of resolveHermesBackend before trusting

A user-reported failure on Windows-on-ARM: a pre-installed Python 3.13
on PATH makes findSystemPython() succeed, so resolveHermesBackend
returns a backend pointing at it -- but hermes_cli isn't in that
interpreter's site-packages. The spawn dies with ModuleNotFoundError
and the user sees a dead GUI instead of the first-launch installer.

Same shape can hit step 4 (existing `hermes` on PATH) when a stale
shim survives a partial uninstall.

Add cheap exit-code probes -- `python -c "import hermes_cli"` for
step 5, `<hermes> --version` for step 4 -- and fall through to step 6
(bootstrap-needed) on failure. install.ps1 then runs as if on a clean
box and the venv gets built.

Probes live in a standalone electron/backend-probes.cjs module so they
can be unit-tested with node --test, same pattern as bootstrap-platform.cjs
and hardening.cjs. New test file wired into test:desktop:platforms.

* test(desktop): allow `node-pty` bare-require in packaged entrypoints

Pre-existing failure on bb/gui since c858484b4 swapped the node-pty
fork for upstream microsoft/node-pty 1.1.0. main.cjs intentionally
bare-requires node-pty (it's hoisted by workspace dedup in dev, and
staged to resources/native-deps via scripts/stage-native-deps.cjs +
extraResources for packaged builds, with a try/catch fallback at
line ~38). The allowlist hadn't been updated to match -- same shape
as `electron`, which was already allowed.

* chore(deps): refresh root lockfile for dashboard @nous-research/ui 0.14.0

apps/dashboard/package.json was bumped to @nous-research/ui 0.14.0 (+
flag-icons ^7.5.0, motion ^12.38.0) but the root package-lock.json was
never refreshed. Running `npm install` from the repo root now
materialises 0.14.0's transitive closure (launder, bumps for
@nanostores/react, nanostores, sanitize-html, tailwind-merge).

No code changes; purely a lockfile catch-up so fresh checkouts on bb/gui
get a working dashboard install.

* chore(desktop): bump version to 0.0.1

First non-placeholder version so electron-builder's artifactName template
produces `Hermes-0.0.1-win-x64.exe` instead of the obviously-unreleased
`Hermes-0.0.0-...`. No release process yet; this just stops the artifact
filename from telling users "you got a debug build."

Bumped in three slots that all carry the desktop app's version:
- apps/desktop/package.json (source of truth)
- apps/desktop/package-lock.json (per-app lockfile, kept for CI parity)
- root package-lock.json's apps/desktop workspace entry

Identity-of-build for first-launch bootstrap continues to come from
build/install-stamp.json (commit SHA + builtAt), unchanged.

* fix: fs icon color

* perf(desktop): cut per-keystroke layout + listener churn in chat composer

Empirical work via CDP harnesses under apps/desktop/scripts/ (see
profile-typing-lag.md):

  jsListeners growth (per round of 200 chars + GC):
    before: +35  (verified leak — listeners stuck after 1st trigger popover use)
    after:  +0

Four narrow edits in src/app/chat/composer/index.tsx:

1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to
   decide composer expansion. Replace with `draft.length > 60` heuristic;
   the existing ResizeObserver still catches edge cases. `scrollHeight`
   is a forced-layout call and was firing on every char until the first
   wrap.

2. Bucket measured composer height to 8px before writing
   `--composer-measured-height` / `--composer-surface-measured-height`
   on `documentElement`. Without this, the editor grows ~1px per char,
   setProperty fires every keystroke, computed style is invalidated tree-
   wide.

3. Remove the dead `$composerDraft` two-way sync. Nothing outside the
   composer subscribed to that atom (verified via grep). Two useEffects
   on `[draft]` were pushing draft→atom and atom→aui per keystroke for
   no consumer. Also drop the per-keystroke
   `reconcileComposerTerminalSelections` call; it was pruning stale
   labels for `terminalContextBlocksFromDraft`, but that helper already
   ignores labels not in the current submitted text, so pruning per
   keystroke was just bookkeeping.

4. `refreshTrigger` fast-bails when the draft contains neither `@` nor
   `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup
   regardless; `range.toString()` inside is O(n) over draft length.

Synthetic typing latency p50/p90/p99 is similar before vs after on a
freshly-loaded session (Blink can already handle ~30cps typing into a
contentEditable on its own); the real win is the listener leak being
gone and the global computed-style invalidations dropping ~8× when the
composer is sitting at a fixed height row.

The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit /
TTFT stall") is unmeasured here — needs a throwaway session because
the harness fires a real prompt. Not blocking this commit.

* perf(desktop): cut FadeText forced layouts during streaming

The slowest user-felt path is typing into the composer while the
assistant is streaming. Profile (scripts/profile-under-stream.mjs):

  FadeText measureOverflow self time:  35.8 ms → 18.1 ms  (-50%)
  total active CPU during 7s window:   ~150 ms → ~50 ms

Two changes in src/components/ui/fade-text.tsx:

1. Drop the `useEffect([children])` that re-ran `measureOverflow`
   (reads scrollWidth + clientWidth — forced layout) on every parent
   re-render. `useResizeObserver` already fires the same callback on
   mount and whenever the host span's box size changes; that covers
   the only case where overflow state can legitimately change. The
   previous explicit useEffect was a forced-layout flush on every
   parent render, which during streaming meant every token tick.

2. Wrap the component in `memo` with a custom comparator that
   short-circuits the entire render when scalar string `children` and
   the className/fadeWidth/style props are unchanged. The hot path
   was tool-fallback's title chips being re-rendered by parent
   streaming updates even though their text was stable; memo+
   comparator skips that.

Also adds two harness scripts under apps/desktop/scripts/:
  - latency-under-stream.mjs (key→paint latency while a turn streams)
  - profile-under-stream.mjs (CPU profile while a turn streams)

Updates profile-typing-lag.md with the streaming numbers and confirms
the Enter→paint submit path is already fast (≤320ms on the populated
session; the 2s "stall after Enter" the user noticed once was a
one-time cold-start, not reproducible at the UI layer).

I'd guess the felt jank in real use is fast-burst typing during a
long-form streaming reply (code blocks + markdown lists multiply the
per-token render cost). The CPU savings here scale linearly with
token volume.

* chore(desktop): drop diag scratch scripts no longer needed

* docs(desktop): correct leak-typing numbers on a real session

Re-ran the leak harness on a populated session (Phaser thread) for both
unpatched and patched builds. The original 'listener leak' was transient
warm-up cost, not a steady-state leak — both versions show 0 listener
growth/round in steady state.

The load-bearing number is forced layouts per character:
  unpatched (HEAD~2):  7.02 layouts/char
  patched   (HEAD):    2.35 layouts/char  (3× fewer)

The patches reduce per-char forced-layout work to Blink's natural floor.
Document node count and heap are flat in both builds.

* perf(desktop): fix "Enter jumps up" on long threads

User reported: after pressing Enter on a long thread, the view jumps up
— the just-submitted message disappears below the fold. Confirmed via
apps/desktop/scripts/measure-jump.mjs:

  before:  distFromBottom 0 → 49.5px, sticks there permanently
  after:   distFromBottom 0 → ~0 (worst case 4px for one frame)

Root cause in useThreadScrollAnchor (thread-virtualizer.tsx):

1. The sticky-bottom logic disarmed on any scroll event where
   `scrollTop < lastTopRef.current`. That check can't distinguish a
   user scrolling up from a programmatic `pinToBottom` write that
   the browser clamped short of bottom (because content also grew in
   the same frame, so `scrollTop = scrollHeight` lands at
   `scrollHeight - clientHeight` for the OLD scrollHeight, which is
   now below the NEW scrollHeight). Result: sticky-bottom disarmed
   permanently on the user's first submit.

2. There was no synchronous pin tied to React's commit phase. By the
   time the ResizeObserver fired and re-pinned, the user had already
   seen ~50ms of "message below the fold" — visually that reads as the
   view jumping up.

Fix:

- `programmaticScrollPendingRef` counter tracks scroll events we
  expect to be ours (one per `pinToBottom` write). The scroll handler
  skips the disarm check when consuming a pending tick, keeps the
  arm bit true, and re-pins synchronously if the browser clamped us
  short of bottom. A depth cap (8) breaks runaway loops in
  pathological streaming-burst layouts.

- `useLayoutEffect` on `groupCount` increase pins BEFORE the browser
  paints, eliminating the visible ~50ms window between optimistic
  user-message insert and the RO/scroll-event chain firing.

Verified on the long Cloud Shadows thread (7-8 turns, ~11k px tall):
all three repro runs now hold within 0–4 px of bottom across the
post-Enter transition. Submit latency unchanged (paint 77–107 ms),
streaming-typing latency unchanged.

Also adds three debug harnesses:
  - measure-jump.mjs   — sample thread scroll across Enter
  - probe-thread.mjs   — dump current thread / scroll state
  - diag-jump.mjs      — intercept scrollTop + RO + mutations across Enter

* perf(desktop): rate-limit thread auto-pin during streaming

Follow-up to the Enter-jump fix. The first version did a synchronous
re-pin loop inside the on-scroll handler when the browser clamped our
`scrollTop = scrollHeight` write short of the new bottom; that gave a
tight 4 px visible jump on Enter, but during streaming the
ResizeObserver fires many times per second as content grows, and each
RO callback re-entered the pin loop. CPU profile showed
`Virtualizer.getMaxScrollOffset` climbing to 22 ms self over a typing-
during-streaming window — the sync re-pin path was paying tanstack-
virtual's recompute cost ~3× per token.

Re-architect:

- RO callback coalesces to one pin per animation frame. Streaming-rate
  RO bursts now cost the same as a single per-frame pin.
- The on-scroll programmatic-counter guard remains (it's what prevents
  the false-disarm bug when the browser clamps a write). It no longer
  does sync re-pins; the next RO/rAF will catch up.
- The useLayoutEffect on groupCount (the path that fires on user
  submit / new turn arrival) ALSO schedules one rAF pin in addition to
  the synchronous pin. This catches the case where React mounts the
  new message in a second commit (after our layout effect ran), which
  grows scrollHeight again. Two pins instead of a tight loop, paid only
  once per turn change.

Net effect on the Cloud Shadows long thread:

  enter-jump transient:   12–20 px for 1 frame (was 49 px permanent)
  CPU during stream+type: `getMaxScrollOffset` dropped out of top-5
                          self-time list
  typing-during-stream:   p50 ~10 ms paint, p99 ~20 ms (1 frame),
                          occasional 40 ms+ outliers during burst
                          token arrivals

Also adds scripts/profile-long-stream.mjs: 20-second streaming profile
with per-500ms FPS histogram + content-length tracking, so we can see
whether streaming render cost grows with message length (it doesn't —
sustained 60 fps).

* perf(desktop): use textContent for trigger precondition

Replace composerPlainText() call inside refreshTrigger's no-trigger
fast-bail with a textContent check. textContent is a browser-native
flat traversal; composerPlainText walks recursively with chip-aware
logic. We only need to know if @ or / appears; either way the trigger
char will be in textContent because chips contain @ in their refText.

Profile shows composerPlainText was ~18ms self over a 12s typing-during-
stream window, called from refreshTrigger on every keystroke. Most of
that was the precondition check (the trigger detection path is the
slow path but only runs when a trigger char is present).

* Revert "perf(desktop): use textContent for trigger precondition"

This reverts commit a6a78ff08a.

* Revert "perf(desktop): cut FadeText forced layouts during streaming"

This reverts commit 88e7d7537c.

* Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer"

This reverts commit bff1b3261d.

* Revert "Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer""

This reverts commit b7b378e3a4.

* Revert "Revert "perf(desktop): use textContent for trigger precondition""

This reverts commit 0739588f48.

* chore(desktop): synthetic-stream perf harness + scripts

Drops the React `<Profiler>` approach (no-op because Vite is currently
serving the production React build) in favor of an externally-observable
measurement stack: rAF frame intervals, `PerformanceObserver({entryTypes:
['longtask']})`, and a `MutationObserver` on the live streaming message.

Adds a synthetic stream driver — `window.__PERF_DRIVE__.stream({...})` —
that pushes tokens through the live `$messages` atom at a controlled rate,
so the assistant-ui runtime, incremental repository, and Streamdown
markdown pipeline see the same workload they'd see during a real LLM
stream, without the LLM cost.

The driver lives in `src/app/chat/perf-probe.tsx`; `main.tsx` side-imports
it under `import.meta.env.MODE !== 'production'` so it tree-shakes out of
prod builds. (Using `MODE` rather than `DEV` because our Vite setup
currently reports `DEV=false` even under `vite dev` — see the dev-build
note in `profile-typing-lag.md`.)

Scripts:
  - measure-synthetic-stream.mjs  drive synthetic + record frame/longtask/mutation
  - profile-synth-stream.mjs      CPU profile + top self-time during synthetic
  - measure-real-stream.mjs       same harness, real LLM stream
  - profile-real-stream.mjs       CPU profile bracketing the real stream window
  - eval.mjs / reload.mjs         small CDP helpers

A real-LLM measurement on Cloud Shadows (gpt-4o-mini, 39 s window) showed
12 longtasks in the same 75-127 ms range the synthetic predicted, so the
synthetic is a faithful proxy.

* perf(desktop): memo FadeText so it skips re-renders when text unchanged

FadeText is used 110+ times inside `tool-fallback.tsx` on a tool-heavy
thread. During streaming each parent re-render previously triggered the
component's `useEffect([children])`, which forced a `scrollWidth` layout
read even when the title text was unchanged. The `useResizeObserver` was
already covering the genuine resize case, so that effect was strictly
redundant work.

Drops the effect and wraps the component in `React.memo` with a custom
comparator that field-compares `className`, `fadeWidth`, and `style`,
plus identity-compares `children` (scalar fast-path; correct for JSX
nodes too since a new node should force a re-render).

Verified via temporary render counter on the 34 MB
`session_20260514_215353_fe0ac8` thread (110 FadeText instances): a
2 s synthetic stream went from ~11k FadeText render calls to 122 —
roughly one render per truly-new instance instead of one per parent
commit per instance.

Doesn't move the longtask needle on its own (Streamdown's markdown
re-parse dwarfs it) but eliminates a steady CPU floor and a class of
forced layouts during streaming. Profile-typing-lag.md documents the
full investigation, including the remaining Streamdown cost as the
real source of the perceived "5 fps moment" hitches.

* perf(desktop): memoize MarkdownText plugins to stop churning Streamdown

The inline `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
on `<StreamdownTextPrimitive>` constructed a new object literal on every
parent render. That broke `<Streamdown>`'s outer memo and forced its
internal `rehypePlugins` / `remarkPlugins` array useMemos to rebuild,
which propagates a new identity into every `<Block>` and defeats Block's
memoization for stable historical blocks.

After memoizing on `[isStreaming]` (the only real dimension of variance),
CPU profile during a 5 s synthetic stream on the 34 MB session shows
`parser` self-time dropping out of the top 10, `compile` cut roughly in
half, and `bn$1` / `m$1` (micromark internals) leaving the top entries.

Doesn't move the visible longtask count on its own — Streamdown's
per-Block parse cost still dominates whenever the last block's content
changes — but it removes a class of unnecessary re-parses for historical
blocks during streaming. See `scripts/profile-typing-lag.md` for the
full investigation.

* perf(desktop): floor assistant-text flush gap to 33ms for predictable batching

`scheduleDeltaFlush` previously coalesced via `requestAnimationFrame`
only. The "at most one flush per frame" guarantee that gives you is fine
for fast streams (>~80 tok/sec) where multiple tokens arrive within a
single frame, but breaks down at typical LLM token rates (30-80 tok/sec)
where each token arrives slower than the rAF cadence and triggers its
own React commit + Streamdown markdown re-parse.

Track `lastFlushAt` and require at least 33 ms between two flushes.
React 18+ auto-batching probabilistically already collapsed some of
these, but the floor makes it deterministic.

A/B on the 34 MB session, 300 tokens at 50 tok/sec (markdown chunks):

| | avgFps | p99 frame | LTs / 5 s | max LT |
|---|---|---|---|---|
| no floor (current rAF) | 54.0 | 38 ms | 2.0 | 145 ms |
| 33 ms floor (this PR) | 54.3 | 41 ms | 1.7 | 110 ms |

`inter-mutation` p50 also tightens from 22-28 ms to a clean 33 ms,
which is the expected signature of a deterministic floor. Doesn't fully
solve the user's perceived hitches — Streamdown's per-Block parse cost
when the last block grows past ~2 k chars is still the elephant — but
it consistently shaves the worst-case longtask and makes the streaming
cadence visibly steadier.

Also threads a matching `flushMinMs` option through the synthetic
stream driver in `perf-probe.tsx` + `scripts/measure-synthetic-stream.mjs`
so the harness can A/B both regimes without spending LLM credits.

See `scripts/profile-typing-lag.md` for the full investigation.

* perf(desktop): useDeferredValue for streaming markdown so parses don't block input

Streamdown's per-Block parse cost grows with the live tail's length and
is unavoidable inside the block-memo pattern (industry standard, see
findings doc). The fix is to stop having that work block the main thread.

`<DeferStreamingText>` is a 12-line wrapper that reads message-part state
via `useMessagePartText`, runs it through `useDeferredValue`, and
re-publishes via assistant-ui's `<TextMessagePartProvider>`. The inner
`<StreamdownTextPrimitive>` reads the deferred value through the normal
`useMessagePartText` hook — no fork, no internal-path imports, fully on
assistant-ui's public API. React's concurrent scheduler then:

  - abandons in-flight deferred renders when a newer token arrives, so
    intermediate states get skipped under fast streams
  - deprioritises the markdown render when the main thread has urgent
    work (typing, scroll), so input stays responsive even while a
    100ms parse is queued

Streamdown already uses `useTransition` for its block-array setState;
this lifts the deferral up to the consumer boundary so it covers the
whole pipeline (preprocess → split → repair → parse → render).

A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
(four trials each, with the 33ms flush throttle on for both):

| | avgFps | p99 frame | LTs/5s | max LT | typing-while-stream p95 |
|---|---|---|---|---|---|
| pre  | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
| post | 58.5 | 31 ms | 2.0 | 117 ms | 14-18 ms |

Longtask count + max LT unchanged — useDeferredValue doesn't reduce
CPU, only its priority. The avgFps lift and p99 frame drop are the
proof that the existing CPU is no longer blocking 60 fps cadence. One
clean run logged MUTATIONS=0 — React skipped every intermediate text
state and only committed the final one (textbook deferred-value
behaviour).

The actually-reduce-CPU path is replacing the parser with a state
machine like Flowdown — left for a future PR; see
`apps/desktop/scripts/profile-typing-lag.md` for the full investigation.

* feat(desktop): add hermes gui launcher

* feat(desktop): launch packaged gui builds by default

* bump gui version to 0.0.2

* fix(dashboard): allow file:// origin on loopback WS + diagnostic logging

Upstream commit 2e66eefbc ("fix(dashboard): validate WebSocket Host
and Origin") added a WebSocket Host/Origin guard to block DNS
rebinding against the dashboard.  The guard rejects any Origin whose
scheme is not http/https or whose netloc is empty — which includes
Electron's renderer Origin: file:// when the desktop app loads its
bundle from disk in production mode.

That makes the bb/gui Electron desktop unable to open the gateway
WebSocket against the embedded backend on Windows / macOS prod
builds.  The renderer reports "Desktop boot failed" and the backend
logs:

  WARNING hermes_cli.web_server: gateway-ws reject
      peer=127.0.0.1:NNNN reason=non_loopback_or_bad_origin
      bound_host=127.0.0.1 close_code=4403

DNS-rebinding requires a DNS-resolvable hostname; file:// has no
host component and therefore cannot be the attack vector this guard
exists to block.  When bound to a loopback interface (127.0.0.1 /
::1 / localhost), accept file:// origins so desktop wrappers can
attach.  Non-loopback binds (operator opted into network exposure)
keep rejecting file:// — the loose policy doesn't apply.

Also adds per-reason diagnostic logging in
_ws_host_origin_is_allowed, so future ws-guard rejections name the
specific clause that fired (bad_host / bad_origin_scheme /
origin_host_mismatch) instead of the opaque
"non_loopback_or_bad_origin" surfaced at the call site.

Verified against tests/hermes_cli/test_web_server_host_header.py
(all 11 upstream tests still pass) and hand-tested by opening the
bb/gui Electron desktop dev build against the patched backend.

* fix(tui_gateway): restore _content_display_text helper

Bb/gui had dropped the helper but the orchestrator code merged from main
still calls it (_inflight_text, _message_preview). Re-add the definition
verbatim from main so session.create / _start_inflight_turn don't crash
with NameError on first prompt submit.

* fix(tui-gateway): restore _content_display_text helper lost in main merge

The May 27 merge of origin/main into bb/gui re-introduced two callers of
_content_display_text (in _inflight_text and _history_to_messages) but
dropped the helper definition itself, leaving an unresolved reference.

NameError fires on every user message via _start_inflight_turn ->
_inflight_text, taking down both the TUI and the desktop (which share
this gateway backend) the moment input is dispatched.

Restores the helper verbatim from main (commit 36c99af37) -- pure
structured-content text extractor, no other dependencies.

* fix(telegram): import Set for _dm_topic_chat_ids annotation

self._dm_topic_chat_ids: Set[str] = {...} at line 460 references Set
but only Dict, List, Optional, Any are imported from typing. The file
has no 'from __future__ import annotations', so the annotation is
evaluated at runtime and raises NameError on TelegramAdapter
construction.

* fix(setup): drop shadowing inner importlib.util re-imports

_print_setup_summary and _setup_tts_provider each had 'import
importlib.util' inside a try: block nested deeper in the function
body. Python flips importlib to function-local for the whole scope,
so earlier references in the same function (the neutts branches at
lines 493 / 1109) hit UnboundLocalError before the late import can
run.

The top-of-module 'import importlib.util' at line 14 already covers
both call sites, so dropping the redundant inner imports restores
the intended behavior.

* feat(install.ps1): add -IncludeDesktop switch + Stage-Desktop

The new Hermes-Setup.exe (Tauri bootstrap installer) passes -IncludeDesktop
so users who install via the GUI end up with a launchable Hermes.exe at
apps/desktop/release/<os>-unpacked/. Existing flows are unchanged:

  * The 'irm install.ps1 | iex' CLI one-liner omits the flag — terminal
    users don't need a prebuilt desktop binary; 'hermes desktop' builds
    on demand.
  * The Electron desktop's bootstrap-runner.cjs also omits the flag —
    rebuilding apps/desktop from inside a running Hermes.exe would try
    to overwrite the live binary on disk and fail.

Stage-Desktop runs after Stage-NodeDeps so workspace npm is already
installed when electron-builder fires. It does:
  1. 'npm install' at repo root so apps/* workspaces resolve their deps
     (Electron itself arrives via npm here, ~150MB)
  2. 'npm run pack' in apps/desktop (tsc + vite + electron-builder --dir)
  3. Probes apps/desktop/release/{win-unpacked,win-arm64-unpacked}/Hermes.exe

The --dir mode produces an unpacked launchable binary without an NSIS/MSI
installer artifact — we don't need one because Hermes-Setup.exe spawns the
unpacked binary directly via launch_hermes_desktop.

* feat(installer): Tauri bootstrap installer for first-time onboarding

Hermes-Setup.exe is a small signed Rust+Tauri binary that drives
scripts/install.ps1 stage-by-stage with a native UI matching the
desktop's design language. Replaces the chicken-and-egg pattern of
shipping a 200MB Electron app whose first launch existed only to
run install.ps1.

The architecture:

  Rust backend (src-tauri/):
    bootstrap.rs        orchestrator -- Tauri commands, stage iteration
    install_script.rs   resolve install.ps1 (dev checkout, cache, GitHub raw)
    powershell.rs       spawn powershell, line-stream stdout/stderr, parse JSON
    events.rs           BootstrapEvent types -- mirror bootstrap-runner.cjs
    paths.rs            HERMES_HOME resolution + tracing log setup
    build.rs            bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from
                        'git rev-parse HEAD' at compile time

  React frontend (src/):
    Tauri webview rendering 4 screens (welcome / progress / success /
    failure), driven by nanostores subscribing to the Rust event stream.
    Visual layer reuses the desktop's styles.css wholesale via @import
    so the installer and desktop never drift visually.

  Distribution:
    targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The
    raw target/release/Hermes-Setup.exe IS the artifact on Windows;
    .dmg + .app on macOS; AppImage on Linux. One file, double-click,
    no installer-installing-an-installer pattern.

  Compile-time pinning:
    build.rs reads 'git rev-parse HEAD' and emits
    cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>.
    bootstrap.rs's option_env!() picks these up so the binary fetches
    install.ps1 from the exact SHA it was tested against. CI / release
    builds can override via HERMES_BUILD_PIN_COMMIT env var.

  Windows manifest:
    hermes-setup.manifest declares level='asInvoker' so the
    productName 'Hermes Setup' doesn't trip Windows's installer-
    detection heuristic and refuse to launch without elevation.
    Also declares PerMonitorV2 DPI + UTF-8 active code page + Common
    Controls v6.

Limitations of this initial version:

  * No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe
    ('More info -> Run anyway'). The downstream binaries it produces
    (Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and
    therefore don't carry MOTW, so they launch without SmartScreen
    intervention. Cert procurement tracked separately.

  * macOS and Linux build paths defined but untested -- Windows-only V1.

* fix(installer): pass -IncludeDesktop to manifest, surface launch errors, alias hermes desktop

Three bugs found in the first VM end-to-end test:

1. install.ps1 -Manifest was called WITHOUT -IncludeDesktop, so the
   manifest came back with the 14-stage list (no desktop stage), the
   UI showed '14 steps' and Stage-Desktop never ran. Pass the flag to
   both the manifest fetch and the per-stage runs — install.ps1 gates
   the desktop stage's inclusion on the flag.

2. The Success screen's Launch button silently swallowed the Tauri
   error when no Hermes.exe existed (e.g. Stage-Desktop was skipped).
   Wire the error through to inline UI with an alert callout, so the
   user gets actionable text ('Hermes.exe missing, run hermes desktop
   from a terminal') instead of an unresponsive button.

3. The Success screen tells users to run 'hermes desktop' from a
   terminal but the CLI only accepted 'hermes gui' — invalid choice
   for 'desktop'. Rename the subcommand canonically to 'desktop' with
   'gui' as a backwards-compatible alias. Update the _SUBCOMMANDS sets
   used by session-flag arg parsing + logging-mode probe so both names
   route to the same logic.

* fix(install.ps1): pre-warm electron-builder winCodeSign cache + fix Stage-Desktop $HasNode false-skip

Two bugs caught in the second VM end-to-end run:

1. electron-builder's winCodeSign extraction fails on grandma-class
   Windows boxes because the .7z archive contains macOS symlinks
   (darwin/10.12/lib/libcrypto.dylib and libssl.dylib pointing at
   versioned siblings). Creating symlinks on Windows requires
   SeCreateSymbolicLinkPrivilege, a per-user right that non-admin
   accounts don't have on stock Windows. Result: every fresh install
   on a non-admin user fails Stage-Desktop with a 7-Zip 'cannot create
   symbolic link' error, retried four times, then bails.

   Fix: Initialize-ElectronBuilderCache pre-extracts winCodeSign-2.6.0.7z
   ourselves with -snl (don't preserve symlinks, store as resolved file
   content) AND -x!darwin (skip the entire macOS subtree — irrelevant
   on Windows). Writes to electron-builder's expected cache dir before
   electron-builder gets a chance to try its own broken extraction.
   Idempotent — fast-paths via signtool.exe sentinel check.

2. Install-Desktop's first guard was 'if (-not $HasNode) skip'.
   $HasNode is set by Stage-Node into $script:HasNode, but in
   cross-process driver mode (each -Stage NAME is a fresh powershell.exe
   spawned by Hermes-Setup.exe), that script-scope variable from the
   PREVIOUS process is invisible — so the guard always fired and
   Install-Desktop returned in 900ms with a misleading
   'Node.js not available' reason. The real npm probe below it never
   got to run. Fix: re-probe npm directly via Get-Command when $HasNode
   is empty/false, since by that point Stage-Node has already verified
   Node is installed and the only question is whether *this* process
   can see it on PATH (it can — installer-wide PATH update from Stage-Node).

* fix(install.ps1): tell electron-builder we're NOT signing instead of pre-extracting winCodeSign

The previous commit (c7e46f9f3) worked around the winCodeSign-symlinks-
on-Windows extraction crash by pre-extracting the archive ourselves with
-snl + -x!darwin. That fix was correct but addressed the wrong layer.

The deeper question: why was electron-builder fetching winCodeSign at all
when we have no signing cert configured? Answer: electron-builder
unconditionally pre-warms the toolchain assuming any build MIGHT sign.
The cert auto-discovery never finds anything (we never set CSC_LINK
or anything else), so the signing never happens — but the 100MB fetch
of winCodeSign and its broken-on-Windows symlink extraction does.

Set CSC_IDENTITY_AUTO_DISCOVERY=false (with WIN_CSC_LINK and
WIN_CSC_KEY_PASSWORD also explicitly cleared as belt-and-suspenders)
before invoking npm run pack, and electron-builder skips the entire
winCodeSign apparatus. No download, no extraction, no privilege check.
Env vars are saved/restored around the invocation so we don't leak
the override into Stage-PlatformSdks etc.

Net: removes the 100-line Initialize-ElectronBuilderCache helper that
manually downloaded + extracted winCodeSign-2.6.0.7z. Replaced with
3 env-var assignments. The produced Hermes.exe is functionally
identical — just no longer carries a code-signing-machinery dependency
we never used.

* fix(installer): bump bootstrap-installer.log to capture stage transitions + every install.ps1 line

Diagnosing the second VM failure was impossible because bootstrap-installer.log
contained only the 'starting' banner. Two causes:

1. emit_log() inside run_bootstrap() was tracing::debug! — dropped on the
   floor under the default INFO env-filter.

2. The per-stage sink callbacks (on_stdout_line / on_stderr_line) only
   emitted Tauri events to the frontend; they never tee'd to the log file
   at all. When the failure route mounts, the Tauri event stream is the
   only place the script output lived, and it gets discarded.

3. The Failed / Stage / Manifest / Complete lifecycle frames in emit_event()
   were also Tauri-only — so even the 'which stage failed' frame never
   reached the log.

Fixes:
  * emit_log() → tracing::info!
  * Sink callbacks tee stdout to info!, stderr to warn!, with stage label
    as a structured field for grep'ability
  * emit_event() now matches on the variant and logs each lifecycle frame
    at the right level: Failed → tracing::error!, others → info!

Result: a failing install leaves a complete forensic trail in
bootstrap-installer.log — manifest stage list, every install.ps1
stdout/stderr line tagged by stage, the stage transitions, and the
final error. Same path as before so nothing the user does changes.

* fix(install.ps1): Stage-NodeDeps cross-process $HasNode + stream npm install output to bootstrap log

VM run 3 diagnosis: node-deps stage skipped on the VM (logged
'Skipping Node.js dependencies (Node not installed)') and then
desktop's npm install failed with exit 1 and zero diagnostic detail.

Two root causes:

1. $HasNode false-skip in Stage-NodeDeps — same cross-process bug
   pattern we fixed for Stage-Desktop in c7e46f9f3. Stage-Node ran
   in process A and set $script:HasNode = $true, then exited. Stage-
   NodeDeps ran in fresh process B (Hermes-Setup.exe -Stage NAME
   spawns each stage independently), where that variable doesn't
   exist. Re-probe via Get-Command npm instead of trusting the
   stale script-scope global. The previous stage already verified
   Node so the re-probe succeeds.

2. npm install --silent + Tee to TEMP file hid the real error.
   When the workspace install failed on the VM, the actual reason
   was buffered in $env:TEMP\hermes-npm-desktop-install-*.log and
   the user saw only 'exit 1'. Drop --silent so npm streams its
   full output, drop the TEMP-file dance — the Tauri installer's
   streaming sink already tees every stdout/stderr line to the
   rolling bootstrap-installer.log, so a side log file is dead
   weight that hides the very error we need.

After this, the bootstrap log on a failure will contain npm's full
output (deprecation warnings, ETARGET, native-module compile errors,
whatever) tagged with stage=desktop, making the actual cause
diagnosable instead of an opaque exit code.

* fix(install.ps1): restore Initialize-ElectronBuilderCache (CSC env vars alone aren't enough)

VM run 4 diagnosis: even with CSC_IDENTITY_AUTO_DISCOVERY=false set,
electron-builder still fetches winCodeSign and signs bundled binaries.
The log shows the signing happens BEFORE the cache extraction:

  • signing with signtool.exe  ...\winpty-agent.exe
  • signing with signtool.exe  ...\OpenConsole.exe
  • downloading winCodeSign-2.6.0.7z
  • <symlink privilege error>

Cause: node-pty's bundled prebuilds are listed in apps/desktop's
asarUnpack ['**/*.node', '**/prebuilds/**']. electron-builder
re-signs anything unpacked from asar, regardless of whether OUR
binary gets signed. The signtool invocation needs winCodeSign on
disk, which needs the .7z extracted, which hits the macOS-symlink
crash on non-admin Windows.

The CSC env vars I added in d5fe46727 only kill IDENTITY DISCOVERY
(so OUR Hermes.exe stays unsigned, which is fine — we have no cert).
They don't prevent the toolchain fetch for the bundled-prebuild
re-sign. I removed the pre-extract in d5fe46727 thinking the env
vars subsumed it; that was wrong. Both are needed.

Restoring Initialize-ElectronBuilderCache verbatim from c7e46f9f3
and keeping the CSC env vars. Wrote a clearer doc-comment at the
call site explaining the two-knob interaction so future maintainers
don't drop one half again.

* fix(desktop): disable signtool via signtoolOptions.sign=null, drop dead winCodeSign pre-extract

VM run 5 diagnosis: the pre-extract from 3b29e65c1 ran (extracted 83
files, 24MB) but produced ZERO files at the expected sentinel path
'/winCodeSign-2.6.0/windows-10/x64/signtool.exe'.

Cause: the .7z archive's root entries are 'windows-10/', 'darwin/',
'linux/', etc. — not 'winCodeSign-2.6.0/<arch>'. Extracting with
'-o$cacheRoot' put files at $cacheRoot/windows-10/..., NOT at
$cacheRoot/winCodeSign-2.6.0/windows-10/.... I had the directory
nesting wrong from the start.

And then we observed: electron-builder downloads winCodeSign-2.6.0.7z
under a random numeric filename ('384387955.7z') regardless of what's
already extracted in the parent dir. The cache key isn't the dirname;
it's content-addressed. So the pre-extract approach was doomed even
if the path nesting had been right.

Actual fix: signtoolOptions.sign=null in apps/desktop/package.json's
win build config. electron-builder honors this and skips the bundled-
prebuild signing entirely — no signtool invocation, no winCodeSign
fetch, no symlink-privilege crash. The previous failures all stemmed
from electron-builder pre-signing node-pty's bundled .exes
(winpty-agent.exe, OpenConsole.exe) which are already author-signed
upstream; re-signing with our nonexistent cert was overwriting good
sigs with nothing useful anyway.

Cost: when we DO get a real cert later, we'll add it back with the
sign function pointing at the cert chain. Until then, all-null is
the correct config and unblocks every non-admin Windows user.

Removed Initialize-ElectronBuilderCache (the dead pre-extract).
Removed the call site. Kept the CSC_IDENTITY_AUTO_DISCOVERY env
vars as belt-and-suspenders against a future electron-builder
change that might revive cert auto-discovery.

* fix(desktop): use no-op sign function instead of sign=null

VM run 6 still hit the symlink crash even with signtoolOptions.sign=null.
electron-builder 26.8.1 treats null as 'use the default signtool path'
rather than 'skip signing', so the winCodeSign fetch + extraction still
fired for the bundled prebuild re-sign.

The Electron docs (electronjs.org/docs/latest/tutorial/code-signing)
make it clear signing is OPTIONAL and unsigned apps work fine — users
just see SmartScreen on first launch. The electron-builder mechanism
for 'don't actually sign anything' is to supply a custom sign function
(via signtoolOptions.sign: '<path-to-cjs-module>') that resolves
without invoking signtool.

build-noop-sign.cjs is that module — a 5-line async function that
returns undefined. electron-builder calls it for every binary it would
have signed, gets back a resolved promise, and considers each binary
'signed.' No signtool spawn, no winCodeSign fetch, no symlink crash.

When Nous's cert arrives, replace this file with a real signing hook
(@electron/windows-sign-based or a direct signtool invocation). The
architecture's signing-ready and the cutover is a one-file edit.

* fix(desktop): signAndEditExecutable=false to skip signtool path entirely

After reading app-builder-lib/winPackager.js line 216 + 231 directly:
signAndEditExecutable is the ACTUAL hardcoded gate that short-circuits
both signApp() (which signs Hermes.exe + every shouldSignFile match
including bundled prebuilds) AND createTransformerForExtraFiles().
None of signtoolOptions.sign / sign:null / sign:<custom-fn> gate the
winCodeSign download — that happens before they're consulted.

What we lose: rcedit also runs through signAndEditResources, so
disabling this drops PE metadata (file properties showing 'Hermes' /
'Nous Research' / file description). Cost is real but bounded:
  * Hermes.exe filename, icon, asar contents, app identity intact
  * Task Manager shows 'Hermes.exe' (the filename) not 'Hermes' (PE
    description) — minor downgrade
  * Start menu, taskbar, window title all work normally
  * SmartScreen will warn once (unsigned, same as before)

When the cert lands, flip signAndEditExecutable back to default true,
both signing AND rcedit return, PE metadata is restored.

Removes the no-op sign function (build-noop-sign.cjs) since
signAndEditExecutable=false prevents signtool from being invoked at
all — the custom hook never gets called either.

* feat(install.ps1): write .hermes-bootstrap-complete marker at end of install

The desktop app's main.cjs resolver ladder has a 'bootstrap-needed' rung
that fires when .hermes-bootstrap-complete is missing from
ACTIVE_HERMES_ROOT. Pre-Hermes-Setup, this marker was written by the
packaged-desktop's own bootstrap-runner.cjs at the end of its install
flow. Now that Hermes-Setup.exe runs install.ps1 directly, install.ps1
needs to own the marker — otherwise the desktop sees no marker on first
launch and triggers its legacy first-launch bootstrap (re-running
install.ps1 from inside Electron, the exact recursion Hermes-Setup.exe
was supposed to obviate).

Implementation:
  * New Stage-BootstrapMarker (worker) → Write-BootstrapMarker (helper)
  * Slotted in the manifest right after platform-sdks, before the
    interactive configure/gateway stages, so it runs unconditionally
    when the install reaches the finalize phase
  * Schema mirrors apps/desktop/electron/main.cjs writeBootstrapMarker /
    isBootstrapComplete EXACTLY: {schemaVersion: 1, pinnedCommit,
    pinnedBranch, completedAt}. Schema version stays at 1 so old
    desktops that read marker files written by future install.ps1s
    can still parse them.
  * pinnedCommit comes from -Commit flag (Hermes-Setup.exe passes it)
    or falls back to 'git rev-parse HEAD' in InstallDir
  * pinnedBranch from -Branch flag, defaults to 'main' matching
    install.ps1's own param default

Two PS-5.1 gotchas baked into comments:
  * The ?. null-conditional operator doesn't exist pre-PS7; use
    explicit if-checks on Get-Command results
  * Set-Content -Encoding UTF8 emits a BOM in 5.1 and Node's plain
    JSON.parse rejects BOM — write via .NET's UTF8Encoding(false)
    to produce BOM-less JSON the desktop's readJson() can parse

* feat(installer): drive in-app updates through the Tauri installer

Converge update on the same principle as bootstrap: one driver owns all
repo mutation. The desktop becomes a pure consumer that hands off to
Hermes-Setup.exe --update instead of re-implementing git/pip in Electron.

- hermes desktop --build-only: build without launching, so the installer
  owns the post-update launch (CLI keeps build logic single-sourced).
- Installer AppMode {Install,Update} from argv; get_mode exposed to the UI.
- Installer self-copies to HERMES_HOME/hermes-setup.exe on install success
  (no-op guard during --update re-invocation to avoid the locked-exe copy).
- Installer --update flow (update.rs): wait for the desktop to release the
  venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other),
  then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses
  the bootstrap event channel + progress UI via a synthetic two-stage manifest.
- Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip
  removed) -> thin handoff: spawn updater, app.quit() to free the shim.
  Detection (checkUpdates, commit changelog, behind-count) kept intact.
- install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe
  (never bare 'hermes desktop', which would rebuild every launch).

* test update

* fix(installer): pass --branch to hermes update in the --update flow

The install is a detached-HEAD checkout of a pinned commit. Without
--branch, 'hermes update' fell back to its default (main) and switched
the checkout to main — a divergent branch that lacks the desktop CLI
command — so the update targeted the wrong branch and the rebuild stage
failed with 'invalid choice: desktop'.

Thread BUILD_PIN_BRANCH (the branch this installer was built against,
and the same branch the desktop detected the update on) into
'hermes update --branch <b>' so update + rebuild stay on-branch.

* test update

* fix(installer): stamp Hermes icon onto Hermes.exe via rcedit (no winCodeSign)

The unpacked Hermes.exe showed the stock Electron icon + name in the
taskbar because build.win.signAndEditExecutable=false disables BOTH
electron-builder's signing AND its rcedit metadata/icon stamping. That
flag is load-bearing: enabling it re-triggers signtool -> winCodeSign,
whose macOS symlinks crash 7-Zip on non-admin Windows (unfixable dead end).

Decouple identity-stamping from signing entirely: after npm run pack,
run rcedit ourselves on the produced exe.
- Add rcedit as a direct devDependency of apps/desktop (the transitive
  electron-winstaller copy is fragile).
- apps/desktop/scripts/set-exe-identity.cjs: Node helper that calls
  rcedit's named export to set icon + ProductName/FileDescription/
  CompanyName. Node builds argv natively — avoids the PowerShell->exe
  ->JSON double-escaping that broke the app-builder rcedit path.
- install.ps1 Set-DesktopExeIdentity invokes the script after the build,
  before shortcuts. Best-effort: failure keeps the stock icon, never
  fails the install. rcedit is a pure PE editor — no signtool, no
  winCodeSign, no symlinks.

Verified locally: stamping a copy of the built Hermes.exe embeds the
32x32 icon and sets ProductName=Hermes.

Also fix update-path success-screen flash: in update mode the installer
hands off + exits in ~600ms, so don't route to the 'launch Hermes'
success view (it flashed before the window closed).

* update test

* fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error

A user who installed via the CLI (irm|iex / install.sh) then ran
`hermes desktop` has no staged hermes-setup.exe, so clicking Update
in-app hit resolveUpdaterBinary()=null and showed a misleading error
('re-run the Hermes installer') with a Try-again button that could
never succeed — a dead loop for a perfectly valid install.

Treat the no-updater case as an intentional outcome, not a failure:
- main.cjs applyUpdates returns { ok:true, manual:true, command:'hermes update' }
  (no throw, no 'error' stage) when no updater binary exists.
- New 'manual' update stage + apply-state.command thread the command to the UI.
- updates-overlay ManualView: a polished terminal-native card with the
  exact command and a copy button, framed as the correct path for a CLI
  user rather than an error.

GUI-installer users are unaffected — hermes-setup.exe present => seamless
auto-update runs as before. Zero new process orchestration; can't fail
the update demo.

* update test

* fix(gui): pin /api/hermes/update to the current branch

The desktop command-center 'update' action hits POST /api/hermes/update,
which spawned bare `hermes update` with no --branch. cmd_update then
falls back to its default (main) and checks the working tree OUT of the
tracked branch — a bb/gui install silently jumped to main and lost the
desktop CLI.

Resolve the checkout's current branch and pass --branch <current> from
this endpoint only. The engine default (main) is DELIBERATELY unchanged:
bare `hermes update` from a terminal, the gateway /update bot command,
and the CLI/TUI relaunch path all keep their long-standing 'update against
main' contract for the existing user base. Only the GUI button is scoped
to update-the-branch-you're-on. Detached HEAD / git failure falls back to
the bare default.

* update test

* fix(desktop): branch-pin the CLI manual-update command card

The 'Update from your terminal' card (shown to CLI installs with no staged
updater) hardcoded bare `hermes update` — which defaults to main and would
switch a bb/gui (or any non-main) checkout off-branch. Same bug we fixed for
the GUI button, leaked into the card's copy text.

Resolve the checkout's current branch and show `hermes update --branch
<current>` for non-main checkouts; keep it bare for main so the card stays
clean. Best-effort: bare fallback if branch detection fails. Matches the
GUI button + installer --update contract; bare terminal/bot/TUI update
paths still default to main, unchanged.

* docs: phragg was here

* feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970)

- Feature Nous Portal as the primary onboarding card (Recommended tag,
  app logo, single pitch line); collapse other OAuth providers behind an
  "Other providers" disclosure whose open/closed state persists.
- Surface OpenRouter as a one-click API-key option inside the disclosure;
  move "I have an API key" to a quiet bottom-right link.
- Treat "no provider configured" as a normal onboarding state, not a red
  error banner (provider-setup-errors copy match).
- Fix setup.runtime_check: it reported ready when the resolved runtime had
  an empty credential or only implicit Bedrock/IAM, so fresh installs never
  saw onboarding. Now requires a usable credential.
- Auto-wire Windows fonts for WSL2 users so the renderer renders real
  Segoe UI instead of the DejaVu fallback; make WSL detection env-independent
  via the /proc kernel marker.

* feat(desktop): live elapsed timer on install bootstrap steps

The first-launch install overlay showed a static "Installing" with no
motion, so long steps (notably the repo clone) looked frozen. Stamp each
stage's start time on the running transition and tick once a second so the
active step shows live elapsed (e.g. "Installing · 1:23"), plus elapsed on
the overall current-step line. Completed steps keep their final duration.

* fix(desktop): resolve PortableGit for update checks + reserve titlebar tools space

- runGit() hardcoded spawn('git'), which ENOENTs on fresh installer-driven
  Windows installs (git is PortableGit under %LOCALAPPDATA%\hermes\git, never
  on PATH) — so "Check for updates" failed with "Couldn't check for updates".
  Add resolveGitBinary() mirroring findGitBash (PortableGit → Git-for-Windows
  → PATH) and use it in runGit.
- PageSearchShell rendered a full-width search input in the titlebar row, so
  on Windows its right edge slid under the fixed top-right tools + native
  window controls. Reserve that footprint via --titlebar-tools-* vars.

* fix(desktop): stop streaming caret from shifting layout on completion

The streaming caret (::after on the running message's last child) was an
in-flow inline-block adding ~0.78em of inline width, which could wrap the
last line mid-stream; when the caret is removed on completion the line
un-wraps and reflows — the visible post-response layout shift. Net-zero its
inline advance with a compensating negative margin so it paints at the text
end without consuming layout width.

* fix(desktop): stop completed-message layout shift while streaming

The assistant message action bar used `hideWhenRunning`, which unmounts it
whenever the thread is streaming. Since the bar reserves vertical space in
each completed assistant message's footer (it's invisible-until-hover via
opacity, not via mount), unmounting it collapsed every prior turn by the
bar's height — then remounting on resolve grew them back, shifting the whole
conversation (visible as "padding appears above the last user message").
Drop hideWhenRunning so the footer height is constant; the bar stays
invisible during streaming via its existing opacity/pointer-events gating.

* fix(merge): keep windows-footgun suppressions inline

* fix(merge): keep remaining gateway footgun suppressions inline

* fix(merge): restore contracts caught by main-target CI

* fix(dashboard): honor injected HERMES_DASHBOARD_SESSION_TOKEN

The desktop shell mints a session token and signs its /api + /api/ws
calls with it via HERMES_DASHBOARD_SESSION_TOKEN, but the main-merge
restored a web_server.py that ignored the env var and minted its own
random _SESSION_TOKEN -- so every desktop request 401'd and the UI
reported "gateway offline". Read the injected token (fall back to a
fresh random one) so loopback HTTP + WS auth line up.

Adds a regression test so a future merge can't silently drop the read.

* fix(desktop): align fresh-install home so upgraders don't brick

Two related first-launch bugs on machines with a legacy ~/.hermes:

- install.ps1 hardcoded $HermesHome/$InstallDir to %LOCALAPPDATA%\hermes
  and ignored the HERMES_HOME the desktop passes through. The desktop
  freezes HERMES_HOME at module load and prefers a legacy ~/.hermes when
  %LOCALAPPDATA%\hermes is absent, so the installer wrote to a different
  home than the shell read -> "Could not connect to Hermes gateway". Honor
  $env:HERMES_HOME in the param defaults.

- isBootstrapComplete() trusted the marker + checkout without verifying a
  runnable venv, so an interrupted/split install spawned a dead backend
  instead of re-bootstrapping. Also require the venv python to exist.

* fix(dashboard): allow packaged desktop file:// origin on loopback WS

The packaged Electron desktop loads its renderer over file://, so its
/api/ws handshake carries Origin: file:// (or null). The DNS-rebinding
WebSocket Origin guard only accepted http(s) origins matching the bound
host, so it rejected the desktop's own renderer with 4403 -> "Could not
connect to Hermes gateway" on macOS.

A browser DNS-rebinding attacker can only ever present an http(s) origin
(the site hosting the malicious page); it cannot forge file://, null, or
a custom app scheme AND hold the loopback session token. So on loopback
binds we now trust non-web origins -- the token in _ws_auth_ok remains
the real authenticator. Public/gated binds still reject them, and
cross-site http(s) origins are still rejected everywhere.

* fix(desktop): resolve renderer assets relative to BASE_URL

Absolute public asset paths (/apple-touch-icon.png, /ds-assets/...) work
under the dev server but break in the packaged app, where the renderer is
loaded from file://.../index.html and a leading slash resolves to the
filesystem root -> broken onboarding provider icon and backdrop image on
macOS. Prefix these with import.meta.env.BASE_URL so they resolve next to
the bundled index.html in both dev and packaged builds.

* feat(desktop): automate first-launch bootstrap on macOS/Linux

Previously a packaged macOS/Linux app with no Hermes install hit a
dead-end ("first-launch install is not yet automated -- run install.sh
manually") because install.sh lacked the staged protocol install.ps1
exposes. Now both platforms bootstrap on first launch with the same
structured, per-step progress UI as Windows.

- install.sh: add --manifest / --stage / --json / --non-interactive plus
  a stage dispatcher (prerequisites, repository, venv, python-deps,
  node-deps, path, config, setup, gateway, complete). User-input stages
  (setup, gateway) are skipped under --non-interactive; the in-app
  onboarding overlay owns API keys/model, matching the Windows flow.
  Each stage runs inside the install dir (its own process) and a new
  --commit flag pins the checkout to the build-stamp SHA.
- bootstrap-runner.cjs: drive the staged manifest/stage/JSON protocol for
  both install.ps1 (PowerShell) and install.sh (bash), selected by
  installer kind; removed the single-blob POSIX shim.
- main.cjs: drop the macOS/Linux unsupported-platform dead-end so the
  bootstrap-needed path runs the installer on every platform.

* fix(dashboard): return 404 JSON for unmatched /api paths instead of SPA HTML

The SPA catch-all (serve_spa) served index.html for any unmatched GET,
including unregistered /api/* endpoints. A missing API route therefore
came back as <!doctype html> with status 200, and JSON clients (the
desktop app's fetchJson) crashed with an opaque
'SyntaxError: Unexpected token <' instead of a clear error.

- web_server.py: unmatched /api or /api/... now returns 404 JSON
  ('No such API endpoint'); non-api paths still serve the SPA for
  client-side routing.
- main.cjs fetchJson: detect an HTML body / text/html content-type on a
  2xx response and reject with a clear message naming the URL, rather
  than a raw JSON.parse SyntaxError. Empty bodies resolve to null;
  malformed JSON reports the URL plus a snippet.

* say 'OS appearance' instead of 'macOS appearance'

* feat(install): add --include-desktop stage + PowerShell-style flags to install.sh

Brings install.sh to parity with install.ps1's bootstrap surface so the
shared Rust/Tauri bootstrapper (apps/bootstrap-installer) can drive a
macOS/Linux install the same way it drives Windows.

- Accept the PowerShell-style aliases the bootstrapper emits to both
  installers: -Commit / -Branch (alongside existing -Manifest / -Stage /
  -Json / -NonInteractive).
- Add --include-desktop / -IncludeDesktop. When set, the manifest gains a
  'desktop' stage (immediately before 'complete'), and a new install_desktop
  runs a root workspace `npm install` + `npm run pack` (electron-builder
  --dir, signing auto-discovery disabled) to produce release/mac*/Hermes.app
  -- mirroring install.ps1's Install-Desktop / Stage-Desktop.
- The flag is opt-in, exactly like Windows: the signed bootstrap installer
  passes it; the Electron app's own first-launch bootstrap and the CLI
  one-liner omit it (building the desktop from inside the running app would
  clobber it).

* fix: tts endpoints

* macOS desktop: install + in-app self-update (#35607)

* fix(installer): align macOS HERMES_HOME with the rest of the stack

paths.rs computed the macOS Hermes home as ~/Library/Application Support/
hermes, but nothing else does: hermes_constants.get_hermes_home() (Python),
scripts/install.sh, and the Electron desktop's resolveHermesHome() all use
~/.hermes on macOS. The drift meant the Tauri installer wrote the install to
one directory and the desktop looked for it in another, so a fresh GUI
install never found its backend (the file's own comment warned this exact
drift would break things). Use ~/.hermes on macOS to match.

* fix(install.sh): always emit a stage result frame on failure

Stage helpers (clone_repo, install_deps, check_python, …) were written for
the monolithic flow and call `exit 1` on failure. Under `--stage`, that
terminated the process before the JSON result frame was printed, so the
installer's parse_stage_result saw "no frame" instead of a clean
{ok:false,...} contract response. Run the stage body in a subshell so an
`exit` only unwinds the subshell and the parent still emits the frame.

* feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1)

install.ps1 downloads PortableGit on Windows, but install.sh just printed a
"please install git" hint and exited — so a fresh Mac with no developer tools
(no Xcode CLT → no git) couldn't get past the clone step. check_git now tries
to install git before bailing:
  - macOS: Homebrew if present (headless), else `xcode-select --install`
    (the CLT prompt also provides the compiler some wheels need), polling for
    git to appear.
  - Linux: apt/dnf/pacman via sudo when available.
Falls back to the manual instructions only if auto-provision fails.

* feat(desktop): in-app GUI+backend self-update on macOS/Linux

On Windows the staged Hermes-Setup binary drives updates (quit → hermes
update → hermes desktop --build-only → relaunch). The mac drag-install has no
such binary, so "Update now" previously just printed `hermes update`.

Since there's no venv-shim file lock on POSIX, the desktop can drive the whole
update itself. applyUpdates now, when no staged updater exists on mac/linux:
  1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps),
  2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the
     Hermes-managed Node + venv on PATH,
  3. spawns a detached swapper that waits for this process to exit, dittos the
     freshly built Hermes.app over the running bundle, clears quarantine, and
     relaunches.
Degrades to "backend updated — restart to load the new GUI" if the rebuild
fails or there's no .app bundle to swap (dev run, Linux AppImage).

* chore: uptick

* chore: uptick

* chore: linux build

* fix(install): detect xcode-select git stub on fresh macOS

* chore: bump

* fix(desktop): repair voice dictation on Windows

Voice dictation was broken on Windows in two ways:

1. Mic access was denied. The Electron permission request handler only
   granted 'media' requests whose details.mediaTypes included 'audio',
   but Chromium on Windows frequently fires the mic request with an empty
   mediaTypes array, so getUserMedia threw NotAllowedError. The handler
   now grants audio-capture when mediaTypes includes 'audio' OR is
   empty/absent, handles the 'audioCapture' permission name, and adds a
   setPermissionCheckHandler (the synchronous path Chromium also consults
   for getUserMedia on Windows). Video is still denied.

2. Transcripts went nowhere. The composer's insertText handler (used by
   dictation and other inserts) only updated the assistant-ui composer
   store via setText, never the contentEditable editor DOM. The
   draft->editor sync effect only re-renders the editor when it is NOT
   focused, and dictation runs while the editor has/regains focus, so the
   transcript was stored but never shown and could not be sent. insertText
   now renders into the editor DOM and places the caret, mirroring
   appendExternalText.

Also hardens fetchJson: a 2xx response with an HTML body (or text/html
content-type) now rejects with a clear message naming the URL instead of
an opaque JSON.parse 'Unexpected token <' error.

* feat(desktop): route Nous subscribers onto the Tool Gateway from the GUI

When the GUI sets the main provider to Nous via POST /api/model/set, call
the same apply_nous_managed_defaults the CLI uses after model selection, so
GUI/onboarding users land on the Nous Tool Gateway the same way CLI users do
— no separate prompt, no duplicated logic.

Purely additive: apply_nous_managed_defaults skips any tool where the user
has a direct key (FIRECRAWL_API_KEY, FAL_KEY, etc.) or explicit config, so it
never overwrites a user's own setup. Only unconfigured tools get routed.

- web_server.py: in set_model_assignment (scope=main, provider=nous), resolve
  enabled toolsets and apply managed defaults; guarded so a Portal hiccup never
  blocks saving the model. Returns routed tools as gateway_tools.
- onboarding.ts: surface a 'Tool Gateway enabled' toast listing routed tools.
- types/hermes.ts: add gateway_tools to ModelAssignmentResponse.
- tests: cover nous-applies, non-nous-skips, and failure-doesnt-block-save.

* feat(desktop): mirror hermes model free/paid curation in GUI onboarding

GUI onboarding picked models[0] from /api/model/options, which ignores the
Nous free/paid tier — a free user could land on a paid default (e.g.
anthropic/claude-opus-4). Now the recommended default mirrors what `hermes
model` does.

- web_server.py: new GET /api/model/recommended-default?provider=<slug>. For
  Nous it runs the same curation as the CLI (get_curated_nous_model_ids +
  pricing + check_nous_free_tier + union_with_portal_{free,paid}_recommendations
  + partition_nous_models_by_tier) so free users get a free model and paid users
  get the curated default. Other providers fall back to the first curated model.
  Never 500s — returns empty model on error so onboarding degrades gracefully.
- hermes.ts: getRecommendedDefaultModel client + RecommendedDefaultModel type.
- onboarding.ts: fetchProviderDefaultModel prefers the recommended endpoint,
  falls back to models[0] when unavailable.
- tests: free-tier picks free model, paid-tier picks curated default, failure
  returns empty without 500.

* feat(desktop): show model pricing + free/paid tier gating in GUI picker

The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid
models on free Nous accounts. The GUI picker showed bare model names. Bring it
to parity across both the model-picker dialog and onboarding confirm card.

Backend:
- inventory.build_models_payload gains a pricing=True flag → _apply_pricing
  enriches each provider row with formatted per-model pricing
  ({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses,
  and for Nous adds free_tier + unavailable_models (paid models a free user
  can't select) via check_nous_free_tier + partition_nous_models_by_tier.
  Best-effort: any pricing/tier failure is swallowed and fails open (no gating).
- /api/model/options and TUI model.options now pass pricing=True so the
  global picker and in-session picker both carry pricing.

Frontend:
- ModelOptionProvider gains pricing/free_tier/unavailable_models; new
  ModelPricing type.
- model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a
  Free tier/Pro badge on the Nous heading, and disables + grays unavailable
  paid models for free users with a 'Pro models need a paid subscription' note.
- onboarding confirm card shows the chosen model's price + tier badge.

Tests: test_inventory_pricing covers price formatting, free-tier gating,
paid no-gating, providers without pricing, and swallowed failures.

* fix(desktop): GUI model picker shows curated Nous list in curated order

Two bugs made the GUI Nous model list diverge from the `hermes model` CLI picker:

1. Backend (model_switch.py): the Nous row in list_authenticated_providers
   fell through to cached_provider_model_ids("nous"), dumping the full live
   /v1/models catalog (~50 vendor-prefixed models, alphabetical). Now it uses
   the curated list AND applies the Portal free/paid recommendation union —
   exactly like _model_flow_nous in main.py — so newly-launched models such as
   stepfun/step-3.7-flash:free surface in curated order. Best-effort: falls
   back to the curated list alone if the Portal fetch fails.

2. Frontend (model-picker.tsx): cmdk's Command had shouldFilter on (default),
   which re-sorts items by fuzzy-match score (≈alphabetical) and ignores array
   order. Set shouldFilter={false} + own the search term and do an
   order-preserving substring filter, so the backend's curated order is shown
   verbatim.

* feat(desktop): add/switch providers from the model picker via onboarding reuse

The model picker could only select models from already-authenticated
providers. Switching to a new provider had no in-app path. Rather than
duplicate provider UI, reuse the existing onboarding provider selector
(featured Nous + other providers + API-key form + device-code/PKCE flow +
model-confirm with pricing/tier).

- onboarding store: add a 'manual' flag with startManualOnboarding() /
  closeManualOnboarding(). Manual mode forces the onboarding overlay to show
  even when configured===true and refreshOnboarding no longer auto-dismisses
  on runtime-ready (the app is already working — the user is just adding or
  switching a provider).
- onboarding overlay: render when manual even if configured; show a Close
  button (the first-run flow has none since the app can't run yet).
- model picker: 'Add provider' footer button opens the onboarding selector;
  ModelResults lists only configured (model-bearing) providers.

* feat(desktop): add PUT /api/tools/toolsets/{name} enable/disable endpoint

* feat(desktop): add toggleToolset RPC binding

* feat(desktop): toolset enable/disable switch in Tools settings

* feat(desktop): tool configuration parity in GUI Tools settings

Bring the desktop GUI Tools settings to parity with the CLI `hermes tools`
for provider selection and API-key configuration.

Backend (hermes_cli/web_server.py):
- GET  /api/tools/toolsets/{name}/config  - provider matrix + key status
- PUT  /api/tools/toolsets/{name}/provider - persist provider selection

Shared core (hermes_cli/tools_config.py):
- Extract apply_provider_selection / _write_provider_config from the
  interactive _configure_provider so the CLI and GUI write identical
  config keys (web.backend, tts.provider, browser.cloud_provider, plugin
  image/video providers, use_gateway flags) through one code path.

Desktop UI:
- ToolsetConfigPanel: provider list with select, per-provider API-key
  entry (set/replace/clear/reveal via the shared env RPCs), Ready/Needs
  keys state, guidance for Nous-auth and post-setup providers.
- Wire the Configured/Needs keys pill to expand the panel inline; refresh
  the toolset list after key changes so the pill updates live.
- Add getToolsetConfig / selectToolsetProvider RPC bindings + types.

Post-setup (OAuth/install) flows still defer to the CLI; see
docs spike findings for the planned /api/tools/setup/* endpoint family.

Tests: backend round-trip + 400 cases for the new endpoints and
apply_provider_selection; desktop vitest coverage for the config panel
(provider render, select, key save). No change-detector tests.

Also removes three stale completed plan docs.

* fix(desktop): show real Hermes version + sync package.json on release

The desktop app version was disconnected from the Hermes version: the
release script bumped pyproject.toml + hermes_cli/__init__.py but never
touched apps/desktop/package.json, which sat stale at 0.0.2 (lockfile at
0.0.1).

- main.cjs: hermes:version IPC now resolves __version__ from
  hermes_cli/__init__.py (the canonical source release.py bumps) via a new
  resolveHermesVersion() helper, falling back to app.getVersion() when the
  source tree isn't readable. The About panel now always shows the live
  Hermes version and can't drift.
- release.py: update_version_files() also bumps apps/desktop/package.json
  in lockstep with pyproject (top-level version only; dep specs untouched).
- One-time catch-up: package.json 0.0.2 -> 0.15.1 and the lockfile root
  mirrors 0.0.1 -> 0.15.1.

* fix(desktop): stamp exe identity in afterPack hook so updates stay branded

The packed Hermes.exe reverted to the stock Electron icon + "Electron" name
after an in-app update. The icon/identity stamp (rcedit) lived only in
install.ps1, but the installer's --update path rebuilds the desktop via
`hermes desktop --build-only` -> `npm run pack`, which never ran install.ps1
and so never stamped the rebuilt exe.

Move the stamp into an electron-builder afterPack hook so it runs for EVERY
packed build regardless of caller (first install, hermes desktop, the update
rebuild, or a manual npm run pack):

- set-exe-identity.cjs: refactor to export stampExeIdentity(exe, desktopRoot);
  still runnable as a standalone CLI.
- after-pack.cjs (new): afterPack hook calling stampExeIdentity. Windows-only
  guard; best-effort (logs + resolves on failure, never fails the build).
- package.json: register build.afterPack.
- install.ps1: remove the now-redundant Set-DesktopExeIdentity function + call;
  the hook handles it during npm run pack.

electron-builder's own rcedit step stays disabled (signAndEditExecutable=false)
to avoid the signtool -> winCodeSign -> 7-Zip macOS-symlink crash on non-admin
Windows; the hook runs rcedit directly (pure PE resource edit, no signing).

* fix(desktop): export afterPack hook as exports.default so electron-builder runs it

The afterPack hook used `module.exports = fn`, which electron-builder's hook
loader doesn't pick up — it expects the function as the module's default
export (the same shape afterSign/notarize.cjs uses). The hook silently never
ran, so even first install shipped the stock "Electron" exe.

Switch to `exports.default = async function afterPack(...)`. Verified with a
real `npm run pack`: electron-builder now invokes the hook and the produced
release/win-unpacked/Hermes.exe carries ProductName/FileDescription=Hermes.

* chore(desktop): drop auto-build release CI in favor of manual build + upload

Remove desktop-release.yml (nightly-on-main + stable publish). Installers
are now built locally per platform and uploaded to a GitHub Release by hand;
the website points at them via NEXT_PUBLIC_HERMES_DL_* env. Update README +
docs and drop the dead desktop-nightly channel links.

* fix(desktop): stable shortcut icon + bust icon cache so updates repaint

Symptom on a freshly-installed laptop: Hermes.exe itself shows the correct
Hermes icon (Explorer reads the live exe's stamped PE resource), but the
desktop shortcut still draws the stock Electron icon.

Cause: New-DesktopShortcuts set IconLocation to "<exe>,0", so Windows cached
the icon it extracted from the exe at shortcut-creation time. On an update the
exe gets re-stamped, but the shortcut keeps rendering the stale cached bitmap.

- package.json: ship assets/icon.ico beside the exe via extraResources
  (-> resources/icon.ico). Verified with a real npm run pack.
- install.ps1 New-DesktopShortcuts: point IconLocation at resources/icon.ico
  (fallback to <exe>,0 if absent) — a dedicated .ico is cache-stable and skips
  the per-exe extraction that goes stale. Then run `ie4uinit.exe -show` to bust
  the shell icon cache so the shortcut repaints immediately instead of showing
  the old Electron icon until reboot.

Both best-effort; never fail an otherwise-good install.

* dummy update

* feat(desktop): self-heal update branch + backend contract guard

Two fixes for the bb/gui→main transition:

- Self-update self-heals: if the tracked branch (e.g. bb/gui) no longer
  exists on origin (merged + deleted), the desktop updater falls back to
  main and persists it. Read-only ls-remote probe that only flips on a
  definitive "ref absent" (exit 2), never on a transient network error, so
  already-installed clients migrate themselves with no manual flip.
- Backend contract guard: tui_gateway reports DESKTOP_BACKEND_CONTRACT in
  session runtime info; the desktop warns with a one-click "Update Hermes"
  when the backend predates the GUI's required contract (e.g. a bb/gui app
  pointed at a main checkout) instead of failing cryptically downstream.

* docs(desktop): rewrite README to match current install/update/build flow

The old README contradicted itself (claimed a bundled Python payload while
also saying it no longer bundles source) and predated cross-platform support.
Rewrite for accuracy: Linux is a first-class build target, install.sh/install.ps1
both drive the staged bootstrap, the real self-update handoff (Windows
Hermes-Setup vs in-app macOS/Linux), and the bb/gui→main self-heal + backend
contract guard.

* docs(desktop): rewrite README as a real product readme

Lead with what the app is and how to get it (download an installer, or
`hermes desktop` for existing CLI users) plus a plain-language feature list,
then keep contributor/build/internals as a clearly separated secondary section.

* docs(desktop): fix install framing — releases no longer auto-build installers

Lead with the install-with-Hermes path (`--include-desktop` / `hermes desktop`),
which always works, and describe prebuilt installers as manually published when
a release ships them rather than implying CI attaches them to every release.

* docs(desktop): match base repo README style

Adopt the root README's conventions: centered title + badge row, bold
one-liner intro, a feature <table> grid, --- section dividers, and a
Community / License footer.

* feat(desktop): recover from gateway boot failures + validate API keys on entry (#35864)

Fresh installs that hit a gateway boot failure had no recovery path: the
shell rendered dead ("gateway offline"), logs were undiscoverable, and a
mistyped API key was accepted because onboarding only checked credential
presence, not validity.

- Add BootFailureOverlay: a top-level recovery surface (Retry, Repair
  install, Use local gateway, Open logs + inline recent logs) that mounts
  on any hard boot failure, including post-install. Trims the now-redundant
  recovery button from the onboarding Preparing panel.
- Add hermes:logs:reveal / :recent IPC (reveal desktop.log) and a
  hermes:bootstrap:repair IPC that drops the bootstrap marker to force a
  clean reinstall. Surface "Open logs" in Gateway settings too.
- Add POST /api/providers/validate: a live per-provider probe
  (OpenRouter/OpenAI/xAI/Gemini key check, local endpoint connectivity)
  wired into saveOnboardingApiKey so a rejected key blocks before it's
  persisted, while an unreachable probe falls through (offline-safe).

* test(model-catalog): fix stale nous picker test after curated-list change

ac2e48907 made the GUI/picker Nous row use the curated list (curated["nous"]
= get_curated_nous_model_ids()) + Portal union, matching the `hermes model`
CLI — but test_picker_nous_row_uses_manifest still asserted the old 2-model
manifest snapshot, breaking the test shard.

Rewrite it as an invariant: stub the Portal union to passthrough and assert the
row equals get_curated_nous_model_ids() computed under the same conditions, so
it tracks the real contract instead of a hardcoded model list that rots on every
catalog update.

---------

Co-authored-by: emozilla <emozilla@nousresearch.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: ethernet <arilotter@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-31 17:46:56 -05:00
Teknium cf328723d4 docs: drop early-beta framing for native Windows support (#36093)
Native Windows is out of beta. Removes the early-beta warnings, headings,
and rough-edge framing across the README and docs (EN + zh-Hans), keeping
the WSL2-only dashboard PTY caveat. Historical RELEASE_v0.14.0.md notes are
left intact since they accurately describe the state at that release.

- README: Windows install + cross-platform notes
- index.mdx, installation.md: headings, warning admonitions, parity note
- windows-native.md: title/sidebar_label/warning, provider-hunting tip
- contributing.md, nous-portal.md: cross-platform / Portal parity prose
- Repoint cross-links to the renamed installation#windows-native-powershell
  anchor (EN) and #windows原生powershell (zh, also fixes pre-existing drift)
2026-05-31 15:33:18 -07:00
kshitijk4poor c9a28dfb08 feat(model-picker): description on group layer, plain labels on members
For grouped provider families, the descriptive text now lives only on the
collapsed top-level group row. The member sub-picker rows show just the
short provider label (no parenthetical tui_desc), so the description is not
duplicated one layer down.

Ungrouped providers are unaffected — they have no group layer, so their own
row keeps its full tui_desc.

- main.py: member sub-picker uses provider_labels (label) instead of
  canonical_descs (tui_desc).
- Telegram already showed labels + model count on member buttons; group
  buttons keep Label ▸ (count) since inline keyboards can't fit a long blurb.

Member labels retain their short disambiguators (e.g. 'MiniMax (OAuth)') so
the sub-picker rows stay distinguishable.
2026-05-31 15:02:26 -07:00
kshitijk4poor 84d82453ae feat(model-picker): show short description on grouped provider rows
The 7 consolidated provider families (OpenAI, xAI Grok, GitHub Copilot,
Google Gemini, Kimi / Moonshot, MiniMax, OpenCode) collapse to one
top-level picker row. Previously that row showed only the bare group
label (e.g. `OpenAI ▸`); now it carries a short blurb describing the
endpoints folded inside (e.g. `OpenAI ▸ (Codex CLI or direct OpenAI API)`).

- models.py: extend PROVIDER_GROUPS tuples to (label, description, members);
  group_providers() emits the description on group rows.
- main.py: CLI picker renders `<label> ▸ (<description>)` for group rows.
- telegram.py: update the group tuple unpack (button text keeps the member
  count, which fits inline keyboards better than a long blurb).
- tests: assert every group has a non-empty description and the fold emits it.

Member-specific detail still lives in each member's tui_desc and shows in
the drill-down sub-picker. Slug identity, --provider, /model paths unchanged.
2026-05-31 15:02:26 -07:00
kshitijk4poor 47d2d05892 chore(model-picker): refresh provider picker descriptions
Update the tui_desc text shown for each provider in the interactive
`hermes model` / setup wizard / `/model` pickers. Pure copy refresh —
slugs, labels, PROVIDER_GROUPS folding, and all typed paths are unchanged,
so the 7 grouped families (OpenAI, xAI Grok, GitHub Copilot, Google Gemini,
Kimi / Moonshot, MiniMax, OpenCode) still fold identically.

Also aligns the auto-injected alibaba-coding-plan provider description to
the same parenthetical style.
2026-05-31 15:02:26 -07:00
kshitijk4poor eb3cf9750e fix(gateway): resolve _get_dm_topic_info on adapter class, not instance
Follow-up to the synthetic-notification DM-topic routing fix. The new
_is_telegram_dm_topic_target probed the adapter's _get_dm_topic_info via
instance-level getattr, which a MagicMock auto-creates as a truthy callable —
so any test double with a non-dm chat_type and a thread_id would be
misclassified as a DM topic lane and have the fallback routing keys injected.

Resolve the method on type(adapter) and treat only dict-shaped returns as an
operator-declared topic, mirroring the existing guard in
_rename_telegram_topic_for_session_title. Update the home-channel startup test
to declare _get_dm_topic_info on a real adapter subclass instead of patching a
MagicMock onto the instance.
2026-05-31 12:13:46 -07:00
Dusk1e 4259bab7d4 fix(gateway): preserve Telegram DM topic routing metadata in synthetic notifications 2026-05-31 12:13:46 -07:00
kshitij 59cc7c305d Merge pull request #36023 from kshitijk4poor/fix/spawn-via-env-bg-wrapper
fix(tools): don't compound-rewrite spawn_via_env background wrappers
2026-05-31 12:11:17 -07:00
kshitij 01dda3fa02 Merge pull request #36010 from kshitijk4poor/fix/terminal-cwd-acp-aware
fix(tools): preserve live session cwd in terminal_tool, keep ACP update_cwd authoritative
2026-05-31 11:41:21 -07:00
kshitijk4poorandCharZhou 6f8975dcd8 fix(tools): don't compound-rewrite spawn_via_env background wrappers
Background tasks on non-local backends (SSH/Docker/Modal/Daytona/Singularity)
go through `ProcessRegistry.spawn_via_env`, which builds a hand-crafted,
shell-safe wrapper:

    mkdir -p T && ( nohup bash -lc CMD > LOG 2>&1; rc=$?; ... ) & echo $! > PID && cat PID

`BaseEnvironment.execute()` unconditionally ran `_rewrite_compound_background`
on every command, including this wrapper. The rewrite (meant to defuse the
`A && B &` subshell-wait trap for user commands) turns `( ... ) & echo $!` into
`{ ( ... ) & } echo $!` — note `} echo` with no separator, which is a bash
syntax error. The wrapper then never produces a PID, the redirected output file
is never created, and the agent sees an immediate exit code -1. This breaks
*every* background launch on a non-local backend (e.g. a simple
count-and-redirect script over SSH), not just edge cases.

Fix:
- Add `rewrite_compound_background: bool = True` to `BaseEnvironment.execute()`
  (and the `BaseModalExecutionEnvironment` override, which accepts and ignores
  it). Default preserves existing behavior; the user foreground terminal path
  still rewrites.
- `spawn_via_env` passes `rewrite_compound_background=False` so its already
  shell-safe wrapper is left intact.
- Treat a wrapper that produces no PID as a failed launch (mark the session
  exited with a real exit code instead of exposing a fake running session), and
  don't register/checkpoint a session that never started.

Verified empirically: with the rewrite skipped, the wrapper is valid bash,
launches the process, captures the PID, and writes the log/pid/exit files; the
old rewritten form fails `bash -n` with a syntax error.

Based on #33756 by @CharZhou (extracted from a multi-feature branch; the
unrelated image_gen / docker-media changes are not included here).

Co-authored-by: CharZhou <17255546+CharZhou@users.noreply.github.com>
2026-06-01 00:05:10 +05:30
kshitijk4poorandDusk1e 7a315bd702 fix(tools): preserve live session cwd in terminal_tool, and keep ACP update_cwd authoritative
terminal_tool re-sent the init-time/config cwd on every command, clobbering
session-local `cd` state: the environment tracked the new directory in
`env.cwd`, but foreground/background calls forced the old cwd back. A small
`_resolve_command_cwd` resolver now applies the precedence
`workdir > live env.cwd > config/override cwd` to:
  - foreground `env.execute(...)`
  - background `process_registry.spawn_local(...)`
  - background `process_registry.spawn_via_env(...)`

Additionally, syncing the cwd onto the live cached env when a `cwd` override is
(re-)registered. Preferring live `env.cwd` would otherwise demote the ACP
`update_cwd` override (registered via `register_task_env_overrides` on
`session/load` / `session/resume`) below an already-set `env.cwd`, silently
ignoring an editor's mid-session project-root change once any command had run.
`register_task_env_overrides` now pushes a new cwd onto the cached env so an
explicit ACP cwd change wins, while ordinary in-session `cd` tracking is
preserved.

Regression coverage:
  - foreground/background commands follow live `env.cwd`
  - explicit `workdir` still overrides everything
  - registering a cwd override updates the live env cwd (ACP authority)
  - no-op when no live env exists; non-cwd overrides leave env.cwd untouched

Based on #35510 by @Dusk1e.

Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com>
2026-05-31 23:50:40 +05:30
Teknium 1044d9f25d fix(gateway): /stop can interrupt a sibling participant's run in a per-user thread (#35959)
In a per-user thread (thread_sessions_per_user=True), each participant
gets an isolated session key (...:{thread_id}:{user_id}). A run another
user started lives under a different key, so the caller's own /stop found
nothing and replied 'no active task to stop'.

When /stop finds no run under the caller's own key, fall back to
interrupting any running agent(s) sharing the caller's thread prefix
({chat_id}:{thread_id}), gated on _is_user_authorized. Thread-only — the
fallback returns [] for non-thread channels, and a prefix-collision guard
prevents thr1 from matching thr11.
2026-05-31 09:29:03 -07:00
Teknium de4f40ed02 feat(setup): thin out setup — Quick Setup via Nous Portal + Full Setup defaults (#35723)
* feat(setup): Quick Setup routes through Nous Portal (OAuth + model + messaging)

First-time quick setup now goes straight to the Nous Portal provider
instead of showing the full provider picker. Runs the device-code OAuth
login, selects a Nous model, configures the terminal backend, and offers
messaging setup — applying recommended defaults for everything else.

- Rename menu entry to 'Quick Setup (Nous Portal)'.
- _run_first_time_quick_setup now calls _model_flow_nous (handles both the
  logged-out OAuth+model-select path and the logged-in curated picker),
  then re-syncs config from disk to avoid the #4172 stale-overwrite.
- Terminal / defaults / messaging steps unchanged.

* feat(setup): thin out Full Setup with happy defaults

Full Setup no longer asks for every config knob — anything with an
obvious default is applied silently and stays tunable via the per-section
commands (hermes setup agent|terminal|tts, hermes auth add).

- Model section: drop the same-provider rotation pool, vision-backend
  picker, and TTS provider sub-flows. Vision auto-detects from the main
  provider; TTS defaults to Edge; rotation lives in hermes auth add.
- Terminal section: keep the backend picker (Local default) and any
  required credentials (Modal token, SSH host/user/key, Daytona key),
  but stop prompting for container image, CPU/mem/disk resources, gateway
  cwd, and sudo password — all use defaults.
- Agent Settings: removed from the wizard. First installs get recommended
  defaults silently; existing installs keep their tuned values.
- New defaults: max_turns 90 -> 150, session_reset both -> none.
- Tests: reconfigure tests assert agent settings are no longer prompted
  on existing installs; drop 3 tests covering the deleted in-setup
  rotation flow.
2026-05-31 09:13:06 -07:00
brooklyn! a726e8a811 fix(tui): auto-recover session on unexpected gateway death (+ persist lifecycle breadcrumbs) (#35893)
* fix(tui): persist gateway lifecycle breadcrumbs to crash log

A backend SIGTERM (`=== SIGTERM received ===` in tui_gateway_crash.log) is
always a parent action — `gw.kill()` (graceful-exit on a signal to Node, or an
explicit /quit) or `start()` replacing a live child. #31051 added parent-side
lifecycle breadcrumbs but left them in an in-memory CircularBuffer that dies
with the process, so SIGTERM crash reports arrive with no parent context and no
way to tell a signal-driven kill from a memory-critical `process.exit(137)`
(which closes the child's stdin → clean EOF, not SIGTERM).

Persist the death-explaining breadcrumbs (spawn / transport-exit / child-exit /
replace-live-child / kill-reason / startup-timeout) plus the graceful-exit
signal name and the memory-critical exit into the same crash log the Python
side writes, so they interleave by timestamp next to the child's panic entry —
making these recurring reports diagnosable.

Gated off under VITEST so unit tests stay hermetic.

* feat(tui): auto-recover the session when the gateway dies unexpectedly

When a still-owned gateway child dies while the TUI is alive (a crash, OOM
process.exit, or a SIGTERM/SIGHUP forwarded to it), the app currently nulls the
session and drops to an inert "gateway exited" state — the user loses a long
session and has to restart + re-run everything. That single behavior is most of
the "TUI doesn't survive heavy work" complaint, independent of what does the
killing.

The 'exit' event only reaches this handler on an *unexpected* death: a user
/quit calls process.exit before it fires, and a replaced child is identity-
skipped in GatewayClient. So on exit we now respawn the gateway and resume the
session that was live (history is persisted in SQLite) via a one-shot
recoverSidRef the next gateway.ready consults before forging a new session. The
in-flight reply is lost (it died with the process) but the session survives.

Bounded to GATEWAY_RECOVERY_LIMIT (3) attempts per GATEWAY_RECOVERY_WINDOW_MS
(60s) so a gateway that crash-loops on startup can't spawn-storm; past the
budget we fall back to the inert state.

* fix(tui): sanitize newlines + soften SIGTERM-cause claim in parentLog

Address PR review:
- recordParentLifecycle collapses embedded \r\n so a multi-line value (e.g. an
  error message) stays a single breadcrumb and can't masquerade as a separate
  entry or as the child's panic output sharing the crash log.
- Reword the header: a backend SIGTERM is *usually* a parent action but can come
  straight from an external supervisor (s6, cgroup OOM, stray kill); the
  presence/absence of a [tui-parent] line before the child's panic is precisely
  what disambiguates the two.

* fix(tui): clear sid during recovery + extract/test the recovery budget

Address PR review:
- Null `sid` immediately in the gateway exit handler. While the gateway is down
  (busy=false) the old sid would otherwise let sid-guarded effects (the 1.5s
  session.active_list poll, queue drain) fire RPCs at a dead/respawning gateway.
  recoverSidRef carries the session forward; resumeById restores sid on ready.
- Extract the respawn budget into a pure evalRecovery() (gatewayRecovery.ts) and
  unit-test the bound: allows GATEWAY_RECOVERY_LIMIT within the window, blocks
  past it, and prunes attempts older than the window so recovery re-arms.

* fix(tui): cap parent-log breadcrumb length (PR review)

Truncate a single persisted breadcrumb to 4096 chars (matching GatewayClient's
in-memory log-line cap) so a pathological value — e.g. a giant error string —
can't bloat the shared crash log or add noticeable blocking on the synchronous
append during a failure path. Covered by a test.

* fix(tui): keep "recovering session…" status visible during resume (PR review)

resumeById() synchronously sets status to 'resuming…' on entry, so the
recovery branch now applies its 'recovering session…' label *after* calling
resumeById — the distinct label sticks for the duration of the resume RPC
(which later flips to 'ready') instead of being immediately clobbered. Test
updated to assert the ordering.

* fix(tui): keep recovery budget alive across a startup crash-loop (PR review)

deadSid was read from getUiState().sid, which the first exit nulls — so if the
respawned gateway crash-looped before gateway.ready (resumeById never restored
sid), later exits saw null and abandoned the session after a single attempt,
defeating the bounded retry budget.

Lift the whole decision into a pure planGatewayRecovery() that falls back to the
pending recoverSidRef target when the live sid is already cleared, and unit-test
the crash-loop sequence (keeps retrying the same session up to the limit, then
falls back to inert). Supersedes evalRecovery.

* chore(tui): drop non-null assertion + clarify breadcrumb cap comment (PR review)

- Recovery branch guards on `recoverSidRef && recoverSid` so the ref write needs
  no `!` assertion (avoids a future unsafe refactor).
- Reword the parentLog cap comment: it slices the value to 4096 chars and
  appends a short truncation marker (so the written line is slightly longer),
  rather than implying a strict 4096-byte limit.

* chore(tui): soften "absence ⇒ external signal" + "any in-flight reply" (PR review)

- parentLog header: a missing [tui-parent] line only *suggests* an external
  signal (the logger is best-effort: VITEST-disabled, failed append swallowed),
  not a definitive conclusion.
- Recovery notice says "any in-flight reply was lost" since the gateway can also
  exit while idle.
2026-05-31 10:36:57 -05:00
teknium1 04bb74c58e chore: map fesalfayed author email for release notes 2026-05-31 06:14:34 -07:00
fesalfayed 64628ea89b fix(anthropic): demote dead thinking signature when orphan-strip mutates the latest turn
Extended-thinking Claude models (4.6+, e.g. Opus 4.8) emit a signed `thinking`
block on assistant turns that also carry parallel `tool_use` blocks. Anthropic
signs that block against the full, original turn content.

When a parallel tool batch is interrupted before every `tool_result` returns,
`_strip_orphaned_tool_blocks` removes the unanswered `tool_use` on replay — which
mutates the turn. The latest-assistant branch of `_manage_thinking_signatures`
then replays the now-stale signed thinking block verbatim, and Anthropic rejects
the request with a non-retryable HTTP 400:

    messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest
    assistant message cannot be modified. These blocks must remain as they were
    in the original response.

Because the poisoned turn is rebuilt from the persisted store every turn, the
gateway crash-loops with no self-recovery (a soft session reset does not clear
it). The drifting content index in the error is the changing count of stripped
`tool_use` blocks across rebuilds.

Fix: when orphan-stripping removes a `tool_use` from a turn that also holds a
thinking/redacted_thinking block, flag the turn. `_manage_thinking_signatures`
then demotes every thinking block on that latest turn to a plain text block
(preserving the reasoning text) instead of replaying a signature that can no
longer validate. An intact turn is unaffected — its signed thinking is still
replayed verbatim. The internal flag is stripped before the payload is sent.

Adds two regression tests:
- demotion when an orphaned parallel tool_use is stripped
- control: signed thinking preserved verbatim when nothing is stripped
2026-05-31 06:14:34 -07:00
Teknium 2b5268f716 revert: drop cumulative-resend tool-arg heuristic from shared streaming path (#35718) (#35860)
PR #35718 added a per-slot "cumulative-resend" latch to the universal
streaming tool-call accumulator to fix DeepSeek / Baidu Qianfan (#35592).
The latch fires when a delta is a strict superset of the accumulated
buffer (len(_new) > len(_prev) and _new.startswith(_prev)) and then
REPLACES the buffer instead of appending.

That superset test is not an unambiguous cumulative signature. A normal
incremental stream can emit a single fragment that restates an already-
accumulated prefix — trivially common in large code-patch arguments with
repeated lines / indentation — which trips the latch and clobbers the
accumulated buffer, corrupting the tool call. Observed in the wild on
Anthropic Opus (the primary model) building a large patch: corrupted /
short arguments → finish_reason='length' dead-end → session killed.

A guessing heuristic that can silently clobber a tool-call buffer has no
place on the path every provider and model shares. Reverting restores the
known-good plain `+=` accumulator. The #35592 narrow provider bug should
be re-addressed provider-gated so it is structurally impossible to touch
Anthropic / OpenAI incremental streams, rather than via a heuristic on the
shared path.

Reverts ca03486b6.
2026-05-31 06:14:32 -07:00
Teknium f2d4cf4f76 fix(cli): clamp post-compression token sentinel in status bar (#35858)
The status bar read context_compressor.last_prompt_tokens directly with
an 'or 0' guard that only catches 0/None. Right after a compression the
compressor parks last_prompt_tokens at the -1 sentinel
(awaiting_real_usage_after_compression) until the next API call reports
real usage. -1 is truthy, so it sailed through and rendered as '-1/200K'
and '-1%' for that one transitional turn.

Clamp negative token/context-length values to 0 in the status-bar
snapshot so the gap reads as empty context until real usage arrives.
2026-05-31 06:03:01 -07:00
Teknium 1fc7bdc5e6 feat(tools): always show Nous Tool Gateway backends, login on select (#35792)
* feat(tools): always show Nous Tool Gateway backends, login on select

The Nous-managed Tool Gateway rows in `hermes tools` (Firecrawl, OpenAI
TTS, Browser Use, FAL image/video) were hidden unless the user was already
logged into Nous Portal with paid access. Now they are always listed.
Selecting one runs an inline Nous Portal device-code OAuth + entitlement
check — auth only, no inference-provider switch and no bulk 'enable all
tools' prompt (that stays in `hermes model`). The row only activates the
gateway once paid access is confirmed.

- _visible_providers: stop hiding managed_nous_feature rows (incl. those
  also flagged requires_nous_auth); pure pre-auth UX rows still gate on login
- nous_subscription.ensure_nous_portal_access(): auth + entitlement gate
  that preserves the user's active inference provider
- _configure_provider / _reconfigure_provider: run the inline gate for
  managed backends; write config only when entitled
- picker marker: 'via Nous Portal (login on select)' for logged-out users
- _hidden_nous_gateway_message: now a no-op (rows are never hidden)

* docs: hermes tools is a first-class Tool Gateway entry point

The Tool Gateway docs framed `hermes setup --portal` / `hermes model` as
the activation path and only mentioned `hermes tools` for mixing in your
own keys. With the inline-login change, picking a Nous-managed backend in
`hermes tools` is a complete path on its own — it logs you into Nous
Portal on select if needed, without switching your inference provider or
prompting to enable every other tool.

- tool-gateway.md: Get started now lists three peer entry points; new
  paragraph explaining login-on-select and the no-prompt fast path when
  OAuth is already active
- nous-portal.md + run-hermes-with-nous-portal.md: note that managed rows
  appear logged-out and trigger inline login on select
2026-05-31 03:39:17 -07:00
kshitijk4poor 8f4c8e7c82 refactor(cli): extract shared curses menu event-loop driver
The three curses menus (curses_checklist / curses_radiolist /
curses_single_select) each hand-rolled an identical event loop: cursor
hide + color-pair init, the per-frame clear/getmaxyx/refresh cycle,
scroll-offset math, row iteration, the read_menu_key dispatch with
NAV_UP/NAV_DOWN cursor wrap, flush_stdin, and the
KeyboardInterrupt/curses-unavailable fallback. Terminal-behavior changes
(e.g. Ghostty raw-escape handling, scroll tweaks, a new key) had to be
made in three places.

Extract that boilerplate into one _run_curses_menu driver. Each public
menu now supplies small callbacks for the parts that genuinely differ:
draw_header (returns the item-list start row), draw_row (checkbox vs
radio vs bare prefix), an on_action reducer (toggle-set vs return-cursor
vs return-None + the single_select cancel-row guard), an optional
draw_footer (the checklist status bar), reserve_bottom, and the numbered
fallback. Behavior is passed as functions; the loop is the only stateful
piece — so future terminal/Ghostty work is a one-place edit.

Duplicated event-loop primitives drop 3 -> 1 (stdscr.clear, read_menu_key
dispatch, scroll math). Verified byte-identical: a render harness records
every addnstr(y, x, clamped-text, attr) call across frames plus the
return value for 6 cases (checklist, checklist+status, radiolist,
radiolist+description, single_select, single_select ESC-cancel); output
diffs clean against origin/main. Non-TTY returns the cancel value
directly (not the input()-based numbered fallback), matching the old
per-menu guard. 150 menu/setup/browse/plugins tests pass.
2026-05-31 03:19:37 -07:00
kshitijk4poor 087be00733 fix(cli): migrate setup model/provider pickers off simple_term_menu to curses
The setup provider->model sub-menu (and three sibling pickers) used
simple_term_menu.TerminalMenu, whose ESC and arrow-key handling was
unreliable across terminals — notably ESC failed to back out of the
model selection list on terminals that emit raw escape sequences (e.g.
Ghostty). The codebase already notes simple_term_menu 'conflicts with
/dev/tty' and causes 'ghost-duplication rendering', and a prior attempt
to migrate these (closed PR) confirmed the same root cause.

Route all four single-select pickers through the shared, already-hardened
curses_radiolist (which decodes raw CSI/SS3 escape sequences and handles
ESC consistently, fixed in #35776):

- auth.py _prompt_model_selection — model picker; the pricing column
  header and the unavailable-models block are passed as the radiolist
  description so they survive the curses screen clear. ESC now cancels.
- main.py _prompt_reasoning_effort_selection — reasoning-effort picker.
- main.py _model_flow_named_custom — named custom-provider model picker.
- main.py _remove_custom_provider — provider-removal picker.

simple_term_menu is no longer imported anywhere (only stale comments
referenced it; one in setup.py is corrected). The numbered-input
fallbacks are unchanged and still trigger on curses errors / non-TTY.

Tests: updated test_terminal_menu_fallbacks / test_reasoning_effort_menu
/ test_custom_provider_model_switch / test_model_provider_persistence to
drive the fallback via curses_radiolist errors instead of breaking
simple_term_menu. New test_setup_menu_curses_migration.py asserts each
picker routes through curses_radiolist, ESC cancels, and the pricing
header is preserved. Net -147/+183 (mostly the new test file; production
code shrinks by removing TerminalMenu boilerplate).
2026-05-31 03:19:37 -07:00
kshitij 4ccd141b15 Merge pull request #35776 from kshitijk4poor/fix/curses-arrow-key-decode
fix(cli): decode raw arrow-key escape sequences in curses menus
2026-05-31 01:41:31 -07:00
kshitijk4poor 3463c97a36 fix(cli): decode raw arrow-key escape sequences in curses menus
The setup wizard's provider/model pickers (curses_radiolist via
prompt_choice) bailed to the numbered "Select [1-N]" fallback the moment
a user pressed up or down. Root cause: even with keypad(True) — which
curses.wrapper sets — many terminals/terminfo entries deliver cursor keys
to getch() as raw CSI/SS3 byte sequences (e.g. 27, 91, 66 for arrow-down)
rather than the translated curses.KEY_DOWN. The menus matched only
curses.KEY_UP/KEY_DOWN and treated the leading 27 (ESC) as cancel, so
navigation dropped into the text fallback and the trailing bytes leaked
into the next input().

Add a shared read_menu_key() helper that decodes CSI/SS3 escape sequences
into normalized NAV_* actions (only a lone ESC, with no continuation byte
within a short timeout, still cancels) and consumes the tail of unhandled
sequences so stray bytes can't corrupt later input(). Route all three
curses menus (checklist, radiolist, single_select) through it.

Add regression tests covering raw CSI/SS3 arrows, translated KEY_*
constants, vim keys, lone-ESC cancel, and full consumption of unhandled
sequences (Delete/Home/End).
2026-05-31 13:59:56 +05:30
Teknium 0cd7d54b00 feat(kanban): goal_mode cards run workers in a /goal loop (#35710)
* feat(kanban): goal_mode cards run workers in a /goal loop

A goal_mode card wraps its dispatched worker in the Ralph-style goal
loop behind /goal: after each turn an auxiliary judge checks the
worker's response against the card title+body, and if not done the
worker keeps going in the SAME session until the judge agrees, the
worker terminates the task itself, or the turn budget runs out (which
blocks the card for human review — never a silent exit).

- kanban_db: goal_mode + goal_max_turns columns (additive migration),
  Task fields, create_task params, INSERT wiring, created-event payload.
- kanban_tools: goal_mode/goal_max_turns on the kanban_create tool so
  orchestrators can opt cards in when fanning out.
- kanban CLI: --goal / --goal-max-turns on 'kanban create'.
- dashboard API: goal_mode/goal_max_turns on the create endpoint
  (auto-surfaced back via asdict).
- _default_spawn: sets HERMES_KANBAN_GOAL_MODE / _GOAL_MAX_TURNS only
  when the card opts in.
- goals.run_kanban_goal_loop: standalone, callback-injected loop engine
  (no SessionDB persistence; ephemeral worker). cli.py quiet path calls
  it after the worker's first turn when the env vars are set.
- Docs: orchestrator skill + kanban feature page.

Tests: DB roundtrip + legacy migration, spawn env gating, and the loop's
continuation/completion/budget-block/finalize-nudge branches. E2E run
against a real kanban DB confirms a budget-exhausted goal worker lands
in a sticky blocked state.

* feat(kanban/dashboard): goal-mode toggle in the create form

Wires the goal_mode card setting into the dashboard UI (the plugin's
hand-written IIFE bundle, no build step):

- InlineCreate: 'goal mode' checkbox after the skills field; checking it
  reveals an optional 'max turns' number input. Both reset on submit and
  only post goal_mode/goal_max_turns when enabled.
- TaskDrawer: a 'Goal mode: on (max N turns)' MetaRow so a card's
  goal-mode setting is visible after creation (auto-fed by asdict via the
  existing _task_dict).

Live-tested through the running dashboard with a browser: created a
goal-mode card with max-turns=8, confirmed it persisted to the kanban DB
(goal_mode=1, goal_max_turns=8) and rendered back in the drawer as
'on (max 8 turns)'. No JS console errors.
2026-05-31 01:16:33 -07:00
kshitijk4poor 32899279a7 fix(gateway): detach pending_watchers batch + normalize LRU caches + align test fixtures + AUTHOR_MAP
Self-review follow-up on top of the salvaged perf fixes:

- gateway/run.py (both watcher-drain sites): the salvaged O(n^2) fix
  (#32708) replaced `while pending_watchers: pop(0)` with iterate-then-
  `watchers.clear()`, but `watchers` aliased the registry's live list.
  A watcher appended by a concurrent session during the `await
  asyncio.sleep(0)` yield would be cleared without ever being scheduled.
  Detach the batch atomically (`pending_watchers = []`) before iterating.

- gateway/platforms/bluebubbles.py: normalize the salvaged _guid_cache
  LRU (#30523) to match feishu/codebase precedent — module-level
  `_GUID_CACHE_SIZE` constant, `while len > cap`, and drop the redundant
  post-insert `move_to_end` (a fresh insert is already most-recent).

- gateway/platforms/feishu.py: drop the same redundant post-insert
  `move_to_end` from the salvaged _message_text_cache LRU (#23706).

- scripts/release.py: add AUTHOR_MAP entries for the salvaged commits'
  authors (amathxbt #22155, ErnestHysa #32636/#32708) so the contributor
  audit passes when these commits land on main.

- tests/tools/test_tool_output_limits.py: autouse fixture resets the new
  module-level limits cache between tests.

- tests/gateway/test_feishu.py: hand-built adapter fixture seeded
  _message_text_cache as a plain dict; it's now an OrderedDict, so the
  fixture type had to match.
2026-05-31 00:50:19 -07:00
ErnestHysa 0036c72923 fix(gateway): upgrade plugin/bundle error logging and fix O(n^2) watcher recovery
N43 — Silent plugin/bundle errors:
- Plugin command dispatch: logger.debug() -> logger.warning()
- Bundle dispatch: logger.debug() -> logger.warning()
Plugin/auth failures are no longer invisible to operators.

N42 — O(n^2) pending_watchers recovery:
- Both recovery loops (startup + per-message) used while+pop(0) which is O(n) per pop
- Replaced with enumerate() over the list + periodic asyncio.sleep(0) yield points
- Clears the list after iteration instead of per-pop
- Batch size of 100 balances throughput vs event-loop responsiveness
2026-05-31 00:50:19 -07:00
ErnestHysa eb9bfd3924 fix(T5): replace time.sleep(0.25) with asyncio.sleep in MCP auth reconnect poll
PAIN BEFORE:
Inside _handle_auth_error_and_retry() (a sync function that runs on the MCP
event loop thread), there was a blocking polling loop:

    while time.monotonic() < deadline:
        if srv.session is not None and srv._ready.is_set():
            break
        time.sleep(0.25)   # BLOCKS THE ENTIRE EVENT LOOP

Since _handle_auth_error_and_retry is invoked from tool handlers that run ON
the MCP event loop, time.sleep(0.25) blocked ALL concurrent MCP operations
(including other tools, keepalive heartbeats, OAuth refreshes) for 250ms per
iteration. With a 15-second deadline, worst case = 60 * 250ms = 15 seconds
of fully blocked concurrency.

WHAT WAS FIXED:
Extracted the blocking poll into an async helper _await_ready() that uses
asyncio.sleep(0.25) (non-blocking), and runs it via _run_on_mcp_loop().
_run_on_mcp_loop() properly awaits the coroutine on the event loop without
blocking the caller's thread. Added exception handling around the poll so
stuck reconnects still fall through to the error path.

The sync _handle_auth_error_and_retry now:
1. Fires reconnect signal (threadsafe)
2. Calls _run_on_mcp_loop(_await_ready(), timeout=15) — non-blocking
3. Returns; the event loop handles the polling

File: tools/mcp_tool.py
Lines: _handle_auth_error_and_retry() (~1886-1920)

Found by: exhaustive multi-pass audit (10 strategies, 1901 files, 913K lines)
2026-05-31 00:50:19 -07:00
AMATH 91a98d1519 fix: tool_output_limits re-reads config on every call (no caching) 2026-05-31 00:50:19 -07:00
Yuan Li 3c21fed099 fix(bluebubbles): cap _guid_cache with LRU eviction to prevent unbounded growth
The _guid_cache dict grows without bound as new contacts/groups are
resolved.  In a long-running gateway instance with many unique targets
this becomes a slow memory leak.

Replace the plain dict with an OrderedDict capped at 500 entries.
When the cap is exceeded the oldest (least-recently-used) entries are
evicted.
2026-05-31 00:50:19 -07:00
EloquentBrush e8cacb57d5 fix(feishu): cap _message_text_cache with LRU eviction to prevent unbounded growth
_message_text_cache was a plain dict with no size limit. Every unique
message_id whose text was fetched (for reply-context lookups) stayed in
memory permanently, causing unbounded growth in long-running deployments
with active group chats.

Replace with an OrderedDict and evict the least-recently-used entry
whenever the cache exceeds _FEISHU_MESSAGE_TEXT_CACHE_SIZE (512). Cache
hits call move_to_end() to refresh LRU order. Mirrors the identical
pattern already used by _pending_processing_reactions in the same class.
2026-05-31 00:50:19 -07:00
Teknium e1293bde4e feat(models): refresh model catalog hourly instead of daily (#35756)
Lower the model_catalog disk-cache TTL from 24h to 1h so freshly
published model-catalog.json deploys reach the picker within an hour
instead of up to a day. The picker now refetches on the next
`hermes model` / `/model` once the cache is older than 1h; younger
than 1h still serves the cache (no network hit), and network failures
still fall back to the stale copy.

- DEFAULT_TTL_HOURS 24 -> 1 (model_catalog.py)
- DEFAULT_CONFIG model_catalog.ttl_hours 24 -> 1, _config_version 24 -> 25
- migration v24->25 rewrites a stale ttl_hours:24 to 1, preserving any
  custom value the user set

E2E: verified >1h refetches / <1h skips, and migration rewrites 24->1
while preserving a custom 6.
2026-05-31 00:29:40 -07:00
Teknium ca03486b6a fix(streaming): stop duplicating tool-call args from cumulative-resend providers (#35718)
DeepSeek / Baidu Qianfan stream tool-call arguments in cumulative mode:
each chunk resends the full arguments-so-far instead of the new fragment.
The stream accumulator blindly concatenated arg deltas with +=, turning
that into '{...}{...}{...}', which failed json.loads and got nuked to '{}'
— a silently corrupted tool call (#35592). Worse on multi-param tools
(search_files, session_search, memory replace) because longer args take
more chunks, giving more resend opportunities.

- Per-slot cumulative latch in the stream accumulator: a delta that is a
  strict superset of the accumulated buffer marks the slot cumulative and
  replaces (not appends); exact duplicates are dropped only after latching.
  Incremental fragments are untouched (default += path).
- Backstop _collapse_repeated_json_arguments() in the repair pipeline
  collapses pure identical-resend buffers (K exact repeats of a valid-JSON
  unit) for providers that resend the complete object from chunk 1. Only
  reached after json.loads already failed, so compliant single objects are
  never touched.

Not a gateway or DeepSeek-model bug — any OpenAI-wire provider in
cumulative streaming mode is affected.
2026-05-31 00:19:39 -07:00
Teknium 0ffbcbbe7d fix(vision): cap embedded image size before it wedges a session (#35732)
Resize vision tool-result images down to a 4 MB embed cap at load time,
not just at the 20 MB hard ceiling. A 5-20 MB image previously sailed
through the native fast path and got baked into conversation history,
where Anthropic's 5 MB per-image base64 limit rejected every subsequent
turn with a 400 — and because history is immutable, retries could never
clear it, permanently wedging the session.

Also harden the reactive shrink-recovery: it now returns False (don't
retry) when any oversized image part can't be brought under target, so
the single retry isn't burned re-sending a payload that will fail
identically. Previously it returned True after shrinking *any* part,
even when the actual oversized culprit survived.
2026-05-31 00:12:09 -07:00
Teknium d4e7b2fc19 fix(voice): allow /voice over SSH when a sound server is reachable (#35719)
SSH sessions hard-failed voice mode on the presence of SSH_* env vars
alone, even when a PulseAudio/PipeWire server is running on the host and
audio works (ffplay/aplay/pw-play -> pulseaudio). Probe the default
sound-server sockets (PULSE_SERVER unix path, PULSE_RUNTIME_PATH/native,
$XDG_RUNTIME_DIR/{pulse/native,pipewire-0}) and actually connect() so a
stale socket doesn't count; downgrade the SSH branch to a notice when
audio is reachable. Mirrors the existing Docker/WSL forwarding handling.

Fixes #35622
2026-05-31 00:11:52 -07:00
Teknium d276018378 docs(toolsets): clarify all/* wildcard does not enable kanban (#35729)
The all/* wildcard expands to every registered toolset, but a handful of
tools have an additional check_fn gate on top of toolset membership and
are intentionally NOT turned on by all/* alone:

- Capability-gated tools (browser, computer_use, code_execution, Feishu,
  Home Assistant, cronjob) require their backend/credential prerequisite.
- The kanban toolset is workflow-gated and deliberately opt-in. Kanban
  tools mutate shared board state, so they stay off by default even under
  all/* — you must list 'kanban' by name (or be a dispatcher-spawned
  worker with HERMES_KANBAN_TASK set).

This was the expectations gap behind #35581 — the docs previously said
all/* expands to 'every registered toolset' without noting the carve-out.

Closes #35581.
2026-05-31 00:10:50 -07:00
teknium1 bd72d333dc fix(gateway,cron): reuse existing _HERMES_GATEWAY marker; tighten cron regex
Follow-up to the salvaged #30728:
- Gateway already exports _HERMES_GATEWAY=1 at startup (gateway/run.py) and
  cli.py already keys off it. Drop the redundant new HERMES_IN_GATEWAY var;
  guard stop/restart on _HERMES_GATEWAY instead. One marker for one fact.
- Drop the greedy \bgateway.*restart alternation from the cron lifecycle
  filter — it false-positived on legit prompts that merely mention an
  unrelated gateway + a restart (API/payment gateway monitoring). The
  specific 'hermes gateway (restart|stop|start)' pattern already covers the
  real command.
- Rework the two negative guard tests to sentinel the first downstream call
  so they don't drive real signal delivery (tripped the live-system guard).
- Add false-positive regression cases to test_safe_commands.
2026-05-30 23:05:56 -07:00
simokiihamaki 5cd6c1717d fix(gateway,cron): prevent agent restart loops via self-targeting gateway commands (#30719)
Three defenses against SIGTERM-respawn loops when agent schedules its
own gateway restart under launchd/systemd KeepAlive:

1. HERMES_IN_GATEWAY env var: gateway sets it at startup; stop/restart
   subcommands refuse to run when set (exit 1 with clear message).

2. Cron create payload filter: regex pre-flight rejects prompts/scripts
   containing hermes gateway restart/stop, launchctl kickstart/unload,
   systemctl restart/stop, and pkill patterns.

3. 30 new tests: pattern matching (14), cron block (5), gateway guard (4),
   safe command negatives (7).
2026-05-30 23:05:56 -07:00
Teknium 9b78f411c8 fix(security): neutralize file paths in mutation-verifier footer (#35584) (#35684)
The per-turn file-mutation verifier footer rendered failed-write paths as
bare absolute paths in the user-facing response. The gateway's
extract_local_files() scans response text for bare paths ending in a
deliverable extension (.yaml/.json/etc.), validates os.path.isfile(), and
auto-attaches matches as native uploads — so a denied write to
~/.hermes/config.yaml surfaced the path in the footer and got the
credential file silently uploaded to the messaging channel.

The gateway denylist (validate_media_delivery_path) already blocks the
config.yaml case after #35634. This is defense-in-depth at the source:
backtick-wrap every path the footer emits — both the bullet path and any
path echoed inside the tool's error preview (the protected-file denial
message embeds the path in single quotes, which do NOT block the
extractor regex). extract_local_files skips paths inside inline-code
spans, so wrapping defeats auto-attachment for ANY protected file while
keeping the path human-readable.

- run_agent.py: _format_file_mutation_failure_footer wraps bullet paths;
  new _neutralize_footer_paths backticks any remaining bare path (covers
  the preview echo). staticmethod -> classmethod (caller unaffected).
- tests: backtick-wrap assertion + end-to-end extract_local_files leak test.
2026-05-30 23:05:23 -07:00
Teknium dc4de14377 fix(telegram): retry on httpx pool timeout instead of dropping the send (#35664)
When PTB's general httpx pool is exhausted, it converts httpx.PoolTimeout
into telegram.error.TimedOut whose message states the request was *not*
sent to Telegram. The send retry loop treated all non-connect TimedOut as
non-retryable, so a pool timeout raised immediately, skipped all 3 retry
attempts, and was returned as retryable=False -- silently dropping the
message (agent responses, cron reports, etc.).

A pool timeout means the request never left the process, making it the
safest case to retry. Add _looks_like_pool_timeout() and treat it like a
connect timeout in both the in-loop retry decision and the outer retryable
determination, so pool timeouts flow through the existing backoff loop and
stay retryable on exhaustion.

Reported-by: q3874758 (#35610)
2026-05-30 22:58:16 -07:00
LeonSGP43 02d1da49de Block Hermes root config in media delivery 2026-05-30 21:02:36 -07:00
Teknium 50db2d9c12 feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists

deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).

* refactor(models): trim redundant variants, group curated lists by maker

Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).

* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists

Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).

* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures

The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
teknium1 fe62424ac4 test(redact): assert Discord mentions pass through unchanged
Rewrite TestDiscordMentions as negative assertions (mentions survive the
redactor) and clean up the orphaned comment + dangling whitespace left by
removing _DISCORD_MENTION_RE. Follow-up to the salvaged #32259 fix for #35611.
2026-05-30 20:48:41 -07:00
BarnacleBoy c2cbe2c97d fix: remove Discord mention redaction from secret scrubber 2026-05-30 20:48:41 -07:00
Teknium 9ed9af2f7d fix(update): name new config options in migration prompt; skip prompt for pure version bumps (#35658)
The 'hermes update' config-migration prompt printed only counts ('1 new
config option available') then asked 'configure them now?' without ever
saying what the options were. Users said no because they couldn't tell what
they were agreeing to. For pure config-format version bumps (no new
env/config keys) it still asked the question, where saying yes just bumped
the version and looked like a no-op.

- List each new env var / config key by name + description before prompting
  (cap at 8, then '… and N more'). The data was already available; we just
  threw it away and printed a count.
- Pure version bump (no new options): apply the format migration
  non-interactively and print what happened, instead of asking a misleading
  yes/no.

Reported by ScottFive and Tt2021.
2026-05-30 20:42:37 -07:00
Teknium b1d34cf6e2 fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657)
Some hosts (notably WSL) report a junk window size such as 131072 columns
by 1 row. Both the Ink fork and our components only guard against
0/null/undefined/NaN (stdout.columns || 80), so a positive-but-absurd
width sails through into createScreen(width*height), allocating tens to
hundreds of MB per frame and tripping the TUI memory monitor's hard exit.

Add clampStdoutDimensions(), installed in entry.tsx before ink.render: it
patches process.stdout.columns/rows with clamping getters (cols 1-2000,
rows 1-1000; out-of-range -> 80x24). One install point fixes the renderer,
its resize handler, and every component read. Live resizes still propagate
through the original descriptor, just clamped.
2026-05-30 20:42:30 -07:00
brooklyn!andCopilot Autofix powered by AI cd067ab91e fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512)
* fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer

When the Node event loop blocks during a heavy render/tool-call burst, stdin
stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel
buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND
coordinate digits chewed off across many partial reads. The existing fragment
recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]`
triples, so the degraded shards leak into the composer as typed text — the user
can no longer type or exit until the stall clears.

Captured leak (Windows Terminal, during tool calls):

  M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M
  48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M
  M18M99;26M32MM38M63;44M47MM1;51M M4M54M

Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path:

- MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only
  from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries
  the structural signature of mouse coordinates (>=3 M/m terminators, a digit,
  and a `;`), swallow it wholesale.
- MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and
  after recovered fragments, so a partially-recovered burst doesn't trail a
  chewed-up tail into the prompt.

All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no
digit/`;`, `see 1;2;3M for details` has disqualifying letters, and
`1234;56;78M9;10;11M` has only two terminators — none are swallowed.

This is defense-in-depth: it stops the leak/lockout regardless of what blocks
the loop. The underlying event-loop stall during streaming is a separate,
still-open issue that needs live-turn instrumentation to root-cause.

* fix(tui): check mouse-burst noise before fragment recovery; drop test cast

Copilot review on #35512:

- MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments
  returned null. A noise blob that contains any intact `<b;c;r M` fragment makes
  fragment recovery return non-null, so the whole-text swallow never fired and
  the code emitted a pile of recovered mouse events instead of dropping the blob
  wholesale (contradicting the comment, and doing extra work mid-stall). Move the
  noise check ahead of fragment recovery so pure-noise tokens are dropped early.
  Add a regression test for a noise blob carrying intact fragments.

- Drop the unnecessary `(e as { isPasted?: boolean })` cast in the test;
  discriminated-union narrowing on `e.kind === 'key'` exposes isPasted directly.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-30 22:27:14 -05:00
helix4u 355af2c20f fix(session): survive missing FTS5 runtimes 2026-05-30 18:59:08 -07:00
Teknium ec67def5bf fix(install): refresh stale uv so installs actually get FTS5 Python (#35541)
The installer's ensure_fts5() handled a no-FTS5 Python by running
'uv python install --reinstall', but WHICH Python builds a uv can
install is baked into the uv binary's download manifest. A stale uv
(e.g. 'pip install uv==0.7.20', which predates python-build-standalone
#694) only knows about pre-FTS5 builds, so --reinstall just pulls the
same FTS5-less interpreter — a no-op for FTS5. Result: 'Could not obtain
an FTS5-capable Python' and a broken session search even on the
supported installer path.

ensure_fts5() now escalates uv itself: reinstall with current uv ->
'uv self update' + reinstall (stale standalone uv) -> install a fresh
standalone uv into a temp dir and reinstall with that (externally-managed
uv that can't self-update, the reported case). Pythons live in uv's
shared store, so the fresh uv's --reinstall overwrites the stale
interpreter in place and the installer's later 'uv python find' resolves
to the FTS5-capable build.

Verified against the reporter's exact repro (ubuntu:24.04 +
pip install uv==0.7.20): Python 3.11.13 (no FTS5) -> 3.11.15 (FTS5).
2026-05-30 18:59:05 -07:00
teknium1andJezzaHehn 4ec0adebe8 fix(gateway): denylist config.yaml for media delivery (belt-and-suspenders)
Defense-in-depth on top of the EphemeralReply gate: even if a config.yaml
path reaches response text via some other path, it can never be delivered
as a native attachment. Matches existing protection for .env, auth.json,
and credentials/.

Co-authored-by: JezzaHehn <jezzahehn@gmail.com>
2026-05-30 18:58:46 -07:00
helix4u bdfba45247 fix(gateway): stop system tips from auto-uploading local files 2026-05-30 18:58:46 -07:00
Teknium b1a25404b6 perf(read_file): make compact gutter the only format; drop HERMES_READ_GUTTER (#35532)
The compact "<n>|content" gutter from #35368 is now the sole behavior.
Removes the HERMES_READ_GUTTER=padded escape hatch and its env lookup —
no legacy fixed-width path to maintain. Padding was pure token overhead
(~48% more tokens than bare content, ~16% more than compact) with no
measured accuracy gain in the original A/B.

- file_operations.py: drop env lookup + os import; gutter always f"{i}|{line}"
- tests: drop the padded env-override test; compact assertions retained
2026-05-30 14:38:30 -07:00
brooklyn! 5921d66785 fix(cli): stop OSC 11 bg probe from trapping users in a stray editor (#35441)
Over SSH the OSC 11 background-color query round-trip routinely exceeds
the 100ms read budget, so _query_osc11_background() gives up and the late
reply lands after prompt_toolkit has grabbed the tty. prompt_toolkit then
injects the OSC payload as typed text and reads its BEL terminator
(\x07 = Ctrl+G) as a keystroke — Ctrl+G is the open-external-editor
binding, dropping the user into vi with garbage and no obvious way out.

- Skip the OSC 11 probe on remote sessions (SSH_CONNECTION/CLIENT/TTY);
  fall back to COLORFGBG / env hints / the dark default.
- Restore the tty with TCSAFLUSH instead of TCSANOW so any partial/late
  reply is scrubbed from the input buffer before pt reads it.
2026-05-30 11:55:12 -05:00
Sylw3ster 6a72af044c fix(managed-gateway): keep tool availability scans off the Nous token-refresh path 2026-05-30 07:58:08 -07:00
Teknium 96643b4a52 fix(file-tools): anchor relative-path resolution to absolute base; report resolved path (#35399)
Relative paths in write_file/patch could resolve against the agent PROCESS cwd
instead of the terminal's working directory. In a git-worktree session with a
stale TERMINAL_CWD='.' (a relative base), early edits silently landed in the
MAIN checkout, verified there, and reported success — while the agent inspected
the worktree and saw nothing, misreading it as the patch tool no-op'ing.

- _resolve_base_dir(): resolution base is now ALWAYS absolute. A relative
  TERMINAL_CWD is anchored to the process cwd once, deterministically, instead
  of being left to resolve()-time cwd. Live terminal cwd stays authoritative.
- write_file/patch pass the resolved absolute path to the shell FileOps layer
  so the tool layer and shell layer can't disagree about which file is edited.
- Responses now report the absolute resolved_path and files_modified, so a
  wrong-cwd mismatch is visible on the first call.
- _path_resolution_warning(): emits a _warning when a relative path resolves
  OUTSIDE the live terminal cwd (e.g. a worktree session writing into main).

Validation: 11 new unit tests + 43 live E2E assertions (worktree routing,
mid-session cd, V4A patches, divergence warning, absolute paths, consecutive
patches); 466 existing file/path/terminal tests green.
2026-05-30 07:55:36 -07:00
Sylw3ster 0c6e133c04 perf(cli): stop eager MCP discovery from blocking agent-capable startup 2026-05-30 07:45:26 -07:00
Teknium b47cb1bbf2 feat(kanban): file attachments on tasks (#35395)
Tasks can now carry file attachments (PDFs, images, source docs) that
workers read directly — closes the gap where source material had to be
pasted as a path into the task body.

- kanban_db: task_attachments table (additive), Attachment dataclass,
  add/list/get/delete accessors, attachments_root/task_attachments_dir
  path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override)
- build_worker_context: surfaces each attachment's absolute path so the
  worker (full file/terminal tool access) reads it via read_file/pdftotext
- dashboard API: POST/GET/DELETE attachment routes (multipart upload,
  25MB cap, traversal-safe filenames, root-containment check on download)
- dashboard UI: Attachments section in the task drawer — upload button,
  list with download, per-row remove
- docs + tests (13 cases: DB accessors, REST round-trip, traversal
  rejection, collision suffixing, worker-context surfacing)

Closes #35338
2026-05-30 07:41:04 -07:00
teknium1 20d073fd0b test: update extract_local_files Windows-path test for new matching behavior
test_windows_path_not_matched asserted the pre-fix POSIX-only behavior.
The Windows drive-letter support now intentionally matches these paths,
so replace it with parametrized positive cases plus a relative-path
negative guard, mirroring tests/gateway/test_platform_base.py.
2026-05-30 07:38:03 -07:00
teknium1 1b955450e3 test: use raw docstring in test_run_tool_media_re to silence escape warning 2026-05-30 07:38:03 -07:00
Tranquil-Flow 51d165a8e7 fix(gateway): support Windows absolute paths in MEDIA tag regex and extract_local_files (#34632)
The MEDIA_TAG_CLEANUP_RE and extract_local_files path regex both used
(?:~/|/) to anchor paths, which only matches Unix-style absolute and
home-relative paths. Two additional _TOOL_MEDIA_RE patterns in run.py
had the same limitation. Windows absolute paths (C:\Users\..., D:/...)
were silently ignored, causing MEDIA directive delivery to fail.

Add [A-Za-z]:[/\\] as a third anchor alternative in all four regex
locations (base.py x2, run.py x2). Also update path separators in
extract_local_files from / to [/\\] so it can traverse Windows
directory trees.

Revert accidental + quantifier in MEDIA_TAG_CLEANUP_RE lookahead
that changed match-one to match-one-or-more (unrelated to fix).

Fixes: #34632
2026-05-30 07:38:03 -07:00
Teknium 45465b0d5d fix(gateway): never auto-pause platforms on transient network/DNS failures (#35387)
The per-platform reconnect watcher auto-paused a platform after 10
consecutive reconnect failures, setting next_retry=inf and requiring a
manual /platform resume to recover. But both pause sites only ever fire
on *retryable* failures — non-retryable errors (bad auth) already drop
out of the retry queue earlier. So a transient DNS outage that spanned
the watcher's backoff window would silently park the bot forever, even
after connectivity returned.

The watcher's own docstring already promised 'retryable failures keep
retrying at the backoff cap indefinitely' — the code contradicted it.

Remove the auto-pause from both reconnect-failure branches. Retryable
failures now retry at the 5-min backoff cap forever and self-heal once
the network recovers. The circuit breaker (_pause_failed_platform /
_resume_paused_platform) stays for manual /platform pause|resume.

Fixes #35284.
2026-05-30 07:33:34 -07:00
teknium1 cddb7283d9 fix(gateway): config.yaml path for WhatsApp/Weixin text-batch delays
Convert the salvaged text-debounce delays from HERMES_* env vars to
config.yaml (gateway.platforms.<name>.extra.text_batch_delay_seconds /
text_batch_split_delay_seconds), per the '.env is for secrets only'
policy. Adds a finite/non-negative guard so bad YAML values fall back to
the defaults instead of crashing asyncio.sleep().

- whatsapp.py / weixin.py: read delays via _coerce_float_extra(config.extra)
- update Weixin content-dedup regression test for the deferred dispatch path
- add text-debounce coverage (whatsapp + weixin): defaults, config override,
  bad-value fallback, env-var-ignored, burst-collapse, lone-message
- docs: WhatsApp + Weixin config keys
2026-05-30 07:33:15 -07:00
RedPiggy b0ce47daac feat: add text debounce batching for WhatsApp and WeChat platforms
WhatsApp and WeChat (Weixin/iLink) both deliver messages individually
without any client-side batching, so rapid multi-message bursts (forwarded
batches, paste-splits, etc.) each trigger a separate agent invocation.

This wastes tokens (redundant system prompts / context for each fragment)
and degrades UX (the user receives reply fragments instead of a single
coherent response).

Both adapters now mirror the Telegram adapter's proven text-debounce
pattern:

- _text_batch_delay_seconds / _text_batch_split_delay_seconds
  (configurable via env vars)
- _pending_text_batches dict for per-session aggregation
- _enqueue_text_event() concatenates successive TEXT messages and
  resets the flush timer
- _flush_text_batch() dispatches after the quiet period expires

Configurable via env vars:
  HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS (default 5.0)
  HERMES_WHATSAPP_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 10.0)
  HERMES_WEIXIN_TEXT_BATCH_DELAY_SECONDS (default 3.0)
  HERMES_WEIXIN_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 5.0)
2026-05-30 07:33:15 -07:00
Tekniumandpxdsgnco 234ac00937 fix(dashboard): allow insecure WS peers on explicit non-loopback binds (#35386)
The merged 0.0.0.0/:: insecure-bind fix (#35141) did not cover binding
directly to a specific non-loopback address (e.g. a Tailscale/LAN IP via
--host 100.64.0.10 --insecure). In that mode the dashboard HTML loaded but
every WebSocket upgrade was rejected by the loopback-only peer guard, so
/chat connected then silently received no data.

Generalize _ws_client_is_allowed to lift the loopback-only peer gate for
any explicit non-loopback bound host, not just the 0.0.0.0/:: wildcard.
DNS-rebinding stays blocked: _ws_host_origin_is_allowed already requires
the Host header to exactly match the bound interface for explicit binds,
mirroring _is_accepted_host on the HTTP layer.

Co-authored-by: pxdsgnco <14163800+pxdsgnco@users.noreply.github.com>
2026-05-30 07:33:02 -07:00
tekniumandsweetcornna 433bffff51 fix(cli): surface oneshot agent exceptions to stderr with rc=1
Layer an exception guard on top of the empty-response fix so a crash
inside the agent (e.g. OSError from prompt_toolkit/Vt100 when stdout is a
non-TTY pipe, per #30623) is surfaced on the real stderr with rc=1 instead
of crashing past the redirect_stderr block. KeyboardInterrupt/SystemExit
are re-raised so Ctrl-C and explicit exits still propagate.

Also map briancl2 in scripts/release.py AUTHOR_MAP for the cherry-picked
empty-response commit.

Adapts the exception-guard approach from sweetcornna's PR #33818.

Co-authored-by: sweetcornna <96944678+ymylive@users.noreply.github.com>
2026-05-30 07:31:48 -07:00
Brian LaFlamme 9fbde54b51 fix(cli): fail closed on empty oneshot responses 2026-05-30 07:31:48 -07:00
Teknium 92ad7cc62c fix(browser): recover from CDP DOM-node serialization crash in browser_console (#35385)
browser_console(expression="document.body") returned the cryptic CDP error
"Object reference chain is too long" instead of a usable result.

With returnByValue=true, Chrome deep-serializes the eval result; for a live
DOM Node/NodeList/Window that serialization overruns CDP's recursion guard
and fails the whole call with a protocol-level error (not a JS exception),
which _browser_eval surfaced raw.

- browser_supervisor.evaluate_runtime: on that specific error, retry once
  with returnByValue=false so Chrome returns the node's description string —
  the same graceful path already used for document.querySelector() results.
- browser_tool._browser_eval (CLI subprocess fallback): the subprocess can't
  retry, so convert the reference-chain error into actionable guidance
  (extract a primitive / use JSON.stringify) instead of leaking it raw.

No expression rewriting — normal evals (1+41 -> 42) are untouched.
2026-05-30 07:31:25 -07:00
Teknium 42bbd221e8 fix(compressor): strip stale handoff prefix on resume; reconcile #26290+#32787 (#35344)
A handoff persisted under an older SUMMARY_PREFIX can be inherited into a
resumed lineage. _strip_summary_prefix only matched the current/legacy
literal, so on re-compaction the old 'resume exactly from Active Task'
directive stayed embedded in the body and kept hijacking replies to new,
unrelated user messages.

- Add _HISTORICAL_SUMMARY_PREFIXES (pre-#35344 prefix) and strip/recognize
  them in _strip_summary_prefix + _is_context_summary_content so resumed
  stale handoffs are re-normalized to the current latest-message-wins prefix.
- Reconcile the overlapping Active Task template edits from the salvaged
  #26290 (reverse-signal cancellation) and #32787 (capture open questions /
  decisions, don't write None too eagerly) — both intents kept.
- Regression coverage in tests/agent/test_resume_stale_active_task.py.
- AUTHOR_MAP entries for both salvaged contributors.
2026-05-30 07:29:21 -07:00
Mathijs van den Hurk 56b8dccf25 fix(compressor): treat unanswered user questions as Active Task, not 'None'
The Active Task field in compression summaries is the single most important
field for task continuity across context boundaries. The previous template
described it narrowly as a 'task assignment' or 'request', which caused the
summary LLM to write 'None' whenever the user's most recent input was a
question, a decision request, or a discussion turn rather than an
imperative command. The assistant on the other side of the compaction then
treated the conversation as resolved and gave a generic recap instead of
answering the still-open question.

Expand the template guidance to cover:

  * explicit task assignments
  * questions awaiting an answer
  * decisions awaiting input (A vs B)
  * ongoing discussions where the assistant owes the next substantive reply

Reserve 'None' for the rare case where the last exchange was fully
resolved (e.g. user said 'thanks, that's all').

Also tighten the trailing CRITICAL instruction in the summary prompt so the
LLM cannot fall back to the old 'no imperative command → None' heuristic.

No behavioural code changes — template strings only. All 83 existing
compressor tests pass.
2026-05-30 07:29:21 -07:00
Zhipeng Li 020601d41e fix(compression): drop conflicting 'resume Active Task' directive in summary prefix
SUMMARY_PREFIX previously contained two contradictory directives:

1. "treat it as background reference, NOT as active instructions"
   "Do NOT answer questions or fulfill requests mentioned in this summary"
   "Respond ONLY to the latest user message that appears AFTER this summary"

2. "Your current task is identified in the '## Active Task' section of the
    summary — resume exactly from there."

When the latest user message contradicted Active Task (e.g. 'stop the
i18n refactor', 'never mind, look at grafana instead'), models tended to
follow (2) anyway because 'resume exactly' is a strong, unambiguous
directive — leading to repeated re-surfacing of already-cancelled work
across turns, even after explicit 'stop'/'don't keep bringing that up'
messages from the user.

This change:
- Removes the conflicting 'resume exactly from Active Task' clause.
- Makes the precedence explicit: latest user message is the single source
  of truth; it WINS on conflict; cancelled Active Task / In Progress /
  Pending User Asks / Remaining Work must be discarded entirely (no
  'wrap up the old task first').
- Names canonical reverse signals (stop, undo, roll back, never mind,
  just verify, topic change) so the model recognizes them as cancellation
  triggers, not background context.
- Updates the summarizer template instruction so the LLM doesn't
  mechanically copy a cancelled task into Active Task on the next
  compaction (it's instructed to copy the reverse signal verbatim).
- Preserves: REFERENCE ONLY framing, MEMORY.md/USER.md authority, and
  the 'don't repeat work already reflected in session state' clause.

Adds tests/agent/test_summary_prefix_semantics.py to pin invariants so
the conflict can't regress.

Tested:
- All compaction tests pass: tests/agent/test_context_compressor.py,
  tests/agent/test_context_compressor_summary_continuity.py,
  tests/run_agent/test_413_compression.py,
  tests/run_agent/test_compression_persistence.py,
  tests/run_agent/test_compression_boundary_hook.py,
  tests/cli/test_manual_compress.py — 117/117 passing.
- Tested on macOS.
2026-05-30 07:29:21 -07:00
teknium1 182739fcda test(interrupt): assert no leaked tid instead of no-op block
Follow-up on the #35309 regression test: the trailing `with _lock: pass`
asserted nothing. Replace it with a concrete assertion that
_interrupted_threads is empty after the worker exits, directly verifying
the leak the fix prevents.
2026-05-30 07:28:11 -07:00
liuhao1024 bede3cf12d fix(tools): wrap _run_tool cleanup in finally to prevent interrupt state leak
When _invoke_tool raises a BaseException (CancelledError, KeyboardInterrupt),
the cleanup code at the end of _run_tool was bypassed because it sat outside
the except block (which only catches Exception).  ThreadPoolExecutor recycles
thread IDs, so the leaked tid in _interrupted_threads poisons the next tool
scheduled on that thread — it instantly aborts with 'Interrupted'.

Move the discard + _set_interrupt(False) into a finally block so cleanup
runs regardless of how the worker exits.

Fixes #35309
2026-05-30 07:28:11 -07:00
Teknium 2b16b756a7 fix(gateway): recover model on post-interrupt turn; gate fallback status (#35381)
Empty model could reach the API on a recovery turn after stream_interrupt_abort,
failing HTTP 400 "No models provided" with no recovery — the session went
silent until the user manually re-sent (#35314).

- gateway/run.py: cache last-successfully-resolved model per session (+ a
  process-wide slot); when a fresh config read returns an empty model on a
  recovery turn, reuse the last-known-good instead of building model="".
- run_agent.py + agent/conversation_loop.py: only emit "trying fallback..."
  status when a fallback chain actually exists, so the UI stops announcing a
  fallback that will never run (also #17446).
- tests: empty-model recovery + _has_pending_fallback gate.
2026-05-30 07:28:06 -07:00
Teknium 10dec7c6dc fix(kanban): respect mobile safe areas in task detail drawer (#35378)
* fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch

Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text
files. We had no awareness of it, so: read_file surfaced a phantom
U+FEFF as the first character; patch matches against the true first
line could miss; and a write/patch round-trip silently stripped the
marker, changing the file's byte signature.

Now:
- read_file / read_file_raw strip a single leading BOM so the model
  never sees it (only on the first chunk — the marker lives at byte 0).
- patch_replace strips the BOM before fuzzy-matching (so an exact
  first-line match works) and its post-write verification compares
  BOM-stripped content.
- write_file restores the BOM when the original file had one and the
  new content doesn't, mirroring the existing line-ending preservation
  (detect on disk via a cheap `head -c 3` probe or reuse pre_content,
  re-prepend across the edit). Guards against double-BOM.

Mid-content U+FEFF is left alone (it's data there, not a file marker).

Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read
strips, write preserves, no-BOM-when-original-had-none, no-double-BOM,
patch round-trip preserves, patch matches first line through a BOM,
plus helper unit tests. 208 file-tool tests green.

* fix(kanban): respect mobile safe areas in task detail drawer

The task detail drawer is a body-level z-60 fixed overlay using
height:100vh starting at the viewport top. On mobile this puts the
drawer header behind the dashboard's fixed top bar (min-h-14, z-40)
and lets the bottom comment input sit under the browser's collapsing
nav bar.

- drawer: 100vh -> 100dvh (+ max-height:100dvh), 100vh kept as fallback
- head: padding-top honors env(safe-area-inset-top); mobile (<1024px,
  matching the lg breakpoint where the fixed bar shows) clears the
  3.5rem header
- comment-row + body: bottom padding extended with
  env(safe-area-inset-bottom) so the bottom-most element clears the
  mobile browser chrome

Mirrors the host shell idiom (100dvh + env(safe-area-inset-bottom) in
web/), and web/index.html already sets viewport-fit=cover so the insets
resolve. max()/calc() fallbacks leave desktop unchanged.

Closes #35324
2026-05-30 07:13:26 -07:00
Teknium ea6eaabd8f perf(read_file): compact line-number gutter — ~14% fewer tokens per read (#35368)
read_file's gutter used a fixed-width zero/space-padded prefix
("     1|content"). The padding is pure token overhead: measured with
cl100k on real Hermes source, the padded gutter costs ~48% more tokens
than bare content and ~16% more than a compact "<n>|content" gutter,
because the leading spaces tokenize into extra tokens on every line.

Switched the default to the compact "<n>|content" form. An A/B
(Sonnet 4.6 via OpenRouter, 2 passes, 4-task battery, every claim
verified against ground truth) showed:
  - padded  : 4/4 PASS both passes
  - compact : 4/4 PASS both passes  ← keeps line-referencing + patch
  - none    : 3/4 PASS both passes  ← dropping numbers entirely made
              the model hand-count lines and answer off-by-one (33 vs 34)

So we keep the line numbers (the model genuinely uses them to reference
lines) but drop the wasteful padding — capturing ~14% of the read-token
cost with zero measured accuracy change. Dropping numbers entirely
(the larger 33% saving) is rejected: it regresses line-referencing.

patch/fuzzy_match never consumed the gutter (they match old_string text
and compute char offsets internally), so editing is unaffected. No
downstream parser keys on the fixed-width columns. HERMES_READ_GUTTER=
padded restores the legacy format for anyone relying on alignment.

Tests: updated the 3 format assertions to the compact gutter; added an
env-override test for the legacy padded format. 209 file-tool tests green.
2026-05-30 07:01:22 -07:00
Teknium 5f84c9144a fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch (#35278)
Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text
files. We had no awareness of it, so: read_file surfaced a phantom
U+FEFF as the first character; patch matches against the true first
line could miss; and a write/patch round-trip silently stripped the
marker, changing the file's byte signature.

Now:
- read_file / read_file_raw strip a single leading BOM so the model
  never sees it (only on the first chunk — the marker lives at byte 0).
- patch_replace strips the BOM before fuzzy-matching (so an exact
  first-line match works) and its post-write verification compares
  BOM-stripped content.
- write_file restores the BOM when the original file had one and the
  new content doesn't, mirroring the existing line-ending preservation
  (detect on disk via a cheap `head -c 3` probe or reuse pre_content,
  re-prepend across the edit). Guards against double-BOM.

Mid-content U+FEFF is left alone (it's data there, not a file marker).

Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read
strips, write preserves, no-BOM-when-original-had-none, no-double-BOM,
patch round-trip preserves, patch matches first line through a BOM,
plus helper unit tests. 208 file-tool tests green.
2026-05-30 06:25:50 -07:00
sprmn24andClaude Sonnet 4.6 5a1aa9e68c fix(nous_account): add threading lock to prevent TOCTOU race on cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 06:25:43 -07:00
teknium1 44f3e51865 fix(gateway): run adapter config hooks for nested-only platform blocks
The plugin apply_yaml_config_fn dispatch loop only ran when a top-level
platform block (e.g. `discord:`) existed. Configs that defined a platform
only under `platforms.<name>` or `gateway.platforms.<name>` skipped the
hook, so `platforms.discord.extra.allow_from` never reached
DISCORD_ALLOWED_USERS. Fall back to those nested blocks when the top-level
one is absent.

Also map byquenox@gmail.com -> Que0x for the salvaged commits.
2026-05-30 05:23:55 -07:00
quen0xi 6d2727ef1c fix(discord): bridge explicit allow_from configuration to env var mapping 2026-05-30 05:23:55 -07:00
quen0xi 0bfe19ba17 fix(gateway): merge nested gateway.platforms configuration block 2026-05-30 05:23:55 -07:00
Teknium 61268ff7a9 feat(cli): add hermes prompt-size diagnostic (#35276)
Adds a 'hermes prompt-size' command that reports the fixed prompt budget
for a fresh session: system prompt total, skills index, memory, user
profile, prompt tiers, and tool-schema JSON bytes. Runs offline (dummy
credentials force the direct-construction path, no network call).

Lets users see which block dominates their per-call payload — the skills
index is often the largest single block when many skills are installed
(issue #34667). Zero model-tool footprint: it's a top-level CLI
subcommand, not an agent tool.

--platform <name> simulates a channel's platform hint; --json emits a
machine-readable breakdown.

Closes #34667
2026-05-30 02:53:42 -07:00
kshitijk4poor cbf851ae1d perf(tui): stop slow/dead MCP servers from freezing TUI startup
The 'summoning hermes…' phase blocked on gateway.ready, which ran MCP
tool discovery inline. Any configured-but-unreachable MCP server burned
its full connect-retry backoff (1+2+4s ≈ 7s) before the composer
appeared — startup went from instant to ~7.5s of dead air for anyone
with a down stdio/http server in mcp_servers.

Move discovery into a background daemon thread so gateway.ready fires
immediately; tools register into the shared registry as servers connect,
and the agent isn't built until the first prompt. Measured spawn→ready:
~7500ms → ~115ms (dead twozero_td server in config).

Also drop rich.console + prompt_toolkit off banner.py's import path
(lazy-imported inside cprint/build_welcome_banner). tui_gateway.server
imports banner only to reach the lightweight prefetch_update_check
helper; the eager rich/pt imports added ~45ms before gateway.ready for
no benefit. tui_gateway.server import: ~115ms → ~69ms.
2026-05-30 02:53:37 -07:00
teknium1 bfc4a26032 fix(tools): point email home-channel error at EMAIL_HOME_ADDRESS
The no-home-channel error for send_message derived the env var name
generically as <PLATFORM>_HOME_CHANNEL, producing EMAIL_HOME_CHANNEL for
the email platform. But gateway/config.py reads EMAIL_HOME_ADDRESS, so a
user following the error's guidance would set a variable that is never
consulted. Add a per-platform override map so the email hint names the
variable actually read; all other platforms keep the generic hint.
2026-05-30 02:39:08 -07:00
liuhao1024 d3724c0be6 fix(tools): recognize email addresses as explicit targets in send_message
When using send_message with the email platform, valid email addresses
like user@example.com were not recognized as explicit targets by
_parse_target_ref(). This caused the function to return (None, None,
False), forcing the system into channel-name resolution which has no
way to resolve a raw email address, resulting in 'No home channel set
for email' errors.

Add _EMAIL_TARGET_RE pattern and email platform handler in
_parse_target_ref() so email addresses are treated as explicit targets
and routed directly without requiring a home target configuration.
2026-05-30 02:39:08 -07:00
teknium1 622e534379 test(auxiliary): e2e routing assertions for custom-provider aux resolution
Adds two real-client tests on top of the salvaged #34783 fix:
- config-less custom:<name> endpoint routes via the carried live base_url
  (guards the #34777 symptom directly, not just the wiring)
- named custom:<name> WITH a config entry still resolves via the
  named-custom branch (regression guard against collapsing to bare custom)
2026-05-30 02:38:59 -07:00
liuhao1024 40fcb96585 fix(auxiliary): pass base_url/api_key/api_mode through set_runtime_main for custom providers
When a user configures a custom: provider (e.g. custom:openclaw-router),
set_runtime_main() only stored provider and model in process-local globals.
_resolve_auto() then had no base_url or api_key for the custom endpoint,
causing Step 1 to fail and auxiliary tasks (approval, compression, title
generation) to fall through to the aggregator chain and route to wrong
providers.

Fix: extend set_runtime_main() to accept base_url, api_key, and api_mode
keyword arguments; store them in new globals alongside the existing provider
and model; fall back to these globals in _resolve_auto() when the main_runtime
dict is empty. The call site in conversation_loop.py now passes all five
fields from the agent object.

Fixes #34777
2026-05-30 02:38:59 -07:00
Teknium 2475244ca0 fix(update/windows): robustly exclude launcher-shim ancestors from concurrent check (#35257)
hermes update on Windows still aborted with 'Another hermes.exe is running',
listing its own launcher shim(s) as concurrent instances (issues #29341,
#34795). The distlib Scripts\hermes.exe launcher spawns python.exe and waits;
detection runs in the python child, so the launcher shim shows up in
process_iter.

The prior fix walked the ancestor chain with per-hop current.parent() inside
'except: break' — the first psutil AccessDenied/NoSuchProcess (common on
Windows across session/elevation boundaries) bailed the walk early, leaving
the launcher in the candidate set and re-triggering the false positive.

- Switch to proc.parents() (whole ancestor list in one call), evaluate each
  ancestor independently so one unreadable hop never strands the launcher.
- Only exclude ancestors whose exe is itself a shim, so a genuine second
  hermes.exe under a non-Hermes parent (Desktop backend child) is still flagged.
- Message now prints a copy-pasteable 'taskkill /PID … /F' for the exact stale
  PIDs so a user who already closed everything can self-remediate.

Conservative shim-only ancestor approach credited to the parallel attempts in
PRs #29358 (xxxigm) and #31808 (jquesnelle).
2026-05-30 02:38:40 -07:00
Donovan YohanandDonovan Yohan 8bd00607dc fix(google-workspace): handle Gmail header casing case-insensitively
Normalize Gmail API message header names to lowercase before lookup so
gmail get/search/reply populate to/subject/from regardless of the casing
the message was stored with. Emit conventional MIME header casing
(To/Subject/Cc/From) on send and reply.

Fixes #34806

Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com>
2026-05-30 02:38:18 -07:00
beardthelion 6baf0016be fix(run_agent): gate concurrent checkpoint preflight on block_result (fixes #34827)
In the concurrent tool-execution path, checkpoint preflight (write_file,
patch, destructive terminal) fired BEFORE plugin guardrail block_result
was computed. A blocked write_file could still dirty checkpoint state
(doc_modified_this_turn, _last_write_file_call_id, turn_counter).

Move checkpoint preflight to AFTER block_result computation, gated on
`if block_result is None:` — matching the invariant the sequential path
already enforces.
2026-05-30 02:38:12 -07:00
teknium1 e1945ff697 test(state): cover update_session_model overwrite + getattr-guard text path
Follow-up to LengR's #35181 salvage:
- gateway text-path uses getattr(self, '_session_db', None) to match the
  picker callback path (defensive for object.__new__() gateway test pattern).
- add SessionDB.update_session_model test asserting it overwrites the
  COALESCE-pinned model and survives subsequent token updates (#34850).
2026-05-30 02:35:36 -07:00
lengr 794519c6ad fix(state): persist mid-session model switch to database
When a user switches models mid-session via /model, the gateway updates
the in-memory agent and session overrides, but the database was never
updated. The COALESCE(model, ?) in update_token_counts() only fills NULL
values, so the dashboard always showed the original model.

Fix: Add SessionDB.update_session_model() that unconditionally sets the
model column, and call it from both the interactive picker and direct
/model command paths in the gateway.

Fixes #34850
2026-05-30 02:35:36 -07:00
teknium1 c9e31a8e4b chore(release): map tuancookiez-hub for #34865 salvage 2026-05-30 02:08:36 -07:00
Tuna Dev 296fcdfa52 fix(lsp): handle Windows .cmd shims in LSP process spawn
asyncio.create_subprocess_exec cannot run .cmd/.bat files on Windows
because CreateProcess expects a valid PE executable. npm-installed LSP
servers (intelephense, typescript-language-server, etc.) ship as .cmd
shims on Windows, causing WinError 193 on spawn.

Detect .cmd/.bat extensions and wrap with cmd.exe /c before spawning.
Gated behind sys.platform == 'win32' — no code path changes elsewhere.

Fixes #34864
2026-05-30 02:08:36 -07:00
Sylw3ster 460771bf0f fix(lsp): detect Windows wrapper binaries in installer probes 2026-05-30 02:08:36 -07:00
teknium1 41decf2c4a test(mcp): import os and pytest in test_mcp_stability
The salvaged grandchild-reaping tests reference os.getpgid/os.killpg and
pytest.mark/skip/importorskip directly, but the file only imported asyncio,
signal, and unittest.mock. Add the missing imports so collection succeeds
on current main.
2026-05-30 02:08:29 -07:00
konsisumer a29d64e50c fix(mcp): reap stdio MCP grandchildren via process-group signal
The orphan reaper for stdio MCP subprocesses only tracked the direct child
PID spawned by ``stdio_client`` (e.g. ``openclaw mcp serve``). When that
wrapper itself spawned a helper (``claude mcp serve``) and then exited, the
helper reparented to ``systemd --user`` and survived shutdown.

The MCP SDK already spawns stdio children with ``start_new_session=True``,
so the wrapper is its own pgroup leader and same-pgroup descendants are
reachable via ``killpg``. Capture the pgid at spawn time and reap via
``killpg(pgid, sig)`` so reparented grandchildren are reaped alongside the
direct child, even after the wrapper itself exits. Falls back to per-pid
``os.kill`` on Windows or when no pgid was recorded.

Fixes part 2 (orphan ``claude mcp serve``) of #23799. Part 1 (per-invocation
respawn) was confirmed by the reporter to be an environmental artifact, not
a code bug.
2026-05-30 02:08:29 -07:00
teknium1 4d7ea3fd36 chore(release): map inchargeautomation-lab author email 2026-05-30 02:08:11 -07:00
teknium1andJoshua Kimbrell 2334228eca fix(update): handle pipx installs + --system fallback in _cmd_update_pip
Extends the uv-tool detection (briandevans, #29703) to cover the
remaining no-venv install layouts that hit the same uv 'No virtual
environment found' error:

- pipx-managed installs (sys.prefix under .../pipx/...) -> 'pipx upgrade',
  matching scripts/auto-update.sh (pipx-detection idea from
  inchargeautomation-lab, #29852)
- bare pip outside any venv -> 'uv pip install --system --upgrade'
- venv (launcher shim) keeps the VIRTUAL_ENV overlay from #35224 and never
  gets --system, so the install always targets the venv, not system Python

The four branches are mutually exclusive; VIRTUAL_ENV is exported only for
the uv-pip-in-venv path (uv tool / pipx upgrade ignore it).

Co-authored-by: Joshua Kimbrell <incharge.automation@gmail.com>
2026-05-30 02:08:11 -07:00
briandevans bebd4f8516 fix(cli): restrict uv-tool-install detection to running interpreter
Copilot review on PR #29703 flagged two issues with the `uv tool list`
fallback in `is_uv_tool_install`:

1. False positive: `uv tool list` returns the *machine*'s installed
   tools, not the active install. A regular pip/venv Hermes on a host
   that also has `uv tool install hermes-agent` available would be
   misclassified as a uv-tool install, and `hermes update` would
   upgrade the wrong copy.

2. Overhead: the subprocess call (up to a 15s timeout) was triggered
   even from `recommended_update_command_for_method`, which just
   computes a display string.

Restrict detection to properties of the running interpreter
(`sys.prefix` and `sys.executable` — both can carry the uv-tool layout
marker depending on entry point). Drop the `uv tool list` fallback and
the `uv_path` parameter entirely. `_cmd_update_pip` now also surfaces a
clear hint when the runtime looks like a uv-tool install but `uv` is
missing from PATH, instead of silently falling back to `python -m pip`.
2026-05-30 02:08:11 -07:00
briandevans 1bdb29d938 fix(cli): use uv tool upgrade when Hermes is a uv tool install (#29700)
Hermes installed via `uv tool install hermes-agent` lives outside any
venv. `_cmd_update_pip` previously ran `uv pip install --upgrade`, which
errors with `No virtual environment found; run uv venv ...`. The user
hits this on the very first `hermes update` after a standard
non-`--system` install with `uv` on PATH.

Add `is_uv_tool_install()` in `hermes_cli/config.py`: fast path inspects
`sys.prefix` for the standard `uv/tools/hermes-agent/` layout, falls
back to `uv tool list` for non-standard prefixes. Both the
user-facing `recommended_update_command_for_method("pip")` string and
the actual subprocess invocation in `_cmd_update_pip` now switch to
`uv tool upgrade hermes-agent` when detected. Non-tool installs and the
no-`uv` fallback keep their existing commands unchanged.
2026-05-30 02:08:11 -07:00
Teknium 39f6b6e9d2 fix(file-tools): make write_file/patch atomic (temp-file + rename) (#35252)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* fix(file-tools): make write_file/patch atomic (temp-file + rename)

write_file streamed content straight into the target via `cat > path`, so
a crash, SIGKILL, or truncated pipe mid-write left the file half-written
and corrupt. patch_replace routes through write_file, so it shared the flaw.

Now writes stream into a temp file in the SAME directory and `mv` it over
the target — a real same-filesystem rename, which is atomic on POSIX and on
every terminal backend (local/docker/ssh/modal). A failed write leaves the
original byte-intact and leaks no temp file. The existing file's mode is
preserved across the swap (stat + chmod, GNU/BSD), and content still rides
stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path.

Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering
inode-change-on-overwrite, mode preservation, failed-write-leaves-original,
no-temp-leak, special chars, and patch routing. Updated two mocks in
test_file_operations.py that keyed on the literal `cat >` write command to
key on the stdin_data behavioral signal instead. 200 file-tool tests green.
2026-05-30 02:07:50 -07:00
teknium1 6a08fd3c3f test(skills): assert restore via synced[copied], not manifest re-read
The hermetic CI env (slice 4/6) redirects HERMES_HOME, so a post-restore
_read_manifest() can resolve to an empty/redirected manifest path and return
{}. Assert on sync_skills's in-memory return value (synced["copied"]) instead,
which is the resilient signal that the skill was re-copied and is no longer in
limbo.
2026-05-30 02:05:10 -07:00
teknium1 8ae0802d59 fix(skills): make _rmtree_writable handle read-only directories, not just files
The cherry-picked fix's onerror handler chmod'd only the failing path, but
unlinking a child requires write permission on its PARENT directory. On a true
Nix-store copy (r-xr-xr-x dirs + files) rmtree still failed. Now chmod the
parent dir as well before retrying.

Also rewrites the regression test: the original asserted the helper FAILS on a
read-only dir (documenting the limitation), which is the wrong success criterion.
Split into two tests — restore succeeds on a full read-only tree (real Nix case),
and manifest is preserved when removal genuinely cannot proceed (monkeypatched).
2026-05-30 02:05:10 -07:00
annguyenNous 83a7d0b601 fix(skills): fix transaction ordering in reset_bundled_skill and handle read-only files in rmtree
Two related bugs in tools/skills_sync.py affecting Nix-store and
immutable-package installs:

**#34972 — reset_bundled_skill corrupts manifest on rmtree failure:**
The function deleted the manifest entry BEFORE attempting rmtree. If
rmtree failed (read-only files from Nix store), the function returned
early — leaving the skill in a manifest-less limbo state where future
syncs silently skip it forever.

Fix: reorder steps — attempt rmtree FIRST, only delete manifest entry
after rmtree succeeds. If rmtree fails, nothing is changed.

**#34860 — stale .bak directories after sync:**
sync_skills() called shutil.rmtree(backup, ignore_errors=True) which
silently failed on read-only files, leaving persistent .bak dirs.

Fix: add _rmtree_writable() helper that makes files writable via an
onerror callback before retrying removal. Used in both sync_skills()
backup cleanup and reset_bundled_skill().

Fixes #34972
Fixes #34860
2026-05-30 02:05:10 -07:00
liuhao1024 a57cc00081 fix(packaging): include mcp_serve in py-modules so hermes mcp serve works on pip installs
mcp_serve.py was missing from the setuptools py-modules list, causing
hermes mcp serve to crash with ModuleNotFoundError on standard pip installs.

Fixes #34871
2026-05-30 01:45:30 -07:00
Teknium 93e6a05efc feat(model-picker): group multi-endpoint providers under one row (#35227)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* feat(model-picker): group multi-endpoint providers under one row

The interactive provider pickers (hermes model, setup wizard, Telegram
/model) listed every provider slug flat, so vendors with several endpoints
(Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub
Copilot) each occupied multiple top-level rows. Now related slugs fold into
one top-level row that drills down to the specific endpoint.

- models.py: add PROVIDER_GROUPS table + group_providers() fold (display
  only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model>
  all unchanged and individually addressable).
- hermes model (main.py): group rows drill into a member sub-picker, then
  dispatch to the existing _model_flow_* unchanged. setup wizard inherits it.
- Telegram /model: new mpg:<group> callback expands to member mp:<slug>
  buttons; single authenticated member degrades to a direct button.
- Grouping is the single shared fold across all three surfaces.

Validation: 163 targeted tests pass; E2E confirms group->member->model
resolves to the correct concrete slug for all families.
2026-05-30 01:41:33 -07:00
LeonSGP43 14517ac1f5 fix(update): export launcher virtualenv to uv 2026-05-30 01:41:29 -07:00
teknium1 8e5a6854c3 fix(kanban): align recompute_ready guard with breaker's configured failure_limit
Follow-up to the budget-exhaustion recovery fix. recompute_ready's
new circuit-breaker guard resolved its effective limit from per-task
max_retries -> DEFAULT_FAILURE_LIMIT, skipping the dispatcher's
configured kanban.failure_limit. _record_task_failure resolves
max_retries -> failure_limit(config) -> DEFAULT, so the two disagreed
whenever an operator set kanban.failure_limit != 2:

- config > 2: a task could get stuck at DEFAULT(2) before reaching its
  allowed retry count.
- config < 2: a task the breaker already blocked could be auto-recovered
  back to ready, defeating the stricter limit.

Thread the dispatcher's failure_limit through dispatch_once into
recompute_ready so the guard and the breaker share one resolution order.
Updated test_circuit_breaker_block_still_auto_promotes (it asserted a
failures=5 block auto-recovers and resets the counter — that's the
pre-#35072 behavior the loop fix removes); it now exercises a below-limit
transient block, with the at-limit case covered in test_kanban_db.py.
Added two tests for the config-tier and per-task override resolution.
2026-05-30 01:40:57 -07:00
liuhao1024 6ab71d3bb4 fix(kanban): prevent infinite retry loop when worker exhausts iteration budget
recompute_ready() previously reset consecutive_failures to 0 when
auto-recovering a blocked task.  This defeated the circuit-breaker:
a task that repeatedly exhausted its iteration budget would cycle
forever (block → auto-recover with counter=0 → respawn → budget
exhausted → block → …) with no signal to the operator.

Fix: don't auto-recover tasks whose consecutive_failures has reached
the effective failure limit (per-task max_retries or
DEFAULT_FAILURE_LIMIT).  The counter is also preserved across
recovery so the breaker can accumulate across cycles.

Fixes #35072
2026-05-30 01:40:57 -07:00
teknium1andliuhao1024 c70dca3a88 fix(kanban): rebuild legacy TEXT-PK tables to INTEGER AUTOINCREMENT on open
Legacy kanban boards (pre-AUTOINCREMENT schema) crashed the gateway
notifier on every tick — int(None) on a NULL id in unseen_events_for_sub
— silently losing all kanban notifications. CREATE TABLE IF NOT EXISTS
skips existing tables regardless of schema and _add_column_if_missing
only adds columns, so neither could fix a drifted primary-key type.

_rebuild_drifted_tables() detects the legacy shape via PRAGMA table_info
and rebuilds task_events/task_comments/task_runs (TEXT PK -> INTEGER
AUTOINCREMENT) and kanban_notify_subs.last_event_id (TEXT/NULL -> INTEGER
NOT NULL DEFAULT 0), preserving data. The whole pass is one transaction
so an interruption can't leave a table half-renamed, and recreates every
index DROP TABLE would otherwise take down (including idx_events_run).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-05-30 01:40:49 -07:00
teknium1andBROCCOLO1D 16882cfded refactor(tui): simplify base64 clipboard write to a stdin flag
The per-entry psScript callback was identical for every PowerShell entry,
so the function-valued union member added structure without behavior. Collapse
WriteCmd to a plain stdin boolean and apply the one shared base64 script in the
write loop. Document the CP936 root cause inline.

Co-authored-by: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com>
2026-05-30 01:40:44 -07:00
annguyenNous 64998fa93e fix(tui): use base64 encoding for PowerShell clipboard writes to preserve UTF-8
When writing text to the clipboard via PowerShell (WSL2 and native Windows),
the previous implementation piped text through stdin using `Set-Clipboard
-Value $input`. PowerShell reads stdin using the Windows system's default
ANSI code page (e.g. CP936 for Chinese Windows), causing all non-ASCII
characters (CJK, emoji, accented) to become garbled.

Fix: encode the text as base64 in Node.js and pass it as a command argument.
PowerShell decodes it from base64 using explicit UTF-8, bypassing the code
page issue entirely.

Fixes #35107
2026-05-30 01:40:44 -07:00
Teknium b4cf114f68 fix(vision): fail fast on non-retryable image download errors (#35221)
_download_image() wrapped every download attempt in a blanket
`except Exception` and retried 3x with 2s/4s/8s backoff regardless of
cause. A 404/403 image URL would never resolve on retry, so it just
burned up to 6s of wall-clock + extra GETs before failing — inflating
latency for a deterministic failure (issue #32296, umbrella #35114).

Add _is_retryable_download_error(): 4xx client errors (except 429),
website-policy PermissionError, and too-large/SSRF ValueError now raise
on the first attempt. 429, 5xx, and unclassified network errors stay
retryable. Removed the now-unreachable fall-through branch since the
loop always returns on success or re-raises on the final/terminal attempt.
2026-05-30 01:40:39 -07:00
kshitij e481b15333 Merge pull request #35216 from kshitijk4poor/fix/agents-nudge-single-delegate
fix: surface /agents nudge for single-delegate fan-out (TUI + CLI)
2026-05-30 00:57:15 -07:00
kshitijk4poor 9d2571c86a fix: surface /agents nudge while delegate_task is in-flight (TUI + CLI)
The subagent spawn-observability overlay added a `(/agents)` hint, but
only on the standalone "Spawn tree" panel, gated behind `!inlineDelegateKey`
— it never showed for a single delegate_task call, and only appeared once
subagents had already registered. A nudge that arrives at the end (or only
after spawn) is useless for the actual goal: letting users open the live
monitor *while* delegation is running.

Surface it the moment delegation starts, on both surfaces:

TUI (ui-tui/src/components/thinking.tsx)
- Show `(/agents)` on any "Delegate Task" tool group as soon as it appears
  (in-flight, before any subagent registers), not gated on subagents
  already existing. Same `startsWith('Delegate Task')` predicate already
  used for delegateGroups.

CLI (agent/tool_executor.py)
- Append `· /agents to monitor` to the delegate spinner label, which is
  displayed for the full duration of the delegate_task call. The previous
  attempt put the hint on the completion line (get_cute_tool_message),
  which only renders after the call finishes — reverted.

TUI tsc clean (pre-existing execFileNoThrow type errors unrelated);
subagentTree 35/35; display.py reverted to upstream.
2026-05-30 13:22:45 +05:30
Teknium bb79bcde61 fix: detect pyproject.toml / __init__.py version drift in hermes doctor (#35142)
A git conflict resolution (reset --hard or merge) can revert
hermes_cli/__init__.py to a stale __version__ while pyproject.toml stays
current, so 'hermes --version' silently reports the wrong version. Nothing
cross-checked the two files.

Add a version-consistency check to the doctor 'Python Environment' section:
reads the [project] version from pyproject.toml and compares it to
hermes_cli.__version__. Reports OK when they match, fails with a re-sync
hint when they drift, and is a silent no-op for installed wheels where
pyproject.toml isn't present.

Closes #35070
2026-05-30 00:32:05 -07:00
teknium1 e5765e61fa chore(release): map wei.chen.coder@gmail.com -> wenchengxucool 2026-05-30 00:30:55 -07:00
weichengxu 84ee80eb5d feat: set process title to 'hermes' in ps/top/htop
Adds _set_process_title() in hermes_cli/main.py, called first thing in
main(). Tries setproctitle (optional) for a full ps-args rewrite, then
falls back to ctypes prctl(PR_SET_NAME) on Linux / pthread_setname_np on
macOS. No-op on Windows and on any failure. No new dependency: the
setproctitle path is best-effort via ImportError guard.

Fixes #35108
2026-05-30 00:30:55 -07:00
teknium1 17103a1f11 chore: add SeaXen to AUTHOR_MAP for salvaged PR #33278 2026-05-30 00:23:44 -07:00
SeaXen e8076c1ebe fix(dashboard): allow chat websockets on insecure public bind
Allow non-loopback websocket peers when the dashboard is explicitly exposed with --host 0.0.0.0/:: and --insecure.

This fixes the failure mode where /chat rendered over LAN but /api/ws and /api/events were rejected with HTTP 403, leaving the embedded TUI chat disconnected.

Add regression coverage for the insecure public bind case in the dashboard websocket auth tests.
2026-05-30 00:23:44 -07:00
Max Hsu 636ff636d7 fix(agent): strip schema-foreign keys from max-iterations summary request (#34436)
The max-iterations summary path (`handle_max_iterations`) hand-builds its
message list and calls `chat.completions.create()` directly, bypassing
`ChatCompletionsTransport.convert_messages()`. It only popped
("reasoning", "finish_reason", "_thinking_prefill"), so `tool_name` (SQLite
FTS bookkeeping), the `codex_*` reasoning carriers, and other internal
`_`-prefixed scaffolding leaked to the wire.

Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go, Mistral,
Moonshot/Kimi) reject these with HTTP 400 "Extra inputs are not permitted,
field: 'messages[N].tool_name'", so a long tool-using session that exhausts
the iteration budget fails to summarise instead of returning the result.

Mirror convert_messages() in this path: also drop tool_name,
codex_reasoning_items, codex_message_items, and every `_`-prefixed key.
Copy-on-write is already in place, so internal history keeps the fields for
FTS / Codex-fallback.

Adds a regression test to TestHandleMaxIterations asserting the summary
request carries none of the schema-foreign keys (fails on main, passes here).
2026-05-30 00:22:53 -07:00
Teknium c1b2d0917f fix(cli): don't treat any container as the Docker image for updates (#35139)
detect_install_method() returned "docker" for any container (is_container()),
before the .git check. Both supported installs already self-identify via the
.install_method stamp read first: the curl installer (scripts/install.sh)
git-clones and stamps "git"; the published nousresearch/hermes-agent image
stamps "docker" at boot via docker/stage2-hook.sh. An unsupported manual
install dropped into a container has no stamp, so the bare container check
hijacked it to "docker" and 'hermes update' bailed with the docker-pull
guidance.

Drop the redundant is_container() -> docker fallback. Unstamped installs now
fall through to the .git/pip checks like any off-path install; both supported
paths are unaffected because the stamp wins first.

Fixes #34397.
2026-05-30 00:22:46 -07:00
kshitij 8738cb92c3 Merge pull request #34704 from kshitijk4poor/feat/tui-agents-nudge
feat(tui): nudge toward /agents dashboard when delegation starts
2026-05-30 00:01:59 -07:00
kshitijk4poor 5a72e82fd8 feat(tui): nudge toward /agents dashboard when delegation starts
The TUI already ships a rich /agents spawn-tree dashboard (live tree,
timeline, per-child tokens/cost/files/tools, kill/pause), but nothing
surfaced it — during delegation the transcript stayed quiet and users
had to already know to type /agents.

Drop a one-time transient activity hint ("subagents working · /agents
to watch live") the first time a turn starts delegating, matching the
existing "· /logs to inspect" house style. Guards keep it unobtrusive:

- fires at most once per turn (resets on message.start)
- silent when the /agents overlay is already open
- gated by display.tui_agents_nudge (default true)

Hooked on subagent.start, not subagent.spawn_requested: the delegate
progress callback in tools/delegate_tool.py only relays start/complete
to the gateway and drops spawn_requested, so start is the first
delegation event the TUI reliably receives. spawn_requested is wired
too for the future case, guarded once-per-turn.

Adds the display.tui_agents_nudge config default and gatewayTypes entry.
2026-05-30 12:26:36 +05:30
kshitijk4poor 7b0915037c test: remove low-value model-catalog mirror tests
These tests asserted that hardcoded curated model lists/constants still
contained specific model strings (e.g. 'glm-5' in provider_model_ids('zai'),
exact context-length values per model key, PROVIDER_TO_MODELS_DEV entries).
They mirror a constant rather than exercise logic, so they only ever break
when models are added/retired and never catch a real bug.

Removed 22 such functions across 7 files (149 deletions, 0 additions).
Behavioral siblings are kept: live-catalog-wins, fallback ordering,
substring/longest-match resolution, normalization, credential discovery,
and probe-tier stepping all still tested.
2026-05-29 23:45:05 -07:00
Teknium 0437137fff security: pin patched Starlette (>=1.0.1) for CVE-2026-48710 BadHost (#35118)
Starlette < 1.0.1 is affected by CVE-2026-48710 ("BadHost", CWE-444).
The HTTP Host header was not validated before being used to rebuild
`request.url`, so a malformed Host could make `request.url.path` desync
from the raw ASGI path the router actually dispatched. Middleware and
endpoints that apply path-based authorization off `request.url` (rather
than `scope["path"]`) can therefore be bypassed.

Hermes pulls Starlette transitively, never directly:
  - [web]          -> fastapi==0.133.1  (starlette>=0.40.0, no upper bound)
  - [mcp]          -> mcp==1.26.0 + sse-starlette (starlette>=0.27 / >=0.49.1)
  - [computer-use] -> mcp==1.26.0
  - [dev]          -> mcp==1.26.0

A fresh resolve landed starlette 0.52.1 — vulnerable. With no upper
bound on the transitive specs, pip/uv could resolve any pre-1.0.1
release on a fresh install.

Fix: pin starlette==1.0.1 directly in every extra that exposes a
Starlette-backed server surface, regenerate uv.lock (only starlette
moves: 0.52.1 -> 1.0.1, hash-verified), and mirror the pin in the
lazy-install map (tools/lazy_deps.py `tool.dashboard`) so `hermes`
on-demand dashboard installs can't re-resolve a vulnerable version.

1.0.1 is the advisory's named fix floor and the oldest patched release
(more bake time than 1.1.0/1.2.0, which are days old); it satisfies
every carrier constraint and our requires-python>=3.11.

Scope note: this is a dependency-level fix complementing the
application-layer Host-header validator added in #34162
(`hermes_cli/web_server.py` `_is_accepted_host`). Defense in depth at
both the framework and app layers.

Guards: two invariant tests in tests/test_packaging_metadata.py assert
every server-surface extra pins starlette and that pyproject + uv.lock
both resolve >= the 1.0.1 CVE floor — a dropped pin or stale lock fails
in CI instead of shipping the bypass.

Closes #35067
2026-05-29 23:23:54 -07:00
ErosikaandBROCCOLO1D 827ce602db fix(honcho): harden self-hosted setup paths
Self-hosted Honcho setup had four sharp edges:

- local/cloud URLs ending in /vN double-prefixed by the SDK (/v3/v3/... 404)
- authenticated local servers had no setup prompt for a JWT/bearer token
- profile-derived host keys could be dot-containing workspace IDs Honcho rejects
- memory-provider config files with API keys written world-readable per umask

This keeps existing behavior but makes those paths safer:

- strip a trailing /vN version segment from any configured baseUrl before SDK
  init (the SDK's route builders always prepend their own version prefix);
  auth-skipping stays loopback-only
- add an optional local JWT/bearer prompt in honcho setup, stored under
  hosts.<host>.apiKey
- derive new profile host keys with underscores, still reading legacy
  hermes.<profile> blocks
- write memory-provider config files atomically with 0600 via a shared
  utils.atomic_json_write(mode=) arg (honcho/hindsight/mem0/supermemory)
- skip honcho.json parsing in gateway cache-busting unless Honcho is the active
  memory provider; memoize by honcho.json mtime when active
- bust the gateway agent cache on memory.provider change
- add a hermes memory setup <provider> one-liner so fresh installs can configure
  a named provider without the picker (the per-provider hermes <provider>
  subcommand only registers once that provider is active)

Closes #20688, #29885, #26459, #30246, #33382, #32244.

Co-authored-by: BROCCOLO1D
2026-05-29 22:29:48 -07:00
Siddharth Balyan aa32edcac5 fix(setup): write config for image_gen and video_gen in apply_nous_managed_defaults (#35109)
apply_nous_managed_defaults() was adding image_gen and video_gen to the
'changed' return set without writing any config values.  The caller
(tools_command first_install flow) uses 'changed' to skip manual
configuration, so these tools ended up in platform_toolsets but with no
video_gen.provider, video_gen.use_gateway, or image_gen.use_gateway in
config.yaml.

At runtime the FAL plugin's is_available() returned False because there
was no FAL_KEY and no use_gateway config — the tool never loaded despite
being 'enabled' in the toolset list.

For image_gen this was a latent bug masked by the gateway offer prompt
(prompt_enable_tool_gateway) running earlier in the setup flow and
writing image_gen.use_gateway=True via apply_gateway_defaults().  But if
the user skipped the gateway offer, image_gen would silently break the
same way.

For video_gen (added in PR #33259) the bug was always hit because the
gateway offer ran before the user checked video_gen in the toolset
checklist.

Fix: write provider/use_gateway config values before adding to 'changed',
matching the pattern used by web, tts, and browser.
2026-05-30 03:45:12 +00:00
teknium1 a7421dc7d2 fix(session): point no-FTS5 warning at the supported install
When FTS5 is missing the warning now explains the likely cause (an
unsupported / pip-managed Python whose bundled SQLite lacks FTS5) and
links the supported install at hermes-agent.nousresearch.com, instead
of just logging the raw error.
2026-05-29 20:11:07 -07:00
teknium1 4fa20f9a8b fix(install): ensure the uv-managed Python ships SQLite FTS5
uv's python-build-standalone distributions only gained FTS5 in mid-2025
(#694). A stale interpreter already in uv's store — which `uv python find`
reuses without checking — can lack it, leaving the supported install with
a SQLite that can't create the FTS5 virtual tables hermes_state.py needs
for full-text session search ("no such module: fts5").

check_python now probes the resolved interpreter for FTS5 and, if missing,
reinstalls the latest patch for $PYTHON_VERSION (which has FTS5) and
re-resolves. If an FTS5-capable Python still can't be obtained (offline,
pinned env), it warns and continues — Hermes degrades gracefully and only
disables session search. No bundled second SQLite, no user action.
2026-05-29 20:11:07 -07:00
teknium1 97ecfa0fc4 fix(session): extend no-FTS5 degradation to the trigram CJK index
The salvaged contributor commit guarded only messages_fts. Current main
also creates a second virtual table, messages_fts_trigram (CJK substring
search), whose CREATE VIRTUAL TABLE ... USING fts5 still raised
"no such module: fts5" on builds without FTS5 — re-crashing SessionDB
init. Wrap the trigram setup with the same guard, and broaden the test's
no-fts5 mock to fail BOTH tables so the regression test actually
exercises a faithful no-FTS5 build.
2026-05-29 20:11:07 -07:00
LeonSGP43 5ad2b4c6da fix(session): degrade gracefully when SQLite lacks FTS5 2026-05-29 20:11:07 -07:00
Teknium 860cf28dab docs: clarify compression threshold is derived from the main model's context window (#35099)
The compression threshold is threshold × context_length where context_length
is the MAIN agent model's window, not the auxiliary/summary model's. On a
262,144-token model at the default 0.50 the threshold is 131,072 — close to a
common 128K figure by coincidence of the percentage, which has led to confusion
that the auxiliary model's context limit is the trigger. Add a note preempting
that misreading and pointing to the separate summary-model-context constraint.
2026-05-29 19:59:04 -07:00
teknium1 fb0ab27649 fix(agent): register explainer config key + shorten footer prefix
Follow-up to the salvaged #34452 turn-completion explainer:
- Register display.turn_completion_explainer: True in DEFAULT_CONFIG so the
  setting is discoverable, matching the file_mutation_verifier precedent.
- Shorten the repeated footer prefix from 'Turn ended without a usable
  reply: ' to 'No reply: ' so the 10 reason variants don't all open with
  the same 8-word boilerplate.
- Update the 7 assertions that referenced the old prefix.
2026-05-29 19:23:05 -07:00
Bartok9 de6d6023d7 test(run_agent): align test_dict_tool_call_args with explainer suffix
PR #34470 adds an explainer suffix to abnormal turn endings (e.g.
max_iterations_reached) so users see why the response is short instead
of receiving a bare/blank reply. test_tool_call_validation_accepts_dict_arguments
runs the agent at max_iterations=3 which hits the explainer path; the
existing strict-equality assertion (== "done") no longer matches once
the suffix is appended.

Switch the assertion to .startswith("done") so the test continues to
verify that the models actual text survives intact while leaving the
explainer suffix wording owned by conversation_loop (where it belongs).

Test now passes (1 passed in 0.88s).
2026-05-29 19:23:05 -07:00
Bartok9 59b0ea98c8 fix(agent): explain abnormal turn endings instead of blank/partial reply
When a turn ends abnormally after substantive tool calls (empty content
after retries, a partial/truncated stream, exhausted retries, or an
iteration/budget limit), the CLI/TUI response area was left blank or
showed only a fragment (e.g. "The") with no consolidated reason. The
internal turn_exit_reason values (empty_response_exhausted,
partial_stream_recovery, etc.) were never surfaced to the user.

Add a turn-completion explainer that mirrors the existing file-mutation
verifier footer: at turn end, map an abnormal turn_exit_reason to a
short, actionable message and either replace the bare "(empty)"
sentinel or append the reason after a partial fragment. Normal
text_response exits (e.g. a terse "Done.") stay quiet.

Gated by display.turn_completion_explainer (default on) with
HERMES_TURN_COMPLETION_EXPLAINER env override, matching the
file-mutation verifier seam.

Closes #34452
2026-05-29 19:23:05 -07:00
Teknium 897f9533ed fix: keep CLI context display in sync with preflight token estimate (#35079)
* Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here'

Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).

* fix: keep CLI context display in sync with preflight token estimate

The status bar reads compressor.last_prompt_tokens, which only updates
from a successful API response. When loaded history is oversized but
compression no-ops (e.g. the auxiliary summary model times out), no fresh
usage arrives and the bar stays frozen at the old, smaller value while the
preflight estimate reports a much larger number — looking permanently out
of sync (reported: 74.4K display vs ~144,669 preflight).

Seed last_prompt_tokens with the fresh preflight estimate (upward-only, so
a real usage figure is never clobbered and a successful compression's
downward correction still wins). Display-only; no behavioral change to
compression, caching, or the agent loop.
2026-05-29 19:21:15 -07:00
teknium 9d4c81130a fix(gateway): name what the /status token number actually is
Sharpen the label from 'Session usage (cumulative)' to 'Cumulative API
tokens (re-sent each call)'. The number is real provider-reported usage
summed across every API call in the session — not context size. In an
agentic loop the same context is re-sent each iteration, so a one-hour
tool-heavy session legitimately reaches tens of millions of tokens. The
new label explains the magnitude so users stop reading it as a bug or as
a total across all sessions.
2026-05-29 19:14:37 -07:00
helix4u 2259c15e4d fix(gateway): clarify status session usage label 2026-05-29 19:14:37 -07:00
Bartok9 45bc65abbe fix(gateway): drop outbound silence-narration messages pre-send
Hallucinated 'silence' tokens (*(silent)*, _silent_, the bare '.', '...',
'silent', no response/reply, the mute emoji) are emitted when a persona has
nothing actionable to say. In bot-to-bot channels the receiving bot mirrors
the token back, creating a tight loop that burns API tokens and can crash a
model with 'no content after all retries'. SOUL.md/prompt rules drift across
providers and have already failed in practice, so add a substrate-level guard.

_deliver_to_platform now drops a message whose finalized content is only a
silence-narration token, logs a WARNING with platform/chat_id/truncated
content, and returns {success: True, filtered: 'silence_narration',
delivered: False} instead of calling the adapter. Single chokepoint covers
every platform adapter; the regex is anchored start/end with a 64-char guard
so prose like 'Silence is golden — here is the plan...' or 'Silent install
completed' is never dropped. Local/file delivery is a separate path and is
left untouched. Opt out via gateway.filter_silence_narration: false or the
HERMES_FILTER_SILENCE_NARRATION env override (env wins when set).

Closes #34616
2026-05-29 19:06:05 -07:00
teknium1 9dbc3722ae test(compression): fix StopIteration in large-rough-growth preflight test
The rough-estimate mock supplied only 2 side_effect values but the
conversation loop calls estimate_request_tokens_rough a third time for
the post-response real-token estimate, exhausting the iterator. Use a
callable side_effect that returns 125k once (to fire preflight) then
sub-threshold values, independent of call count.
2026-05-29 19:05:03 -07:00
helix4u e38b0b55d1 fix(compression): avoid repeat preflight compaction from rough estimates 2026-05-29 19:05:03 -07:00
Teknium 04de307d62 fix(cli): repaint input area after inline /steer and /model submit (#34839)
handle_enter dispatches /steer and /model inline on the UI thread while
the agent is running, calling buffer.reset() then returning. Unlike every
other early-return branch in the handler, these two skipped
event.app.invalidate(). process_command() prints through patch_stdout
(scrolls output above the prompt without redrawing the input line), so the
just-cleared input area could keep showing the submitted '/steer <text>'
until an unrelated redraw fired — looking unsent and inviting an accidental
re-submit.

Add event.app.invalidate() after reset in both inline branches to match
the sibling branches. AST regression test pins the invariant: every
reset-then-return branch in handle_enter must invalidate first.

Fixes #34569
2026-05-29 19:04:40 -07:00
Teknium bcc8301000 Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' (#35048)
Adds a user-chosen compression boundary to the existing /compress command.
/compress here [N] summarizes everything except the most recent N exchanges
(default 2), which are preserved verbatim — letting the user pick the
compression boundary instead of relying on the automatic token-budget heuristic.

Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139,
Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20

- hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation
  guard (shared by CLI and gateway).
- cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression;
  compress only the head, re-append the verbatim tail through the seam guard.
- Preserves message-flow role alternation (seam guard merges any illegal
  user->user / assistant->assistant adjacency).
- Reuses the existing _compress_context session-rotation/lock machinery — no
  changes to the compression core.
- Bare /compress (full) and /compress <focus> behavior unchanged.

Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved
tool-call transcript, degenerate/multimodal seams, real handler path).
2026-05-29 17:49:15 -07:00
Bartok9 54aa4db1de fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall
The POSIX installer drops node/npm/npx symlinks in ~/.local/bin pointing
into $HERMES_HOME/node and prepends ~/.local/bin to PATH, shadowing an
existing nvm. Uninstall removed the hermes wrapper but left these behind,
so the user's default node/npm/npx stayed redirected after uninstall.

Add remove_node_symlinks() and call it from run_uninstall. It removes
~/.local/bin/{node,npm,npx} only when each is a symlink resolving into the
current Hermes home's node dir, so a link the user repointed at nvm or a
real binary is never touched. Handles dangling links too.

Closes #34536
2026-05-29 17:24:38 -07:00
Teknium 2062a84000 fix(auxiliary): stop capping output with max_tokens by default (#34530) (#34845)
* fix(auxiliary): stop capping output with max_tokens by default

Auxiliary LLM calls (compression, titles, vision, etc.) no longer send
max_tokens on the OpenAI-compatible chat-completions path. Most providers
treat an omitted max_tokens as "use the model max", which is what we want;
an explicit cap only risks truncation or a wire-format 400.

This was surfaced by GitHub Copilot / GPT-5 (#34530): those models reject
max_tokens and require max_completion_tokens, so compression 400'd and fell
back to a static context marker. Omitting the param sidesteps that quirk
(and ZAI vision's error 1210) entirely.

The Anthropic Messages wire (MiniMax + /anthropic endpoints) keeps
max_tokens because it is a mandatory field there.

* test(auxiliary): update temperature-retry assertions for omitted max_tokens

The temperature-retry tests asserted retry_kwargs["max_tokens"] == 500 on an
api.openai.com endpoint. Now that auxiliary calls omit max_tokens on
OpenAI-compatible endpoints (#34530), that key is absent. Assert it's absent
in both first and retry kwargs and use model as the survives-the-retry witness.
2026-05-29 17:24:30 -07:00
Teknium f9daa4a41d fix(deps): declare setuptools in dev extra for packaging tests (#34851)
* fix(deps): declare setuptools in dev extra for packaging tests

tests/test_packaging_metadata.py imports `from setuptools import
find_packages` at module scope to validate package discovery against
the live tree. setuptools was being picked up ambiently from the CI
runner image, but recent ubuntu-latest images no longer ship it in the
test venv, so collection fails with ModuleNotFoundError on every PR.

Declare setuptools==82.0.1 in the dev optional-dependencies so `.[all,dev]`
installs it explicitly rather than relying on the runner environment.

* test(packaging): skip packaging-metadata tests when setuptools absent

Belt-and-suspenders alongside declaring setuptools in [dev]: guard the
module-level `from setuptools import find_packages` with
pytest.importorskip so a runner missing setuptools SKIPS these checks
instead of erroring out collection for the entire test shard.

* chore(deps): sync uv.lock for setuptools dev dependency
2026-05-29 17:24:23 -07:00
Teknium 689ef5e233 feat(cli): warn on unsupported pip installs + fix stale update-check cache (#34491) (#34846)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* feat(cli): warn on unsupported pip installs + fix stale update-check cache after pip upgrade

Banner now shows a yellow warning when detect_install_method() == 'pip':
'pip install hermes-agent' isn't the supported install path (it exists on
PyPI for internal/CI reasons), so updates and issue support don't behave
correctly. Reuses existing install-method detection; warn, never block.

Also fixes #34491: check_for_updates() keyed its 6h cache only on ts+rev.
On the pip path (no HERMES_REVISION), rev is always None, so a
'pip install --upgrade' changed VERSION but left the cache valid — the
stale 'N commits behind' count survived the upgrade. Cache now also keys
on the installed VERSION and invalidates on mismatch.
2026-05-29 13:30:28 -07:00
teknium1 bb50825716 chore(release): map annguyenNous to AUTHOR_MAP
Clears the check-attribution CI gate on PR #34468 — the contributor's
noreply email was unmapped.
2026-05-29 13:29:34 -07:00
annguyenNous 9f5afc7636 fix(mcp): widen isinstance check to BaseException for CancelledError
asyncio.gather(return_exceptions=True) captures CancelledError as a
BaseException value. The previous isinstance(result, Exception) check
missed CancelledError, silently dropping it without logging.

Since Python 3.9, CancelledError is a BaseException subclass (not
Exception). This one-line change ensures all failure types from MCP
server connections are properly logged.

Fixes NousResearch/hermes-agent#34443
2026-05-29 13:29:34 -07:00
teknium1 4fd8521e44 test(tui-gateway): isolate completion_queue in poller requeue test
test_notification_poller_requeues_when_busy drained and reused the
process-global process_registry.completion_queue, so a concurrent test
in the same xdist worker could put/get on the shared singleton mid-run
and empty the event the poller requeues — flaking 'assert not
completion_queue.empty()' under parallel CI load only.

Monkeypatch a fresh Queue onto the singleton for the test's duration so
nothing external can interleave. The poller reads completion_queue by
attribute at runtime, so the isolated queue is what it operates on.
monkeypatch restores the original on teardown. Verified immune: 50/50
passes under a background thread hammering the global queue.
2026-05-29 13:29:24 -07:00
Bartok9 edfdc77664 fix(cli): resume the selected chat when a bare number follows /resume
A bare `/resume` printed the recent-sessions list but armed no selection
state, so typing just `3` on the next line was sent to the agent as chat
instead of resuming session #3. `/resume 3` worked, but the natural
list-then-pick flow did not.

Arm a one-shot pending-resume prompt when bare `/resume` shows the list,
and consume the next bare numeric input as the selection (out-of-range is
reported, non-numeric/other commands disarm it). Resolves against the same
_list_recent_sessions(limit=10) list used everywhere else.

Closes #34584.
2026-05-29 13:29:24 -07:00
Teknium 3a2c03061c fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted (#34841)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(stt,tts): restore mistralai — 2.4.8 is clean, ban lifted

PyPI quarantined mistralai on 2026-05-12 after the malicious 2.4.6
release (Mini Shai-Hulud worm). 2.4.6 has since been removed from the
registry and clean releases resumed (2.4.7 2026-05-25, 2.4.8 2026-05-28).
This rolls back the blanket runtime ban so Voxtral STT + TTS work again,
following the restoration checklist the repo left in pyproject.toml.

Verified against the real SDK: 2.4.8 keeps the import path the code uses
(from mistralai.client import Mistral) and the audio.transcriptions.complete
/ audio.speech.complete surfaces.

Changes:
- pyproject.toml: re-add mistral extra pinned to mistralai==2.4.8; left
  OUT of [all] per the 2026-05-12 lazy-install policy (one quarantined
  release must not break fresh installs). uv.lock regenerated.
- tools/lazy_deps.py: add stt.mistral / tts.mistral entries so the SDK
  lazy-installs on first use (matches edge / elevenlabs).
- tools/transcription_tools.py: restore explicit-provider gate
  (_HAS_MISTRAL + key) and auto-detect entry (local>groq>openai>mistral>xai);
  _transcribe_mistral lazy-installs before import.
- tools/tts_tool.py: dispatcher routes back to _generate_mistral_tts;
  _import_mistral_client lazy-installs the SDK.
- hermes_cli/tools_config.py, hermes_cli/web_server.py: un-hide Mistral
  from the TTS provider picker and dashboard STT options.
- hermes_cli/security_advisories.py: KEEP the shai-hulud-2026-05 advisory
  (module policy forbids removal) — it is scoped to 2.4.6 only, so it
  still warns anyone with the poisoned build cached and never fires on
  2.4.8. Summary note updated to reflect the un-quarantine.
- tests: revert the disabled-behavior assertions added by the ban commit
  back to routing/positive expectations; add mistral to the
  lazy-installable-extras-excluded-from-[all] contract.

Reported by @SkYNewZ (#34503).

Validation: 189 targeted STT/TTS/lazy_deps/metadata tests pass; E2E with
the real mistralai 2.4.8 SDK routes both STT and TTS to mistral.
2026-05-29 13:24:12 -07:00
781604ce4c fix(gateway): unify MEDIA: extraction extension set + close the unknown-ext black hole (#34517) (#34844)
MEDIA:<path> tags for .md/.json/.yaml/.xml/.html and other document
extensions were silently dropped. extract_media() carried a narrow
extension allowlist that omitted them, while extract_local_files()
had a broad one. The dispatch sites then ran an unconditional
re.sub(r'MEDIA:\\s*\\S+', '') that stripped the tag from the body even
when extract_media had not matched it — so extract_local_files (broad
list) ran on text where the path was already gone, and the file was
delivered by neither path.

- Add MEDIA_DELIVERY_EXTS in gateway/platforms/base.py as the single
  source of truth; extract_media and extract_local_files both derive
  their extension set from it (no more drift).
- Replace the loose MEDIA cleanup at the non-streaming dispatch site
  (base.py) and the streaming consumer (stream_consumer.py) with the
  shared, extension-anchored MEDIA_TAG_CLEANUP_RE. A MEDIA: tag with an
  unknown extension is left in the body so the bare-path detector can
  still pick it up instead of being black-holed.
- Chain cleaned text through extract_media -> extract_images ->
  extract_local_files in run.py's post-stream media delivery (it was
  dropping the cleaned text and rescanning raw text with MEDIA: tags).
- Regression tests covering both halves: previously-dropped extensions
  now extract, and unknown-ext paths survive the cleanup.

Consolidates the MEDIA extension-allowlist PR cluster.

Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: banditburai <123342691+banditburai@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-05-29 13:24:01 -07:00
teknium1 0dc0c5ea6b chore: add AUTHOR_MAP entry for sweetcornna
Maps the cherry-picked commit's noreply email to the GitHub login so the
release attribution / CI author check passes.
2026-05-29 13:22:54 -07:00
Bartok9 3845d86b93 fix(cron): restore jobs.json emptied by config migration on update
Config-version migrations have been observed to leave cron/jobs.json
valid-but-empty after `hermes update`, silently dropping every scheduled
job (#34600). The existing malformed-shape guards in cron/jobs.py don't
catch this because {"jobs": []} is valid JSON.

Add restore_cron_jobs_if_emptied() as a post-migration safety net: if the
live cron/jobs.json now has zero jobs while the pre-update snapshot held
one or more, restore the snapshot copy in place and warn loudly. The
check is conservative — it only restores on unambiguous evidence of loss
(snapshot had jobs, live file readable-and-empty), so a user who genuinely
cleared their jobs is never second-guessed and an unreadable live file is
left untouched so real corruption still surfaces.

Wired into _cmd_update_impl after migrate_config(), reusing the existing
pre-update quick snapshot (which already captures cron/jobs.json).

Closes #34600
2026-05-29 13:22:54 -07:00
Cornna d473e7c938 fix(cron): exclude jobs.json registry from disk-cleanup pattern
Closes #32164
2026-05-29 13:22:54 -07:00
Teknium 91b174038c fix(feishu): bound _chat_locks with LRU eviction (#34836)
The Feishu adapter stored one asyncio.Lock per chat_id in a plain dict
with no upper bound, so a long-running gateway that saw many distinct
chats grew _chat_locks without limit. Port the LRU-eviction pattern
already used by the yuanbao adapter: OrderedDict + move_to_end on access,
CHAT_LOCK_MAX_SIZE cap (1000), and eviction that skips currently-held
locks (falling back to dropping the LRU entry only if all are held).
2026-05-29 13:18:15 -07:00
teknium1andliuhao1024 8055d0f092 test(ntfy): cover echo-tag filter; tag standalone send path
Adds tests for the echo-loop fix (outgoing X-Tags header, inbound skip
on tagged events, genuine tags pass through) and extends the tag to the
out-of-process _standalone_send() path so cron / send_message deliveries
to a self-subscribed topic are also skipped. Maps both contributors in
release.py AUTHOR_MAP.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-05-29 13:17:46 -07:00
annguyenNous 9405cdc8dd fix(ntfy): prevent echo loop by tagging outgoing messages
When publish_topic equals the subscribe topic, the agent's own replies
are echoed back by ntfy as incoming messages, creating an infinite
reply spiral.

Fix: tag outgoing messages with X-Tags: hermes-agent header, and skip
incoming messages that carry this tag. This is zero-config — works
automatically regardless of topic configuration.

Fixes NousResearch/hermes-agent#34447
2026-05-29 13:17:46 -07:00
Bartok9 08c0b22417 fix(gateway): scope tool-result MEDIA scan to current turn
The post-run scan that appends tool-emitted MEDIA: tags to the final
response iterated every tool/function message in the full conversation
and relied solely on path-based dedup against paths reconstructed from
the replayable transcript. When that reconstruction does not byte-match
the in-memory tool content (timestamp stripping, observed-context
withholding, compression rewrites), a stale path emitted several turns
earlier is absent from the dedup set and leaks onto a later text-only
reply (Telegram 'Sending media group of 1 photo(s)' with no MEDIA
directive present).

Scope the scan to this turn's new messages by slicing result['messages']
at len(agent_history) (agent_history is passed as conversation_history
into run_conversation, so the returned list is history + this turn).
Retain path-based dedup as a secondary guard and as the sole guard on
the compression-shrink fallback, preserving the #160 behaviour.

Closes #34608
2026-05-29 13:13:34 -07:00
teknium1 38c4f8c371 test(gateway): update system-unit cwd assertion to HERMES_HOME anchor
test_system_unit_has_no_root_paths asserted the system unit's
WorkingDirectory was the remapped *checkout* path
(/home/alice/.hermes/hermes-agent). That is the brittle pin this PR
fixes — the system unit now anchors cwd at the target user's HERMES_HOME
(/home/alice/.hermes). The test's intent (no root-home leak, target-user
paths present) is unchanged and still holds.
2026-05-29 12:36:59 -07:00
teknium1 a1cb5fa2c7 fix(gateway): anchor service WorkingDirectory at HERMES_HOME, not the source checkout
The systemd unit (and launchd plist) pinned WorkingDirectory to PROJECT_ROOT
(the checkout the unit was generated from). When that checkout is transient —
a git worktree, or a clone hermes update later relocates/removes — the path
rots. systemd then fails the start at the CHDIR step (status=200/CHDIR) BEFORE
Python loads, so the on-boot refresh_systemd_unit_if_needed() self-heal never
runs and Restart=always crash-loops forever on a dead directory. Observed in
the wild: a gateway that crash-looped 153 times overnight, bot offline until a
manual 'hermes gateway restart' regenerated the unit.

Anchor cwd at HERMES_HOME instead — it never moves, always exists, and the
gateway never needed cwd to be the checkout (ExecStart uses an absolute python
+ -m hermes_cli.main). Existing broken units now differ from the generated unit
and self-heal on the next start/restart/update.
2026-05-29 12:36:59 -07:00
Teknium 45b00bb49a fix(packaging): ship hermes_cli subpackages in wheel (#34811)
[tool.setuptools.packages.find] listed 'hermes_cli' without the
'hermes_cli.*' wildcard, so the wheel shipped hermes_cli/*.py but
dropped the dashboard_auth and proxy subpackages. The dashboard died
on every install with ModuleNotFoundError: No module named
'hermes_cli.dashboard_auth' (#34701); 'hermes proxy' was equally
broken.

Add the wildcard, and add a regression test that drives setuptools'
own find_packages against the live tree so any future subpackage
dropped from the include list fails CI instead of a user's container.
2026-05-29 12:36:09 -07:00
teknium1 8836b3a113 fix(cli): widen Windows .bat wrapper fix to custom-name alias path
The profile alias --name path in main.py rewrote the wrapper with a
hardcoded #!/bin/sh script right after create_wrapper_script(), clobbering
the .bat on Windows and reintroducing the exact bug for custom aliases.

create_wrapper_script() now takes an optional target so the alias file is
named after the alias while the -p content references the profile — one
platform-aware code path, no post-hoc rewrite.
2026-05-29 12:32:47 -07:00
liuhao1024 6312dd8c3a fix(cli): create .bat wrapper on Windows instead of POSIX shell script
On Windows, hermes profile create produced a #!/bin/sh script that the
shell cannot execute.  Now creates a .bat file with @echo off + %* on
Windows, and keeps the POSIX shell script on macOS/Linux.

Also fixes check_alias_collision to use 'where' instead of 'which' on
Windows, and remove_wrapper_script to find .bat files.

Fixes #34708
2026-05-29 12:32:47 -07:00
zapabob 30a0d5bc9e chore(release): map zapabob author email 2026-05-29 12:32:35 -07:00
zapabob aa283d1e4f fix(model): isolate custom provider picker credentials 2026-05-29 12:32:35 -07:00
Teknium 2fc2280e63 fix(cli): clarify panel clips choices off-screen on short terminals (#34808)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(cli): clarify panel clips choices off-screen on short terminals

The clarify multiple-choice panel is a height-less Window inside a
non-full-screen HSplit. When its content exceeds the viewport,
prompt_toolkit distributes height per child and clips the panel's tail
— where the choices live — so options render invisible/cut off (issue
#34645, reported on macOS Terminal.app).

Two budget-accounting bugs let the panel overflow:
- the compact-chrome decision ignored the question rows, so full chrome
  (3 blank separators) was kept even with no room
- the '… (question truncated)' marker was not counted against the
  question's row budget, overshooting by one row at a 1-row budget

Fix: reserve one question row in the compact decision, count the
truncation marker against the budget, and drop the question entirely
when the choices alone already exceed the viewport (choices are the
must-see content for a selection).
2026-05-29 12:32:31 -07:00
Teknium 27a2c4f36f fix(mcp): stop reporting false OAuth success when no token was obtained (#34807)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(mcp): stop reporting false OAuth success when no token was obtained

`hermes mcp login` reported "Authenticated — N tool(s) available" for
servers that serve tools/list without auth (e.g. Google's official Drive
MCP server) even when the OAuth flow never completed — dynamic client
registration 400'd because the provider doesn't support RFC 7591, so no
token was ever acquired. Every real tool call then hung until timeout
with no indication of why.

Login now verifies a token actually landed on disk after the probe. When
it didn't, it warns that authentication didn't complete and shows the
config needed to supply a pre-registered client_id/client_secret (the
existing, already-supported workaround for DCR-less providers).

Adds a docs pitfall for Google Drive / Atlassian-style providers.

Fixes #34775
2026-05-29 12:32:19 -07:00
Teknium 1cb850b674 fix(api_server): emit per-turn transcript on run.completed (#34703) (#34804)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix(api_server): emit per-turn transcript on run.completed (#34703)

WebUI clients lost intermediate (pre-tool-call) assistant text after
switching session pages mid-stream. The session-chat SSE stream delivers
all assistant text as assistant.delta events under one message_id
interleaved with tool.* events, then a single assistant.completed
carrying only the final reply — so a client accumulating deltas into one
buffer cannot reconstruct intermediate text segments that preceded tool
calls, and they vanish from the live view (state.db persists them
correctly).

run.completed now carries the authoritative per-turn transcript
(assistant + tool messages for this turn, in client-safe shape) so any
SSE consumer can reconcile its live view against ground truth without a
separate GET /messages round-trip. Purely additive — clients that ignore
the field are unaffected.
2026-05-29 12:27:49 -07:00
Teknium b6ed3913d2 feat(skills): categorize tap skills from skills.sh.json grouping sidecar
A GitHub tap can ship a repo-root skills.sh.json (the published skills.sh
schema) declaring category groupings. The Skills Hub now reads it at index
time and uses each grouping title as the skill's category label, instead of
the tag-derived guess. Generic: any tap that ships the file gets real
categorization — NVIDIA's groupings (Inference AI, Decision Optimization,
GPU Development, etc.) flow through automatically.

- GitHubSource: _get_skillsh_groupings() fetches+caches the sidecar per repo;
  _parse_skillsh_groupings() flattens it to {skill_name: title};
  _list_skills_in_repo() stamps meta.extra['category']; _meta_to_dict now
  serializes extra so the category survives the index cache round-trip.
- extract-skills.py: prefers extra['category'] over the tag heuristic and
  exempts sidecar categories from the small-category to Other collapse.
- Docs + 12 tests.
2026-05-29 12:24:39 -07:00
Teknium 4de8009ce4 feat(skills): integrate NVIDIA/skills as a trusted skills hub tap
NVIDIA/skills is now a default trusted tap in the Hermes Skills Hub —
discoverable, browsable, searchable, and auto-updating through the same
pipeline that already serves OpenAI, Anthropic, and HuggingFace skills.

Rebased onto current main.
2026-05-29 12:24:39 -07:00
Teknium 1596bb287e fix(dashboard): chat tab works in gated (OAuth) mode (#34793)
The Chat/TUI dashboard tab showed a false "Session token unavailable"
error and never rendered the terminal whenever the dashboard ran in
gated mode (OAuth auth gate active, --insecure not set), even though
the user was fully authenticated and every other tab worked.

Two checks in ChatPage.tsx gated purely on window.__HERMES_SESSION_TOKEN__,
which the server intentionally omits in gated mode (web_server.py only
injects __HERMES_AUTH_REQUIRED__=true there; the SPA is expected to use
cookie auth + a single-use WS ticket). buildWsAuthParam() already resolves
WS auth correctly for both modes, but the early bail prevented the effect
from ever reaching it.

Both checks now also honor __HERMES_AUTH_REQUIRED__: the banner no longer
fires and the xterm/WS effect no longer bails in gated mode.

Reported-by: wbrione <wbrione@users.noreply.github.com>
Closes #34755
2026-05-29 12:19:51 -07:00
Teknium 90b3c54de9 fix: drain thread no longer crashes on fd-less stdout streams (#34789)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* fix: drain thread no longer crashes on fd-less stdout streams

The _wait_for_process drain thread called proc.stdout.fileno()
unconditionally. ProcessHandle implementations whose stdout is not
backed by a real OS fd (iterator-style in-memory streams, mock procs)
raised 'list_iterator' object has no attribute 'fileno' (or
'fileno() returned a non-integer' from select.select), killing the
daemon thread and silently losing all process output.

Resolve the fd defensively at the top of _drain; when stdout has no
usable integer fileno, fall back to draining it as an iterable (the
legacy 'for line in proc.stdout' contract). The real subprocess /
os.pipe-backed select() fast path is unchanged.
2026-05-29 12:16:57 -07:00
teknium1 5641ae6469 chore(release): add AUTHOR_MAP entries for Bucket-1 docs salvage contributors 2026-05-29 12:06:22 -07:00
Twanislas 549a69a925 docs(curator): align 'agent-created' definition with actual provenance semantics
The curator docs stated that any skill not bundled/hub-installed was
'agent-created' and subject to curation — including foreground-created
skills and hand-written ones. Since PR #19621 (May 2026), the curator
requires an explicit  marker in .usage.json, which
only the background self-improvement review fork sets.

Changes:
- Rewrite 'What agent-created means' to document the 3-step eligibility
  check (not bundled + not hub + created_by=agent marker)
- Explain that foreground skill_manage(create) does NOT mark skills as
  agent-created (user-directed by design)
- Warn that hand-written skills are NOT curated
- Add note in Per-run reports explaining the '(not resolved)' display
  when no candidates exist (LLM pass skipped, not a config error)
- Link to skill_provenance.py for the write-origin ContextVar

Ref: PR #19621, tools/skill_provenance.py, tools/skill_manager_tool.py
2026-05-29 12:06:22 -07:00
Aman113114-IITD 3f0d44af8a docs: replace invalid 'hermes config get <key>' with 'hermes config show'
'hermes config get <key>' is referenced in three guides but is not a
valid subcommand. The valid subcommands under 'hermes config' are
{show,edit,set,path,env-path,check,migrate}. 'hermes config show' is
already used elsewhere in the docs (including 'hermes config show |
grep <pattern>' in the FAQ), so it's the idiomatic replacement.

- work-with-skills.md: 'View all skill config' now uses
  'hermes config show | grep ^skills\.config'
- migrate-from-openclaw.md: session-policy check now reads the value
  from 'hermes config show'
- configuring-models.md: 'inspect what the CLI will actually use'
  now uses 'hermes config show | grep ^model\.'

Refs #30195
2026-05-29 12:06:22 -07:00
HKPA eff4626747 fix(docs): add baseUrl prefix to SVG image paths in sessions and CLI pages
Fixes #24809

The docs site uses baseUrl='/docs/' but the <img> tags in sessions.md
and cli.md referenced images at /img/docs/... which resolves to a 404.
The static files are served at /docs/img/docs/... instead.

Before: <img src="/img/docs/session-recap.svg"> → 404
After:  <img src="/docs/img/docs/session-recap.svg"> → 200

Also fixes cli-layout.svg which had the same issue.
2026-05-29 12:06:22 -07:00
aqilazizandCodex 175885218e fix(docs): align fallback provider config examples
Use the current top-level fallback_providers list in fallback docs and keep fallback_model documented only as the legacy compatibility shape. Also align cron and delegation fallback coverage with current runtime behavior.

Closes #19691

Co-authored-by: Codex <codex@openai.com>
2026-05-29 12:06:22 -07:00
helix4u 119390a2a1 docs(config): deprecate MESSAGING_CWD guidance 2026-05-29 12:06:22 -07:00
helix4u 3625dbb844 docs(security): update redaction skill source 2026-05-29 12:06:22 -07:00
helix4u aef04b2b53 docs(security): fix secret redaction default docs 2026-05-29 12:06:22 -07:00
TonyPepe a2d3cff53f docs(cli): refine update gateway restart wording 2026-05-29 12:06:22 -07:00
TonyPepe ee0a9bf7c7 docs(cli): align hermes update flags 2026-05-29 12:06:22 -07:00
WadydX b922e3ff93 docs(prompt): align precedence docs with system prompt runtime
- Replace outdated linear ordering in prompt-assembly guide with
  current stable/context/volatile tier contract from system_prompt.py
- Clarify where memory/profile snapshots live versus skills guidance
- Document that pre_llm_call context is user-message injection, not
  cached system-prompt mutation
- Update architecture guide wording to reference system_prompt.py +
  prompt_builder.py tiered assembly

Closes #34118
2026-05-29 12:06:22 -07:00
Octavio Turra 053969fd53 Correct URL format for simplex-chat download
Fix download link for Linux/macOS binary in documentation.
2026-05-29 12:06:22 -07:00
alelpoan 988cf1743b fix(docs): replace channel link with actual playlist URL in quickstart 2026-05-29 12:06:22 -07:00
kurobaryo 03bdeaa876 docs: fix BROWSERBASE_SESSION_TIMEOUT unit (ms → seconds) 2026-05-29 12:06:22 -07:00
haran2001 d86710528a docs(google-workspace): fix dead gws CLI link to googleworkspace/cli
The Google Workspace skill doc linked to https://github.com/nicholasgasior/gws
which returns 404. The actual upstream CLI lives at
https://github.com/googleworkspace/cli (the official Google Workspace CLI in
Rust, dynamically built from the Google Discovery Service).

Closes #28922
2026-05-29 12:06:22 -07:00
Niels Kaspers 6891e05e78 docs: fix session recap image baseUrl 2026-05-29 12:06:22 -07:00
hllqkb 0673638560 fix(docs): correct GitHub org links in memory-providers.md
hermes-ai/hermes-agent → NousResearch/hermes-agent (2 occurrences).
The old org name leads to 404 pages.
2026-05-29 12:06:22 -07:00
Hashclaw ae9dfa510e docs: fix separate typo; hyphenate built-in trust wording
- ACL LaTeX template comment: seperate -> separate
- CONTRIBUTING and docs site: builtin trust -> built-in trust (prose/table cells)

Made-with: Cursor
2026-05-29 12:06:22 -07:00
kshitijandBartok9 7379f17556 fix(gateway): only fire planned-stop watcher for self-targeting markers + fix Windows consume (#34749)
* fix(gateway): only fire planned-stop watcher for markers targeting self

Salvaged from #34599 — rebased onto current main.

The planned-stop watcher now only fires shutdown for a marker that targets
the current process, instead of any marker that exists on disk. Fixes the
Windows crash loop (#34597) where a stale marker from a previous Gateway
instance kills a freshly booted Gateway ~400ms after start with a false
"Received UNKNOWN — initiating shutdown".

Co-authored-by: Bartok9 <danielrpike9@gmail.com>

* fix(gateway): match planned-stop/takeover markers by PID alone when start_time is unavailable

Follow-up to the #34599 salvage. The watcher's non-destructive probe
(planned_stop_marker_targets_self) already falls back to PID equality when
a process start_time is unavailable, but the authoritative consume it gates
(_consume_pid_marker_for_self) still required a non-None start_time match.

_get_process_start_time reads /proc/<pid>/stat and returns None on macOS and
native Windows — the only platform the planned-stop watcher exists for. So on
Windows the probe would fire the shutdown handler (PID matches) but the
handler's consume_planned_stop_marker_for_self() would return False, and a
legitimate 'hermes gateway stop' was still misclassified as an unexpected
UNKNOWN exit (exit 1) and revived by the service manager — a residual half of
the #34597 crash loop on the legitimate-stop path.

Align the consume with the probe: when both start_times are known they must
match (PID-reuse guard preserved on Linux); when either is unavailable, fall
back to PID equality alone, bounded by the existing short marker TTL. This
also fixes the parallel --replace takeover consume on Windows, which shares
the same helper.

Adds regression tests for the Windows (None start_time) path, the foreign-PID
rejection under that fallback, and confirmation the start_time-mismatch guard
still rejects when both are known.

---------

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
2026-05-29 17:36:58 +00:00
alt-glitch 0563ab0652 fix(test): add fal_client.submit stub to surface matrix test
The plugin switched from fal_client.subscribe() to submit()+handle.get().
The test mock only had subscribe, causing CI failures.
2026-05-29 22:26:24 +05:30
alt-glitch e46e4bcf47 fix(video_gen): parse duration suffix in success_response
int(payload["duration"]) blows up on "4s" (veo3.1 format).
Strip non-digit chars before int conversion in the response builder.
2026-05-29 22:26:24 +05:30
alt-glitch 3183b2e28c fix(video_gen): veo3.1 duration format and 4k resolution
FAL veo3.1 API expects duration as "4s"/"6s"/"8s" (with unit suffix),
not bare "4"/"6"/"8" like other families. Add per-family duration_suffix
field and apply it in _build_payload. Also add "4k" to veo3.1 resolutions
per FAL API docs.

Note: the managed gateway currently rejects the "4s" format (expects
integer duration). Gateway-side fix needed for veo3.1 to work through
the Nous subscription path.
2026-05-29 22:26:24 +05:30
alt-glitch a4c18f65d4 feat(video_gen): wire Nous subscription override into hermes tools UX
Add the same managed-gateway UX that image_gen already has:

- TOOL_CATEGORIES['video_gen'] gets a 'Nous Subscription' provider row
  with managed_nous_feature='video_gen' + video_gen_plugin_name='fal'
- NousSubscriptionFeatures gains a video_gen property + feature state
  computation (managed/active/available using the fal-queue gateway)
- _GATEWAY_TOOL_LABELS, _GATEWAY_DIRECT_LABELS, _ALL_GATEWAY_KEYS,
  _get_gateway_direct_credentials, opted_in all include video_gen
- apply_nous_managed_defaults and apply_gateway_defaults handle video_gen
- _is_toolset_satisfied checks Nous features for video_gen
- _is_provider_active detects managed video_gen (use_gateway + fal provider)
- _select_plugin_video_gen_provider accepts use_gateway kwarg, propagated
  from all 4 call sites in _configure_provider when managed_feature is set
- hermes setup status shows 'Video Generation (FAL via Nous subscription)'

Users on a Nous subscription can now pick 'Nous Subscription' under
hermes tools → Video Generation, which sets video_gen.provider=fal +
video_gen.use_gateway=true. The FAL plugin's _resolve_managed_fal_video_gateway
then routes through the managed queue gateway — no FAL_KEY needed.
2026-05-29 22:26:24 +05:30
alt-glitch b6294ea9f1 test(video_gen): cover gateway decision matrix gaps and 4xx error path
- Add test for 4xx ValueError with actionable remediation message
- Add test for is_available() returning True via managed gateway
- Add test for prefers_gateway overriding direct FAL_KEY
- Add test for is_available() via gateway in plugin test file
2026-05-29 22:26:24 +05:30
alt-glitch d04b3c193e feat(video_gen): route FAL video gen through managed Nous gateway
Wire plugins/video_gen/fal/__init__.py to use the same
_ManagedFalSyncClient pattern that image gen already uses.

Changes:
- Add managed gateway resolution, client caching, and
  _submit_fal_video_request() that routes between direct FAL_KEY
  and Nous gateway modes
- Update is_available() to return True when either FAL_KEY or the
  managed gateway is reachable
- Update generate() to use submit+get handle pattern instead of
  fal_client.subscribe() directly
- Fix happy-horse endpoint namespace: fal-ai/ → alibaba/ (matches
  the tool-gateway allowlist from fal-video-gen branch)
- Surface actionable error on 4xx gateway rejections

Tests:
- 4 new tests in test_managed_media_gateways.py (gateway routing,
  client reuse, direct mode fallback, alibaba namespace)
- Updated existing test_fal_plugin.py fixture to use submit/handle
  pattern and patch _resolve_managed_fal_video_gateway for isolation
2026-05-29 22:26:24 +05:30
kshitijk4poor 5cd0673217 ci: harden supply-chain gate jobs against changes-job failure
The scan-gate / dep-bounds-gate jobs use needs.changes; if the changes
job itself fails, its dependents would be skipped via a failed dependency
(not a conditional skip), leaving the required check unreported — the same
"pending forever" failure this PR fixes. Add always() and switch the gate
condition from == 'false' to != 'true' so the gate still fires (and reports
SUCCESS) when changes fails and its output is empty.
2026-05-29 09:17:01 -07:00
ethernet 6bc309baf2 ci: ensure required checks always report status
Remove paths filters from contributor-check and supply-chain-audit
workflows. When no matching files changed, the workflows never ran and
the required checks (check-attribution, supply chain scan, dep bounds)
stayed "pending" forever, blocking merge.

Now both workflows always trigger. A path-check step/job determines
whether the real work should run; gate jobs with matching names report
success when the real job was skipped, so branch protection always
gets a check status.

Also fixes dep-bounds: the old condition
  if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
was always true (the || true made it unconditional). Now uses the
proper changes.deps output from the shared filter job.
2026-05-29 09:17:01 -07:00
ethernet 6928692cec Merge pull request #33773 from dvir-pashut/fix/nix-full-drop-stale-vercel-group
fix(nix): drop stale "vercel" group from #full variant
2026-05-29 11:16:25 -04:00
teknium1 75cd420b3b docs(skills): move antigravity-cli to autonomous-ai-agents in catalog + sidebar 2026-05-29 05:21:48 -07:00
teknium1 78d7fa1b5c refactor(skills/antigravity-cli): move to autonomous-ai-agents (it's an AI agent CLI) 2026-05-29 05:21:48 -07:00
teknium1 904c0b479b refactor(state): return FTS index count from vacuum()
Have vacuum() return optimize_fts()'s count so the CLI 'sessions optimize'
summary uses the real merged-index count instead of probing the private
_FTS_TABLES / _fts_table_exists() members.
2026-05-29 05:09:56 -07:00
kshitijk4poor 38695254f8 perf(state): merge FTS5 segments on VACUUM + add 'hermes sessions optimize'
The FTS5 indexes (messages_fts, messages_fts_trigram) grow as a series of
incremental b-tree segments — one per trigger-driven insert batch. SQLite's
automerge caps at ~16 segments, so a long-lived store keeps scanning many
segments per MATCH and never collapses them unless the special 'optimize'
command runs. Nothing in the codebase ever ran it: vacuum() only fired after
a prune that deleted rows, and even then never merged FTS segments.

Changes:
- SessionDB.optimize_fts(): merges each FTS5 index to a single segment,
  probing for the (optional/lazy) trigram table first so it is safe to call
  unconditionally. Layout-only — search results and snippet() are unchanged.
- vacuum() now calls optimize_fts() before VACUUM so freed index pages are
  returned to the OS in the same pass.
- 'hermes sessions optimize' CLI subcommand for on-demand reclamation +
  segment compaction (previously there was no way to compact the store
  without a prune deleting rows), with before/after size reporting.

Benchmark (8000 msgs, fragmented to 8 segments/index):
- segments 8 -> 1 on both indexes
- porter MATCH 5.5x faster (0.449 -> 0.081 ms/q)
- trigram MATCH 3.0x faster (0.632 -> 0.207 ms/q)
- 8000 matches before == 8000 after, identical row ids (no functional change)

Orthogonal to the structural FTS-size PRs (#20239 external-content,
#27770 optional trigram) — segment merge helps regardless of those.

Tests: TestOptimizeFts covers index count, search+snippet preservation,
missing-trigram path, and idempotency. Full test_hermes_state.py green (227).
2026-05-29 05:09:56 -07:00
Teknium 2159d2a729 docs(credential-pools): document immediate rotation on usage-limit 429 (#34580)
The rotation flowchart only described the generic 'retry once, rotate on
second 429' path. ChatGPT/Codex plan-limit 429s carry a usage_limit_reached
reason and rotate to the next pool key immediately (no retry, since the cap
won't clear on retry). Document that case so the docs match the code.
2026-05-29 04:50:14 -07:00
teknium1 0dba60f73b docs(skills): regen catalog + sidebar for optional antigravity-cli skill 2026-05-29 04:49:42 -07:00
teknium1 632a7088a3 chore(skills/antigravity-cli): make optional, frame through Hermes tools, tighten frontmatter 2026-05-29 04:49:42 -07:00
Tony Simons 1bba5f27ab feat(skills): add antigravity-cli operator skill 2026-05-29 04:49:42 -07:00
teknium1 d6f2bdabda docs(skills): regen catalog + sidebar for optional grok skill 2026-05-29 04:49:38 -07:00
teknium1 99ddba94ed chore(skills/grok): make optional + tighten SKILL.md to modern format 2026-05-29 04:49:38 -07:00
Matt Maximo 10cd4138cc feat(skills): add grok skill for xAI Grok Build CLI
Adds a `grok` skill under `skills/autonomous-ai-agents/`, a third coding-agent orchestration guide alongside `codex` and `claude-code`. It teaches Hermes to delegate coding tasks to Grok Build (xAI's `grok` CLI).

- Headless `-p` one-shots (preferred)
- Interactive TUI via pty + tmux
- Session resume, background tasks, structured JSON output
- PR review and parallel worktree patterns
- Auth via SuperGrok / X Premium+ (`grok login`)
- Full pitfalls and config notes
2026-05-29 04:49:38 -07:00
Teknium 5e7c2ffa9f chore(models): gemini-3.5-flash replaces gemini-3-flash-preview in OpenRouter + Nous lists (#34581)
* chore(models): swap gemini-3-flash-preview for gemini-3.5-flash in OpenRouter + Nous lists

* chore(models): regenerate model-catalog.json for gemini-3.5-flash swap
2026-05-29 04:27:58 -07:00
dvir pashut 66265a0571 fix(nix): drop stale "vercel" group from #full variant
The `vercel` optional-dependency was removed from pyproject.toml in
#33067, but `nix/packages.nix` (added a few hours later in #33108)
still references `"vercel"` in the `#full` variant's
`extraDependencyGroups`. uv2nix fails evaluation with:

  error: Extra/group name 'vercel' does not match either extra or
  dependency group

Because `nix/devShell.nix` does
`inputsFrom = builtins.attrValues self'.packages`, the broken `#full`
derivation is pulled into the dev shell too, so `nix develop` /
direnv breaks on a fresh clone — not just `nix build .#full`.
2026-05-28 11:52:31 +03:00
602 changed files with 33466 additions and 9072 deletions
+17 -5
View File
@@ -3,11 +3,9 @@ name: Contributor Attribution Check
on:
pull_request:
branches: [main]
paths:
# Only run when code files change (not docs-only PRs)
- '*.py'
- '**/*.py'
- '.github/workflows/contributor-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).
permissions:
contents: read
@@ -20,7 +18,21 @@ jobs:
with:
fetch-depth: 0 # Full history needed for git log
- name: Check if relevant files changed
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
if [ -n "$CHANGED" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No Python files changed, skipping attribution check."
fi
- name: Check for unmapped contributor emails
if: steps.filter.outputs.run == 'true'
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
+19
View File
@@ -200,3 +200,22 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
plugin-isolation:
# Enforce that core code and core tests never import from plugin packages.
# Core must interact with plugins exclusively through the registry layer.
# See scripts/check_no_plugin_imports_in_core.py for the rule list.
name: Plugin isolation (blocking)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: "3.11"
- name: Run plugin isolation checker
run: python scripts/check_no_plugin_imports_in_core.py
+67 -10
View File
@@ -3,15 +3,9 @@ name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**/*.py'
- '**/*.pth'
- '**/setup.py'
- '**/setup.cfg'
- '**/sitecustomize.py'
- '**/usercustomize.py'
- '**/__init__.pth'
- 'pyproject.toml'
# 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).
permissions:
pull-requests: write
@@ -27,8 +21,44 @@ permissions:
# advisory-only workflow instead.
jobs:
# ── Path filter (shared by both scan and dep-bounds) ───────────────
changes:
runs-on: ubuntu-latest
outputs:
# True when any file the scanner cares about changed in this PR
scan: ${{ steps.filter.outputs.scan }}
# True when pyproject.toml changed in this PR
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
id: filter
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
'*.py' '**/*.py' '*.pth' '**/*.pth' \
'setup.py' 'setup.cfg' \
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
'pyproject.toml' || true)
if [ -n "$SCAN_FILES" ]; then
echo "scan=true" >> "$GITHUB_OUTPUT"
else
echo "scan=false" >> "$GITHUB_OUTPUT"
fi
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
if [ -n "$DEPS_FILES" ]; then
echo "deps=true" >> "$GITHUB_OUTPUT"
else
echo "deps=false" >> "$GITHUB_OUTPUT"
fi
scan:
name: Scan PR for critical supply chain risks
needs: changes
if: needs.changes.outputs.scan == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -147,10 +177,24 @@ jobs:
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
# Gate: reports success when scan was skipped (no relevant files changed).
# This ensures the required check always gets a status.
scan-gate:
name: Scan PR for critical supply chain risks
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.scan != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No supply-chain-relevant files changed, skipping scan."
dep-bounds:
name: Check PyPI dependency upper bounds
needs: changes
if: needs.changes.outputs.deps == 'true'
runs-on: ubuntu-latest
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -211,3 +255,16 @@ jobs:
run: |
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
exit 1
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
# This ensures the required check always gets a status.
dep-bounds-gate:
name: Check PyPI dependency upper bounds
needs: changes
# always() so the gate still reports SUCCESS even if `changes` fails/is
# skipped — without it, a failed dependency would leave the required
# check unreported (i.e. "pending"), the exact failure mode this fixes.
if: always() && needs.changes.outputs.deps != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
+2 -2
View File
@@ -63,7 +63,7 @@ jobs:
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
uv sync --all-extras
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -169,7 +169,7 @@ jobs:
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
uv sync --all-extras
- name: Run e2e tests
run: |
+2 -3
View File
@@ -48,7 +48,7 @@ agent-browser/
privvy*
images/
__pycache__/
hermes_agent.egg-info/
*.egg-info
wandb/
testlogs
@@ -87,8 +87,7 @@ website/static/api/skills-meta.json
models-dev-upstream/
hermes_cli/tui_dist/*
hermes_cli/scripts/
docs/superpowers/*
# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
docs/superpowers/*# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
# also created in-repo when an agent operates in this checkout). Plans, audit
# logs, and per-session caches are never artifacts of the codebase.
.hermes/
+144 -57
View File
@@ -29,7 +29,9 @@ hermes-agent/
├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py # Parallel batch processing
├── _build_backend.py # Custom PEP 517 build backend — inlines plugin deps at wheel build time
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
│ └── plugin_registries.py # Typed capability registries (auth, transport, platform, tool, model_metadata)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
@@ -39,16 +41,20 @@ hermes-agent/
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
├── plugins/ # Plugin system (see "Plugins" section below)
├── plugins/ # Plugin packages — uv workspace members (see "Plugins" section)
│ ├── model-providers/ # anthropic, bedrock, azure-foundry (own pyproject.toml each)
│ ├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
│ ├── tts/ # Text-to-speech plugin
│ ├── stt/ # Speech-to-text plugin
│ ├── image_gen/ # FAL image generation
│ ├── terminals/ # daytona, modal, vercel
│ ├── web/ # exa, firecrawl, parallel
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
── <others>/ # dashboard, google_meet, spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@@ -486,9 +492,102 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
## Plugins
Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
repo-shipped plugins can be discovered alongside user-installed ones in
`~/.hermes/plugins/` and pip-installed entry points.
Hermes uses a **plugin-first architecture**: every optional capability (model
providers, platform adapters, TTS/STT, terminal backends, image generation)
lives in its own installable Python package under `plugins/`. The core
codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) **never** imports
from a `hermes_agent_*` plugin package directly. Instead, plugins register
their capabilities into typed registries during `register()`, and the core
queries those registries at runtime.
Full architecture doc: `website/docs/developer-guide/plugin-architecture.md`
### Workspace layout
All 21 builtin plugins are uv workspace members — each has its own
`pyproject.toml` (single source of truth for deps), `plugin.yaml`
(directory-scanner manifest for dev mode), and `hermes_agent_<name>/` package
with `register(ctx)`:
```
plugins/
├── model-providers/ # anthropic, bedrock, azure-foundry
├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix
├── tts/ # text-to-speech (Edge TTS + ElevenLabs)
├── stt/ # speech-to-text
├── image_gen/fal_pkg/ # FAL image generation
├── terminals/ # daytona, modal, vercel
├── web/ # exa, firecrawl, parallel
├── memory/ # honcho, hindsight
├── dashboard/ # streamlit dashboard
└── hermes-achievements/ # gamified achievement tracking
```
### The hermetic core boundary
Core code MUST NOT import from `hermes_agent_*` packages. Instead it queries
typed registries in `agent/plugin_registries.py`:
```python
# ❌ BAD — core directly imports plugin
from hermes_agent_bedrock import has_aws_credentials
# ✅ GOOD — core queries the registry
from agent.plugin_registries import registries
bedrock_auth = registries.get_auth_provider("bedrock")
```
Registry types: `auth_providers`, `transport_builders`, `platform_adapters`,
`tool_providers`, `model_metadata`, `credential_pools`.
Each plugin's `register(ctx)` populates the registries via `ctx.register_*()`:
- `ctx.register_auth_provider(name, provider, ...)`
- `ctx.register_transport(name, builder, ...)`
- `ctx.register_platform(name, label, adapter_factory, check_fn, ...)`
- `ctx.register_tool_provider(entry, ...)`
- `ctx.register_model_metadata(entry, ...)`
- `ctx.register_credential_pool(entry, ...)`
- Plus existing: `register_tool()`, `register_hook()`, `register_cli_command()`,
`register_tts_provider()`, `register_transcription_provider()`,
`register_image_gen_provider()`, `register_video_gen_provider()`,
`register_context_engine()`
### Plugin discovery
Three discovery paths (same as before, now workspace-aware):
1. **Directory scanner**`plugins/`, `~/.hermes/plugins/`, `.hermes/plugins/`
(looks for `plugin.yaml`)
2. **Entry points**`[project.entry-points."hermes_agent.plugins"]`
3. **uv workspace**`uv sync --extra <name>` installs the plugin into venv
### Dependency management
- Each plugin's `pyproject.toml` is the **only** place its deps are declared
- Root `pyproject.toml` maps extras to workspace members:
`telegram = ["hermes-agent-telegram"]`
- `uv.lock` resolves the whole workspace (236 packages)
- No `LAZY_DEPS`, no `ensure()`, no runtime `pip install`
- Custom PEP 517 build backend (`_build_backend.py`) inlines plugin deps
at wheel build time for PyPI publishing
### NixOS
`loadWorkspace` discovers all workspace members from `uv.lock` automatically.
`mkVirtualEnv { hermes-agent = ["all"] }` installs all plugins. Select specific
plugins with `extraDependencyGroups = ["telegram", "anthropic"]`.
### Tests
Plugin tests live in `plugins/<category>/<name>/tests/`. The test runner
discovers both `tests/` and `plugins/`. Running plugin tests requires the
plugin to be installed (`uv sync --extra <name>`).
### The rule
**If it can be a plugin, it must be a plugin.** Adding optional capabilities
to core files is a code review rejection. If the plugin surface doesn't
support what you need, extend the surface (new registry type, new hook, new
`ctx` method) — don't inline the capability.
### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
@@ -531,9 +630,14 @@ providers don't clutter `hermes --help`.
**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
If a plugin needs a capability the framework doesn't expose, expand the
generic plugin surface (new hook, new ctx method) — never hardcode
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
honcho argparse from `main.py` for exactly this reason.
generic plugin surface (new hook, new ctx method, new registry type) — never
hardcode plugin-specific logic into core. PR #5295 removed 95 lines of
hardcoded honcho argparse from `main.py` for exactly this reason.
**Hermetic core boundary (May 2026):** core code (`agent/`, `hermes_cli/`,
`gateway/`, `tools/`) MUST NOT import from `hermes_agent_*` plugin packages.
Use the typed registries in `agent/plugin_registries.py` instead. See the
**Plugins** section below for the full list of registry types.
**No new in-tree memory providers (policy, May 2026):** the set of
built-in memory providers under `plugins/memory/` is closed. New memory
@@ -1011,40 +1115,41 @@ def profile_env(tmp_path, monkeypatch):
## Testing
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
on a 16+ core developer machine with API keys set diverges from CI in ways
that have caused multiple "works locally, fails in CI" incidents (and the reverse).
**ALWAYS use `scripts/run_tests.sh`** — do NOT call `pytest` directly on a directory.
The script enforces hermetic environment parity with CI and provides per-file
process isolation that prevents registry singleton / module-level state leakage
between test files.
```bash
scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py # one file
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
### Subprocess-per-test isolation
For a **single test file or specific test**, bare `pytest` is fine:
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
ContextVars from one test cannot leak into the next — the historic
`_reset_module_state` autouse fixture is gone.
```bash
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py -q
nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py::test_x --tb=short
```
Implementation notes:
Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning
from `conftest.py` telling you to use the script instead.
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
Linux, macOS, and Windows alike (POSIX `fork` is not used).
- Per-test overhead is ~0.51.0s (Python startup + pytest collection). xdist
parallelism amortizes this across cores; on a 20-core box the full suite
finishes in roughly the same wall time as before, but flake-free.
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
Hangs are killed and surfaced as a failure report.
- Pass `--no-isolate` to disable isolation — useful when debugging a single
test interactively, or when you specifically want to verify state leakage.
- The plugin disables itself in child processes (sentinel envvar
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
### Per-file process isolation
`scripts/run_tests.sh` calls `scripts/run_tests_parallel.py`, which spawns one
`python -m pytest <file>` subprocess per test **file** (not per test), giving each
a fresh Python interpreter. This means module-level dicts/sets, ContextVars, and
registry singletons from one test file cannot leak into another — no shared state
between files, no xdist required.
`HERMES_PARALLEL_RUNNER=1` is set in each subprocess so `conftest.py` knows tests
are running under the managed runner. If you need to suppress the bare-pytest
directory warning in a special case, set this variable yourself — but prefer the
script.
### Why the wrapper (and why the old "just call pytest" doesn't work)
@@ -1056,31 +1161,13 @@ Five real sources of local-vs-CI drift the script closes:
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
| File isolation | Shared interpreter — state leaks between files | One subprocess per file |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
is belt-and-suspenders.
`tests/conftest.py` also enforces the credential/TZ/locale points as an autouse
fixture so ANY pytest invocation (including IDE integrations) gets hermetic
behavior — but the wrapper adds per-file process isolation on top.
### Running without the wrapper (only if you must)
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
at minimum activate the venv. The isolation plugin loads automatically from
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
either way.
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pytest tests/ -q
```
If you need to bypass isolation for fast feedback while debugging:
```bash
python -m pytest tests/agent/test_foo.py -q --no-isolate
```
Always run the full suite before pushing changes.
Always run the full suite via `scripts/run_tests.sh` before pushing changes.
### Don't write change-detector tests
+5 -6
View File
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
- Document handling, web research, common dev workflows, system administration
- Used regularly by a wide range of people
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
@@ -121,12 +121,11 @@ hermes chat -q "Hello"
### Run tests
```bash
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
# for parity with GitHub Actions before you open a PR:
pytest tests/ -v
# For a single file or specific test, bare pytest is also fine:
# python -m pytest tests/agent/test_foo.py -q
```
---
@@ -857,7 +856,7 @@ refactor/description # Code restructuring
### Before submitting
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI — hermetic env + per-file process isolation)
2. **Test manually**: Run `hermes` and exercise the code path you changed
3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2
4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature.
+30 -4
View File
@@ -136,13 +136,16 @@ RUN npm install --prefer-offline --no-audit && \
# frontend stats the readme path during dep resolution, so we `touch` an
# empty placeholder — the real README is restored by `COPY . .` below.
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# `uv sync --frozen --no-install-project --extra all --extra telegram
# --extra slack --extra discord --extra feishu --extra dingtalk`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# first-boot lazy install. The `[all]` extra already includes
# telegram, slack, discord, feishu, dingtalk. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# git), `[yc-bench]` (another git dep), `[matrix]` (python-olm, no
# wheel on all platforms), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
#
# Provider packages (anthropic, bedrock, azure-identity) are included
@@ -151,8 +154,31 @@ RUN npm install --prefer-offline --no-audit && \
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
# Workspace member pyproject.toml files must be present for uv to resolve
# the plugin extras. We copy the minimal structure needed; full source
# comes in the source COPY below.
COPY plugins/dashboard/pyproject.toml plugins/dashboard/
COPY plugins/image_gen/fal_pkg/pyproject.toml plugins/image_gen/fal_pkg/
COPY plugins/memory/hindsight/pyproject.toml plugins/memory/hindsight/
COPY plugins/memory/honcho/pyproject.toml plugins/memory/honcho/
COPY plugins/model-providers/anthropic/pyproject.toml plugins/model-providers/anthropic/
COPY plugins/model-providers/azure-foundry/pyproject.toml plugins/model-providers/azure-foundry/
COPY plugins/model-providers/bedrock/pyproject.toml plugins/model-providers/bedrock/
COPY plugins/platforms/dingtalk/pyproject.toml plugins/platforms/dingtalk/
COPY plugins/platforms/discord/pyproject.toml plugins/platforms/discord/
COPY plugins/platforms/feishu/pyproject.toml plugins/platforms/feishu/
COPY plugins/platforms/matrix/pyproject.toml plugins/platforms/matrix/
COPY plugins/platforms/slack/pyproject.toml plugins/platforms/slack/
COPY plugins/platforms/telegram/pyproject.toml plugins/platforms/telegram/
COPY plugins/stt/pyproject.toml plugins/stt/
COPY plugins/terminals/daytona/pyproject.toml plugins/terminals/daytona/
COPY plugins/terminals/modal/pyproject.toml plugins/terminals/modal/
COPY plugins/tts/pyproject.toml plugins/tts/
COPY plugins/web/exa/pyproject.toml plugins/web/exa/
COPY plugins/web/firecrawl/pyproject.toml plugins/web/firecrawl/
COPY plugins/web/parallel/pyproject.toml plugins/web/parallel/
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
RUN uv sync --frozen --no-install-project --extra all --extra anthropic --extra bedrock --extra azure-identity
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
+1 -1
View File
@@ -179,7 +179,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv venv --python 3.11
source venv/bin/activate
uv pip install -e ".[all,dev]"
python -m pytest tests/ -q
scripts/run_tests.sh
```
---
+183
View File
@@ -0,0 +1,183 @@
"""Custom PEP 517 build backend for hermes-agent.
At wheel build time, rewrites [project.optional-dependencies] so that
plugin extras (e.g. ``anthropic = ["hermes-agent-anthropic"]``) are
inlined with the actual deps from each plugin's pyproject.toml.
In the source repo (and on Nix), uv resolves workspace members natively
so this backend is NOT used — it's only invoked when building a wheel
for PyPI publication.
Usage in pyproject.toml::
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "_build_backend"
backend-path = ["."]
How it works:
1. ``build_wheel`` intercepts the call before setuptools sees pyproject.toml.
2. It reads the workspace member dirs from [tool.uv.workspace].members.
3. For each member, it reads the member's pyproject.toml and extracts
``project.dependencies`` (excluding the ``hermes-agent`` base dep).
4. It rewrites the main pyproject.toml's optional-dependencies to inline
those deps instead of the workspace member references.
5. It writes a temporary pyproject.toml, delegates to
``setuptools.build_meta.build_wheel``, then restores the original.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
import tomllib
# The original setuptools backend we delegate to.
_BACKEND = "setuptools.build_meta"
def _load_pyproject(path: Path) -> dict:
with path.open("rb") as f:
return tomllib.load(f)
def _save_pyproject(path: Path, data: dict) -> None:
"""Write a pyproject.toml. Uses a simple serializer since we only
need to preserve the structure enough for setuptools to parse."""
import tomli_w
with path.open("wb") as f:
tomli_w.dump(data, f)
def _inline_plugin_deps(root: Path, data: dict) -> dict:
"""Rewrite optional-dependencies to inline plugin deps.
Maps each plugin extra (e.g. ``anthropic = ["hermes-agent-anthropic"]``)
to the actual deps from that plugin's pyproject.toml, minus the
``hermes-agent`` base dependency.
"""
opt_deps = data.get("project", {}).get("optional-dependencies", {})
workspace = data.get("tool", {}).get("uv", {}).get("workspace", {})
members = workspace.get("members", [])
# Build a map: package name → (member_dir, pyproject_data)
pkg_to_deps: dict[str, list[str]] = {}
for member_glob in members:
for member_dir in sorted(root.glob(member_glob)):
pptoml = member_dir / "pyproject.toml"
if not pptoml.exists():
continue
member_data = _load_pyproject(pptoml)
pkg_name = member_data.get("project", {}).get("name", "")
if not pkg_name:
continue
# Extract deps, excluding the base hermes-agent dependency
raw_deps = member_data.get("project", {}).get("dependencies", [])
filtered = [
d for d in raw_deps
if not d.replace(" ", "").startswith("hermes-agent")
]
pkg_to_deps[pkg_name] = filtered
# Rewrite optional-dependencies
new_opt_deps = {}
for extra_name, specs in opt_deps.items():
new_specs = []
for spec in specs:
# Check if this spec references a workspace member package
if spec in pkg_to_deps:
# Inline the plugin's deps
new_specs.extend(pkg_to_deps[spec])
else:
new_specs.append(spec)
new_opt_deps[extra_name] = new_specs
data["project"]["optional-dependencies"] = new_opt_deps
# Remove [tool.uv] section — it's not valid in a published wheel
if "uv" in data.get("tool", {}):
del data["tool"]["uv"]
return data
# ---------------------------------------------------------------------------
# PEP 517 hooks
# ---------------------------------------------------------------------------
def build_wheel(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build a wheel with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
# Read and rewrite
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
# Write a temporary pyproject.toml, build, then restore
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
# Delegate to setuptools
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_wheel(wheel_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Build an sdist — no rewriting needed."""
import importlib
backend = importlib.import_module(_BACKEND)
return backend.build_sdist(sdist_directory, config_settings)
def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0", "tomli_w"]
def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]
def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str:
"""Prepare metadata with inlined plugin deps."""
root = Path.cwd()
pyproject_path = root / "pyproject.toml"
data = _load_pyproject(pyproject_path)
data = _inline_plugin_deps(root, data)
backup = pyproject_path.with_suffix(".toml.bak")
shutil.copy2(pyproject_path, backup)
try:
_save_pyproject(pyproject_path, data)
import importlib
backend = importlib.import_module(_BACKEND)
return backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
finally:
shutil.copy2(backup, pyproject_path)
backup.unlink()
def build_editable(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str:
"""Build an editable install — no rewriting needed (dev mode)."""
import importlib
backend = importlib.import_module(_BACKEND)
kwargs: dict[str, Any] = {"config_settings": config_settings}
if metadata_directory is not None:
kwargs["metadata_directory"] = metadata_directory
return backend.build_editable(wheel_directory, **kwargs)
def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]:
return ["setuptools>=61.0"]
+4 -2
View File
@@ -6,7 +6,9 @@ from typing import Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -176,7 +178,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:
return None
if not _is_oauth_token(token):
if _is_oauth_token is not None and not _is_oauth_token(token):
return AccountUsageSnapshot(
provider="anthropic",
source="oauth_usage_api",
+32 -28
View File
@@ -401,7 +401,7 @@ def init_agent(
agent.status_callback = status_callback
agent.tool_gen_callback = tool_gen_callback
# Tool execution state — allows _vprint during tool execution
# even when stream consumers are registered (no tokens streaming then)
agent._executing_tools = False
@@ -434,12 +434,12 @@ def init_agent(
# their tids explicitly.
agent._tool_worker_threads: set[int] = set()
agent._tool_worker_threads_lock = threading.Lock()
# Subagent delegation state
agent._delegate_depth = 0 # 0 = top-level agent, incremented for children
agent._active_children = [] # Running child AIAgents (for interrupt propagation)
agent._active_children_lock = threading.Lock()
# Store OpenRouter provider preferences
agent.providers_allowed = providers_allowed
agent.providers_ignored = providers_ignored
@@ -452,7 +452,7 @@ def init_agent(
# Store toolset filtering options
agent.enabled_toolsets = enabled_toolsets
agent.disabled_toolsets = disabled_toolsets
# Model response configuration
agent.max_tokens = max_tokens # None = use model default
agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
@@ -460,7 +460,7 @@ def init_agent(
agent.request_overrides = dict(request_overrides or {})
agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns
agent._force_ascii_payload = False
# Anthropic prompt caching: auto-enabled for Claude models on native
# Anthropic, OpenRouter, and third-party gateways that speak the
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
@@ -532,7 +532,7 @@ def init_agent(
# console. Any future noise reduction belongs at the
# handler level inside hermes_logging.py, not here.
pass
# Internal stream callback (set during streaming TTS).
# Initialized here so _vprint can reference it before run_conversation.
agent._stream_callback = None
@@ -582,12 +582,14 @@ def init_agent(
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
# Bedrock + Claude → use AnthropicBedrock SDK for full feature parity
# (prompt caching, thinking budgets, adaptive thinking).
_is_bedrock_anthropic = agent.provider == "bedrock"
if _is_bedrock_anthropic:
from agent.anthropic_adapter import build_anthropic_bedrock_client
build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client")
_region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "")
_br_region = _region_match.group(1) if _region_match else "us-east-1"
agent._bedrock_region = _br_region
@@ -641,8 +643,8 @@ def init_agent(
# so injects Claude-Code identity headers and system prompts
# that cause 401/403 on their endpoints. Guards #1739 and
# the third-party identity-injection bug.
from agent.anthropic_adapter import _is_oauth_token as _is_oat
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
# No OpenAI client needed for Anthropic mode
agent.client = None
@@ -654,9 +656,10 @@ def init_agent(
# The Anthropic adapter installs an httpx event hook
# that mints a fresh JWT per request — we never
# invoke or inspect the callable in the banner.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
if is_token_provider(effective_key):
if is_token_provider and is_token_provider(effective_key):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
@@ -866,10 +869,11 @@ def init_agent(
# provider (Azure Foundry). The OpenAI SDK mints a
# fresh JWT per request internally — the banner
# never invokes or inspects the callable.
from agent.azure_identity_adapter import is_token_provider
from agent.plugin_registries import registries
is_token_provider = registries.get_provider_service("azure", "is_token_provider")
key_used = client_kwargs.get("api_key", "none")
if is_token_provider(key_used):
if is_token_provider and is_token_provider(key_used):
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12:
print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}")
@@ -877,7 +881,7 @@ def init_agent(
print("⚠️ Warning: API key appears invalid or missing")
except Exception as e:
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
# Provider fallback chain — ordered list of backup providers tried
# when the primary is exhausted (rate-limit, overload, connection
# failure). Supports both legacy single-dict ``fallback_model`` and
@@ -909,7 +913,7 @@ def init_agent(
disabled_toolsets=disabled_toolsets,
quiet_mode=agent.quiet_mode,
)
# Show tool configuration and store valid tool names for validation
agent.valid_tool_names = set()
if agent.tools:
@@ -942,16 +946,16 @@ def init_agent(
missing_reqs = [name for name, available in requirements.items() if not available]
if missing_reqs:
print(f"⚠️ Some tools may not work due to missing requirements: {missing_reqs}")
# Show trajectory saving status
if agent.save_trajectories and not agent.quiet_mode:
print("📝 Trajectory saving enabled")
# Show ephemeral system prompt status
if agent.ephemeral_system_prompt and not agent.quiet_mode:
prompt_preview = agent.ephemeral_system_prompt[:60] + "..." if len(agent.ephemeral_system_prompt) > 60 else agent.ephemeral_system_prompt
print(f"🔒 Ephemeral system prompt: '{prompt_preview}' (not saved to trajectories)")
# Show prompt caching status
if agent._use_prompt_caching and not agent.quiet_mode:
if agent._use_native_cache_layout and agent.provider == "anthropic":
@@ -961,7 +965,7 @@ def init_agent(
else:
source = "Claude via OpenRouter"
print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)")
# Session logging setup - auto-save conversation trajectories for debugging
agent.session_start = datetime.now()
if session_id:
@@ -1001,7 +1005,7 @@ def init_agent(
pass
# logs_dir is retained unconditionally for request_dump_*.json (debug
# breadcrumb path written by agent_runtime_helpers.dump_api_request_debug).
# Track conversation messages for session logging
agent._session_messages: List[Dict[str, Any]] = []
# Responses encrypted reasoning replay state. Some OpenAI-compatible
@@ -1013,10 +1017,10 @@ def init_agent(
agent._codex_reasoning_replay_enabled = True
agent._memory_write_origin = "assistant_tool"
agent._memory_write_context = "foreground"
# Cached system prompt -- built once per session, only rebuilt on compression
agent._cached_system_prompt: Optional[str] = None
# Filesystem checkpoint manager (transparent — not a tool)
from tools.checkpoint_manager import CheckpointManager
agent._checkpoint_mgr = CheckpointManager(
@@ -1025,7 +1029,7 @@ def init_agent(
max_total_size_mb=checkpoint_max_total_size_mb,
max_file_size_mb=checkpoint_max_file_size_mb,
)
# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._parent_session_id = parent_session_id
@@ -1036,11 +1040,11 @@ def init_agent(
"reasoning_config": reasoning_config,
"max_tokens": max_tokens,
}
# In-memory todo list for task planning (one per agent/session)
from tools.todo_tool import TodoStore
agent._todo_store = TodoStore()
# Load config once for memory, skills, and compression sections
try:
from hermes_cli.config import load_config as _load_agent_config
@@ -1082,7 +1086,7 @@ def init_agent(
agent._memory_store.load_from_disk()
except Exception:
pass # Memory is optional -- don't break agent init
# Memory provider plugin (external — one at a time, alongside built-in)
@@ -1553,7 +1557,7 @@ def init_agent(
agent.session_estimated_cost_usd = 0.0
agent.session_cost_status = "unknown"
agent.session_cost_source = "none"
# ── Ollama num_ctx injection ──
# Ollama defaults to 2048 context regardless of the model's capabilities.
# When running against an Ollama server, detect the model's max context
+8 -7
View File
@@ -759,7 +759,8 @@ def try_recover_primary_transport(
agent.api_key = rt["api_key"]
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@@ -923,7 +924,8 @@ def restore_primary_runtime(agent) -> bool:
# ── Rebuild client for the primary provider ──
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
agent._anthropic_api_key = rt["anthropic_api_key"]
agent._anthropic_base_url = rt["anthropic_base_url"]
agent._anthropic_client = build_anthropic_client(
@@ -1429,11 +1431,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Build new client ──
if api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
resolve_anthropic_token,
_is_oauth_token,
)
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token")
_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token")
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
# API key — falling back would send Anthropic credentials to third-party endpoints.
+166
View File
@@ -0,0 +1,166 @@
"""Anthropic auxiliary client wrappers — core module, no SDK dependency.
Provides OpenAI-client-compatible shims over native Anthropic SDK clients,
so auxiliary tasks (compression, vision, web extract, etc.) can call
``client.chat.completions.create()`` regardless of the underlying SDK.
The wrapper classes themselves never import the anthropic SDK. They delegate
wire-format conversion to :mod:`agent.anthropic_format` and response
normalization to the ``anthropic_messages`` transport registered in
:mod:`agent.transports`.
"""
from __future__ import annotations
import asyncio
import logging
from types import SimpleNamespace
from typing import Any, Optional
from agent.anthropic_format import (
build_anthropic_kwargs,
_forbids_sampling_params,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Adapter: Anthropic SDK → OpenAI-compatible completions.create()
# ---------------------------------------------------------------------------
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
self._client = real_client
self._model = model
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.transports import get_transport
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
if _skip_mt:
max_tokens = None
else:
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
temperature = kwargs.get("temperature")
normalized_tool_choice = None
if isinstance(tool_choice, str):
normalized_tool_choice = tool_choice
elif isinstance(tool_choice, dict):
choice_type = str(tool_choice.get("type", "")).lower()
if choice_type == "function":
normalized_tool_choice = tool_choice.get("function", {}).get("name")
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth
)
assistant_message = SimpleNamespace(
content=_nr.content,
tool_calls=_nr.tool_calls,
reasoning=_nr.reasoning,
)
finish_reason = _nr.finish_reason
usage = None
if hasattr(response, "usage") and response.usage:
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
usage = SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
choice = SimpleNamespace(
index=0,
message=assistant_message,
finish_reason=finish_reason,
)
return SimpleNamespace(
choices=[choice],
model=model,
usage=usage,
)
class _AnthropicChatShim:
def __init__(self, adapter: _AnthropicCompletionsAdapter):
self.completions = adapter
# ---------------------------------------------------------------------------
# Public wrappers
# ---------------------------------------------------------------------------
class AnthropicAuxiliaryClient:
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
self._real_client = real_client
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
self.chat = _AnthropicChatShim(adapter)
self.api_key = api_key
self.base_url = base_url
def close(self):
close_fn = getattr(self._real_client, "close", None)
if callable(close_fn):
close_fn()
class _AsyncAnthropicCompletionsAdapter:
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncAnthropicChatShim:
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
self.completions = adapter
class AsyncAnthropicAuxiliaryClient:
def __init__(self, sync_wrapper: AnthropicAuxiliaryClient):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
self.chat = _AsyncAnthropicChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
# Mirror _real_client so cache eviction on a poisoned underlying
# client also drops this entry.
self._real_client = sync_wrapper._real_client
File diff suppressed because it is too large Load Diff
+201 -453
View File
@@ -106,6 +106,41 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Core anthropic wire-format modules (no SDK dependency)
# ---------------------------------------------------------------------------
from agent.anthropic_aux import ( # noqa: F401
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
# ---------------------------------------------------------------------------
# Plugin-registry helper — access *plugin-provided* anthropic services
# (resolve.py functions: maybe_wrap_anthropic, is_anthropic_compat_endpoint, etc.)
# Wire-format code (message conversion, aux client wrappers) lives in core
# and is imported directly above.
# ---------------------------------------------------------------------------
def _anthropic_plugin_service(name: str):
"""Lazy accessor for anthropic plugin resolve services.
Only the SDK-dependent orchestration (maybe_wrap_anthropic,
is_anthropic_compat_endpoint, convert_openai_images_to_anthropic) lives
in the plugin. Core accesses it through
``registries.get_provider_service("anthropic", name)`` so that:
- Core never imports from a plugin package directly.
- The plugin need only be installed when the user actually uses it.
"""
from agent.plugin_registries import registries
svc = registries.get_provider_service("anthropic", name)
if svc is None:
raise ImportError(
f"anthropic plugin service {name!r} not available — "
f"the hermes_agent_anthropic package may not be installed"
)
return svc
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
"""Return False instead of raising when a patched symbol is not a type."""
@@ -115,6 +150,35 @@ def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
return False
def _is_async_client(client: Any) -> bool:
"""Check whether *client* is already an async-capable auxiliary client.
Covers AsyncAnthropicAuxiliaryClient, AsyncCodexAuxiliaryClient,
AsyncGeminiNativeClient, and CopilotACPClient (which is natively async).
"""
if _safe_isinstance(client, AsyncCodexAuxiliaryClient):
return True
try:
from agent.anthropic_aux import AsyncAnthropicAuxiliaryClient
if _safe_isinstance(client, AsyncAnthropicAuxiliaryClient):
return True
except ImportError:
pass
try:
from agent.gemini_native_adapter import AsyncGeminiNativeClient
if _safe_isinstance(client, AsyncGeminiNativeClient):
return True
except ImportError:
pass
try:
from agent.copilot_acp_client import CopilotACPClient
if _safe_isinstance(client, CopilotACPClient):
return True
except ImportError:
pass
return False
def _extract_url_query_params(url: str):
"""Extract query params from URL, return (clean_url, default_query dict or None)."""
parsed = urlparse(url)
@@ -417,7 +481,6 @@ auxiliary_is_nous: bool = False
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_NOUS_MODEL = "google/gemini-3-flash-preview"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
# Codex OAuth endpoint used when a caller explicitly requests
@@ -956,253 +1019,6 @@ class AsyncCodexAuxiliaryClient:
self._real_client = sync_wrapper._real_client
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
self._client = real_client
self._model = model
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.transports import get_transport
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
if _skip_mt:
max_tokens = None
else:
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
temperature = kwargs.get("temperature")
normalized_tool_choice = None
if isinstance(tool_choice, str):
normalized_tool_choice = tool_choice
elif isinstance(tool_choice, dict):
choice_type = str(tool_choice.get("type", "")).lower()
if choice_type == "function":
normalized_tool_choice = tool_choice.get("function", {}).get("name")
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth
)
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
# .function.arguments) via properties, so no wrapping needed.
assistant_message = SimpleNamespace(
content=_nr.content,
tool_calls=_nr.tool_calls,
reasoning=_nr.reasoning,
)
finish_reason = _nr.finish_reason
usage = None
if hasattr(response, "usage") and response.usage:
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
usage = SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
choice = SimpleNamespace(
index=0,
message=assistant_message,
finish_reason=finish_reason,
)
return SimpleNamespace(
choices=[choice],
model=model,
usage=usage,
)
class _AnthropicChatShim:
def __init__(self, adapter: _AnthropicCompletionsAdapter):
self.completions = adapter
class AnthropicAuxiliaryClient:
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
self._real_client = real_client
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
self.chat = _AnthropicChatShim(adapter)
self.api_key = api_key
self.base_url = base_url
def close(self):
close_fn = getattr(self._real_client, "close", None)
if callable(close_fn):
close_fn()
class _AsyncAnthropicCompletionsAdapter:
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncAnthropicChatShim:
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
self.completions = adapter
class AsyncAnthropicAuxiliaryClient:
def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
self.chat = _AsyncAnthropicChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
# See AsyncCodexAuxiliaryClient: mirror _real_client so cache
# eviction on a poisoned underlying client also drops this entry.
self._real_client = sync_wrapper._real_client
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the
auxiliary client and the main agent stay in sync on transport selection.
Covers:
- Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies,
Anthropic-compatible gateways).
- ``api.kimi.com/coding`` (Kimi Coding Plan — the /coding route only
speaks Claude-Code's native Anthropic shape; ``chat.completions``
returns 404 on Anthropic-only model aliases like ``kimi-for-coding``).
- ``api.anthropic.com`` (native Anthropic).
"""
normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
if normalized.endswith("/anthropic"):
return True
hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com":
return True
if hostname == "api.kimi.com" and "/coding" in normalized:
return True
return False
def _maybe_wrap_anthropic(
client_obj: Any,
model: str,
api_key: str,
base_url: str,
api_mode: Optional[str] = None,
) -> Any:
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
the endpoint actually speaks Anthropic Messages.
This is the single chokepoint for aux-client transport correction.
Runs at the end of every ``resolve_provider_client`` branch so that
api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and
future /anthropic gateways all land on the right wire format
regardless of which branch built the client.
Returns ``client_obj`` unchanged when:
- It's already an Anthropic/Codex/Gemini/CopilotACP wrapper.
- The endpoint is an OpenAI-wire endpoint.
- ``api_mode`` is explicitly set to a non-Anthropic transport.
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
"""
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
try:
from agent.gemini_native_adapter import GeminiNativeClient
if _safe_isinstance(client_obj, GeminiNativeClient):
return client_obj
except ImportError:
pass
try:
from agent.copilot_acp_client import CopilotACPClient
if _safe_isinstance(client_obj, CopilotACPClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics.
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or _endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
try:
from agent.anthropic_adapter import build_anthropic_client
except ImportError:
logger.warning(
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
"not installed — falling back to OpenAI-wire (will likely 404).",
base_url,
)
return client_obj
try:
real_client = build_anthropic_client(api_key, base_url)
except Exception as exc:
logger.warning(
"Failed to build Anthropic client for %s (%s) — falling back to "
"OpenAI-wire client.", base_url, exc,
)
return client_obj
logger.debug(
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
"(model=%s, base_url=%s, api_mode=%s)",
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
)
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _read_nous_auth() -> Optional[dict]:
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
@@ -1419,7 +1235,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
continue
except ImportError:
pass
return _try_anthropic()
# Delegate to the anthropic plugin resolver via the registry
from agent.plugin_registries import registries as _ar
_anthro_resolver = _ar.get_provider_resolver("anthropic")
if _anthro_resolver is not None:
_ac, _am = _anthro_resolver()
if _ac is not None:
return _ac, _am
continue
pool_present, entry = _select_pool_entry(provider_id)
if pool_present:
@@ -1456,7 +1279,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
except Exception:
pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
creds = resolve_api_key_provider_credentials(provider_id)
@@ -1493,7 +1316,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
except Exception:
pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
return None, None
@@ -1502,7 +1325,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
# ── Provider resolution helpers ─────────────────────────────────────────────
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
pool_present, entry = _select_pool_entry("openrouter")
if pool_present:
@@ -1680,26 +1502,48 @@ def _read_main_provider() -> str:
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
_RUNTIME_MAIN_MODEL: str = ""
_RUNTIME_MAIN_BASE_URL: str = ""
_RUNTIME_MAIN_API_KEY: str = ""
_RUNTIME_MAIN_API_MODE: str = ""
def set_runtime_main(provider: str, model: str) -> None:
"""Record the live runtime provider/model for the current AIAgent.
def set_runtime_main(
provider: str,
model: str,
*,
base_url: str = "",
api_key: str = "",
api_mode: str = "",
) -> None:
"""Record the live runtime provider/model/credentials for the current AIAgent.
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
equivalent setter) at the top of each turn so that
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
overrides instead of the stale config.yaml default.
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
recorded so that ``_resolve_auto`` can construct a valid client in
Step 1 instead of falling through to the aggregator chain.
"""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
_RUNTIME_MAIN_MODEL = (model or "").strip()
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
def clear_runtime_main() -> None:
"""Clear the runtime override (e.g. on session end)."""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
_RUNTIME_MAIN_PROVIDER = ""
_RUNTIME_MAIN_MODEL = ""
_RUNTIME_MAIN_BASE_URL = ""
_RUNTIME_MAIN_API_KEY = ""
_RUNTIME_MAIN_API_MODE = ""
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
@@ -1827,7 +1671,11 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
# LiteLLM proxies, etc.). Must NEVER be treated as OAuth —
# Anthropic OAuth claims only apply to api.anthropic.com.
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@@ -1842,7 +1690,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
# URL-based anthropic detection for custom endpoints that didn't set
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
_fallback_client = _maybe_wrap_anthropic(
_fallback_client = _anthropic_plugin_service("maybe_wrap_anthropic")(
_fallback_client, model, custom_key, custom_base, custom_mode,
)
return _fallback_client, model
@@ -2020,7 +1868,7 @@ def _try_azure_foundry(
# for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →
# ``build_anthropic_client`` detects the callable and installs
# the bearer-injecting httpx hook.
return _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client, final_model, api_key,
base_url, runtime_api_mode,
), final_model
@@ -2029,54 +1877,6 @@ def _try_azure_foundry(
return client, final_model
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
try:
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
except ImportError:
return None, None
pool_present, entry = _select_pool_entry("anthropic")
if pool_present:
if entry is None:
return None, None
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
entry = None
token = explicit_api_key or resolve_anthropic_token()
if not token:
return None, None
# Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic — otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
except Exception:
pass
from agent.anthropic_adapter import _is_oauth_token
is_oauth = _is_oauth_token(token)
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
except ImportError:
# The anthropic_adapter module imports fine but the SDK itself is
# missing — build_anthropic_client raises ImportError at call time
# when _anthropic_sdk is None. Treat as unavailable.
return None, None
return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model
_AUTO_PROVIDER_LABELS = {
"_try_openrouter": "openrouter",
"_try_nous": "nous",
@@ -2657,8 +2457,8 @@ def _retry_same_provider_sync(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task,
)
@@ -2714,8 +2514,8 @@ async def _retry_same_provider_async(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task,
)
@@ -2745,12 +2545,19 @@ def _refresh_provider_credentials(provider: str) -> bool:
_evict_cached_clients(normalized)
return True
if normalized == "anthropic":
from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_refresh_oauth_token = _anthropic.get("_refresh_oauth_token")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if read_claude_code_credentials is None:
return False
creds = read_claude_code_credentials()
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None
token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None
if not str(token or "").strip():
token = resolve_anthropic_token()
if resolve_anthropic_token is not None:
token = resolve_anthropic_token()
if not str(token or "").strip():
return False
_evict_cached_clients(normalized)
@@ -2980,6 +2787,18 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
runtime_api_key = runtime.get("api_key", "")
runtime_api_mode = str(runtime.get("api_mode") or "")
# Fall back to process-local globals when main_runtime dict was not
# provided or was incomplete. ``set_runtime_main()`` now records
# base_url/api_key/api_mode alongside provider/model, so custom:
# providers get the full credential surface in Step 1 of the
# auto-detect chain.
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
runtime_base_url = _RUNTIME_MAIN_BASE_URL
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
runtime_api_key = _RUNTIME_MAIN_API_KEY
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
runtime_api_mode = _RUNTIME_MAIN_API_MODE
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
# scenario where a user switches providers via `hermes model` but the
@@ -3089,7 +2908,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
if _safe_isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@@ -3275,7 +3094,7 @@ def resolve_provider_client(
return CodexAuxiliaryClient(client_obj, final_model_str)
# Anthropic-wire endpoints: rewrap plain OpenAI clients so
# chat.completions.create() is translated to /v1/messages.
return _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
)
@@ -3507,7 +3326,11 @@ def resolve_provider_client(
# branch in _try_custom_endpoint(). See #15033.
if entry_api_mode == "anthropic_messages":
try:
from agent.anthropic_adapter import build_anthropic_client
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
if build_anthropic_client is None:
raise ImportError("anthropic provider not registered")
real_client = build_anthropic_client(custom_key, custom_base)
except ImportError:
logger.warning(
@@ -3550,39 +3373,37 @@ def resolve_provider_client(
except ImportError:
pass
# ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing)
#
# The generic PROVIDER_REGISTRY path below uses
# ``resolve_api_key_provider_credentials`` which only knows about the
# static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important
# cases for the ``azure-foundry`` provider:
#
# 1. ``model.auth_mode: entra_id`` — no static key exists; we need
# a callable bearer-token provider from ``azure_identity_adapter``.
# 2. Non-default ``model.base_url`` (Foundry projects path) — the
# env-var-only resolver doesn't apply config-yaml-driven URL
# overrides.
#
# Delegate to the same runtime resolver the main agent uses so
# auxiliary tasks (title generation, compression, vision, embedding,
# session search) inherit the user's full Azure config.
if provider == "azure-foundry":
client, default_model = _try_azure_foundry(
# ── Plugin-registered resolvers (azure-foundry, etc.) ─────────────
# Providers with complex auth (Entra ID, OAuth, etc.) register a
# resolver callable so core doesn't need per-provider if/elif branches.
from agent.plugin_registries import registries as _reg_early
_early_resolver = _reg_early.get_provider_resolver(provider)
if _early_resolver is not None:
client, default_model = _early_resolver(
model=model,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
async_mode=async_mode,
is_vision=is_vision,
main_runtime=main_runtime,
api_mode=api_mode,
)
if client is None:
logger.warning(
"resolve_provider_client: azure-foundry requested but "
"runtime resolution failed (run: hermes doctor for "
"diagnostics)"
)
return None, None
final_model = _normalize_resolved_model(model or default_model, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
if client is not None:
final_model = _normalize_resolved_model(model or default_model, provider)
# Resolvers that accept async_mode may already return an async
# client (e.g. bedrock returns AsyncAnthropicAuxiliaryClient).
# Don't double-wrap — only call _to_async_client when the
# resolver returned a sync client despite async_mode=True.
if async_mode and not _is_async_client(client):
return _to_async_client(client, final_model, is_vision=is_vision)
return client, final_model
# Resolver returned None — provider unavailable
logger.warning(
"resolve_provider_client: %s requested but resolver returned "
"no client (run: hermes doctor for diagnostics)",
provider,
)
return None, None
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
@@ -3601,14 +3422,6 @@ def resolve_provider_client(
return None, None
if pconfig.auth_type == "api_key":
if provider == "anthropic":
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
if client is None:
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
return None, None
final_model = _normalize_resolved_model(model or default_model, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model))
creds = resolve_api_key_provider_credentials(provider)
api_key = str(creds.get("api_key", "")).strip()
# Honour an explicit api_key override (e.g. from a fallback_model entry
@@ -3741,37 +3554,14 @@ def resolve_provider_client(
return None, None
elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
"boto3 or anthropic SDK not installed")
return None, None
if not has_aws_credentials():
logger.debug("resolve_provider_client: bedrock requested but "
"no AWS credentials found")
return None, None
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
# AWS SDK providers (e.g. Bedrock) — handled by the early resolver
# catch above when a plugin registers one. If we reach here, no
# resolver was registered.
logger.warning(
"resolve_provider_client: aws_sdk provider %s has no "
"registered resolver (plugin not loaded?)", provider,
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
return None, None
elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
# OAuth providers — route through their specific try functions
@@ -3895,7 +3685,12 @@ def _resolve_strict_vision_backend(
# allow-list); callers must specify via auxiliary.<task>.model.
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
from agent.plugin_registries import registries as _reg
_resolver = _reg.get_provider_resolver("anthropic")
if _resolver is not None:
return _resolver(model=model)
# Fallback: no resolver registered (plugin not loaded)
return None, None
if provider == "custom":
return _try_custom_endpoint()
return None, None
@@ -4640,54 +4435,6 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
return "/anthropic" in url_lower
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based image
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _build_call_kwargs(
provider: str,
model: str,
@@ -4717,32 +4464,33 @@ def _build_call_kwargs(
# structured-JSON extraction) don't 400 the moment
# the aux model is flipped to 4.7.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if _forbids_sampling_params(model):
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_forbids_sampling_params = _anthropic.get("_forbids_sampling_params")
if _forbids_sampling_params is not None and _forbids_sampling_params(model):
temperature = None
if temperature is not None:
kwargs["temperature"] = temperature
if max_tokens is not None:
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
_model_lower = (model or "").lower()
_skip_max_tokens = (
provider == "zai"
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
# We do NOT cap output by default. Most chat-completions providers treat
# an omitted max_tokens as "use the model's max output", which is what we
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
# an explicit cap only risks truncating a summary or 400-ing on providers
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
# models reject it entirely with error 1210). Omitting it sidesteps all of
# those wire-format quirks at once.
#
# The one exception is the Anthropic Messages wire (MiniMax and any
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
# there.
_effective_base = base_url or (
_current_custom_base_url() if provider == "custom" else ""
)
if _skip_max_tokens:
pass # ZAI vision models do not accept max_tokens
elif provider == "custom":
custom_base = base_url or _current_custom_base_url()
if base_url_hostname(custom_base) == "api.openai.com":
kwargs["max_completion_tokens"] = max_tokens
else:
kwargs["max_tokens"] = max_tokens
else:
if _is_anthropic_compat_endpoint(provider, _effective_base):
kwargs["max_tokens"] = max_tokens
if tools:
@@ -4930,8 +4678,8 @@ def call_llm(
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
@@ -5373,8 +5121,8 @@ async def async_call_llm(
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
try:
return _validate_llm_response(
+44 -20
View File
@@ -202,12 +202,14 @@ def interruptible_api_call(agent, api_kwargs: dict):
# normalize_converse_response produces an OpenAI-compatible
# SimpleNamespace so the rest of the agent loop can treat
# bedrock responses like chat_completions responses.
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
normalize_converse_response,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
normalize_converse_response = _bedrock.get("normalize_converse_response")
if _get_bedrock_runtime_client is None or normalize_converse_response is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
@@ -681,8 +683,11 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_ant_max = None
if (_is_or or _is_nous) and "claude" in (agent.model or "").lower():
try:
from agent.anthropic_adapter import _get_anthropic_max_output
_ant_max = _get_anthropic_max_output(agent.model)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_get_anthropic_max_output = _anthropic.get("_get_anthropic_max_output")
if _get_anthropic_max_output is not None:
_ant_max = _get_anthropic_max_output(agent.model)
except Exception:
pass
@@ -1167,15 +1172,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if fb_api_mode == "anthropic_messages":
# Build native Anthropic client instead of using OpenAI client
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
_is_oauth_token = _anthropic.get("_is_oauth_token")
effective_key = (fb_client.api_key or (resolve_anthropic_token() if resolve_anthropic_token else "") or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = fb_base_url
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False
if build_anthropic_client is not None:
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url, timeout=_fb_timeout,
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" and _is_oauth_token else False
agent.client = None
agent._client_kwargs = {}
else:
@@ -1283,6 +1293,18 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
agent._copy_reasoning_content_for_api(msg, api_msg)
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
api_msg.pop(internal_field, None)
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
# Completions schema. The main loop drops these via
# ChatCompletionsTransport.convert_messages(), but the summary path
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
agent._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)
@@ -1559,12 +1581,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
def _bedrock_call():
try:
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
stream_converse_with_callbacks,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client")
invalidate_runtime_client = _bedrock.get("invalidate_runtime_client")
is_stale_connection_error = _bedrock.get("is_stale_connection_error")
stream_converse_with_callbacks = _bedrock.get("stream_converse_with_callbacks")
if _get_bedrock_runtime_client is None or stream_converse_with_callbacks is None:
raise ImportError("bedrock provider not registered")
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
+114 -13
View File
@@ -40,17 +40,47 @@ SUMMARY_PREFIX = (
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"If the latest user message is consistent with the '## Active Task' "
"section, you may use the summary as background. If the latest user "
"message contradicts, supersedes, changes topic from, or in any way "
"diverges from '## Active Task' / '## In Progress' / '## Pending User "
"Asks' / '## Remaining Work', the latest message WINS — discard those "
"stale items entirely and do not 'wrap up the old task first'. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:",
)
# Minimum tokens for the summary output
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
@@ -518,6 +548,10 @@ class ContextCompressor(ContextEngine):
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def update_model(
self,
@@ -615,6 +649,10 @@ class ContextCompressor(ContextEngine):
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
self.summary_model = summary_model_override or ""
@@ -648,6 +686,44 @@ class ContextCompressor(ContextEngine):
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
if self.last_prompt_tokens > 0:
self.last_real_prompt_tokens = self.last_prompt_tokens
if self.last_prompt_tokens < self.threshold_tokens:
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
else:
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""Return True when a high rough preflight estimate is known-noisy.
``estimate_request_tokens_rough(..., tools=...)`` intentionally
overestimates schema-heavy requests so Hermes compresses before a
provider rejects the payload. After a successful compressed API call,
though, provider ``prompt_tokens`` are a better signal than repeating
compaction from the same rough schema overhead. Defer only while the
rough estimate has grown modestly since a request the provider proved
fit under the threshold.
"""
if rough_tokens < self.threshold_tokens:
return False
if self.last_real_prompt_tokens <= 0:
return False
if self.last_real_prompt_tokens >= self.threshold_tokens:
return False
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
if baseline <= 0:
return False
growth = max(0, rough_tokens - baseline)
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
if growth > tolerated_growth:
return False
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
return True
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Check if context exceeds the compression threshold.
@@ -1190,11 +1266,27 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
task assignment verbatim the exact words they used. If multiple tasks
were requested and only some are done, list only the ones NOT yet completed.
Continuation should pick up exactly here. Example:
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
- Decisions awaiting input ("optie A of B?")
- Ongoing discussions where the assistant owes the next substantive reply
A conversation where the user just asked a question IS an active task the
task is "answer that question with full context". Do NOT write "None" merely
because the user did not issue an imperative command; reserve "None" for the
rare case where the last exchange was fully resolved and the user said
something like "thanks, that's all".
If multiple items are outstanding, list only the ones NOT yet completed.
Continuation should pick up exactly here. Examples:
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
"User chose option A; awaiting implementation of step 2"
If the user's most recent message was a reverse signal (stop, undo, roll
back, never mind, just verify, change of topic) that supersedes earlier
work, write the reverse signal verbatim and DO NOT carry forward the
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
verify the current diff' — earlier i18n in-flight work is cancelled."
If no outstanding task exists, write "None."]
## Goal
@@ -1260,7 +1352,7 @@ PREVIOUS SUMMARY:
NEW TURNS TO INCORPORATE:
{content_to_summarize}
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved.
{_template_sections}"""
else:
@@ -1424,9 +1516,16 @@ The user has requested that this compaction PRIORITISE preserving all informatio
@staticmethod
def _strip_summary_prefix(summary: str) -> str:
"""Return summary body without the current or legacy handoff prefix."""
"""Return summary body without the current, legacy, or any historical
handoff prefix.
Historical prefixes must be stripped too: a handoff persisted under an
older prefix can be inherited into a resumed lineage (#35344), and if we
only re-prepend the current prefix without removing the old one, the
stale directive it carried stays embedded in the body.
"""
text = (summary or "").strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
return text[len(prefix):].lstrip()
return text
@@ -1440,7 +1539,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
text = _content_text_for_contains(content).lstrip()
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def _find_latest_context_summary(
+9
View File
@@ -115,6 +115,15 @@ class ContextEngine(ABC):
"""
return False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""Return True when preflight should trust recent real usage instead.
Built-in compression uses this to avoid re-compacting from known-noisy
rough estimates after a compressed request has already fit. Third-party
engines can ignore it safely.
"""
return False
# -- Optional: manual /compress preflight ------------------------------
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
+30 -8
View File
@@ -575,19 +575,18 @@ def compress_context(
force=True,
)
# Update token estimate after compaction so pressure calculations
# use the post-compression count, not the stale pre-compression one.
# Use estimate_request_tokens_rough() so tool schemas are included —
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
# omitting them delays the next compression cycle far past the
# configured threshold (issue #14695).
# 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.
_compressed_est = estimate_request_tokens_rough(
compressed,
system_prompt=new_system_prompt or "",
tools=agent.tools or None,
)
agent.context_compressor.last_prompt_tokens = _compressed_est
agent.context_compressor.last_compression_rough_tokens = _compressed_est
agent.context_compressor.last_prompt_tokens = -1
agent.context_compressor.last_completion_tokens = 0
agent.context_compressor.awaiting_real_usage_after_compression = True
# Clear the file-read dedup cache. After compression the original
# read content is summarised away — if the model re-reads the same
@@ -599,7 +598,7 @@ def compress_context(
pass
logger.info(
"context compression done: session=%s messages=%d->%d tokens=~%s",
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
agent.session_id or "none", _pre_msg_count, len(compressed),
f"{_compressed_est:,}",
)
@@ -645,6 +644,12 @@ 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
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
# be re-sent and rejected again, wasting the single retry budget. We only
# report success (caller retries) when every over-threshold image was
# actually brought under the target.
unshrinkable_oversized = 0
def _shrink_data_url(url: str) -> Optional[str]:
"""Return a smaller data URL, or None if shrink can't help."""
@@ -711,17 +716,34 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
if resized:
image_value["url"] = resized
changed_count += 1
elif isinstance(url, str) and url.startswith("data:") \
and len(url) > target_bytes:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
resized = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
changed_count += 1
elif image_value.startswith("data:") \
and len(image_value) > target_bytes:
unshrinkable_oversized += 1
if changed_count:
logger.info(
"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB",
changed_count, target_bytes / (1024 * 1024),
)
if unshrinkable_oversized:
# At least one oversized image could not be shrunk under the target.
# Retrying would re-send it and fail identically, so signal "no
# progress" even if other parts shrank — the caller will surface the
# original error rather than burning its single retry on a no-op.
logger.warning(
"image-shrink recovery: %d oversized image part(s) could not be "
"shrunk under %.0f MB — not retrying (would re-send rejected payload)",
unshrinkable_oversized, target_bytes / (1024 * 1024),
)
return False
return changed_count > 0
+115 -17
View File
@@ -27,6 +27,8 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from agent.plugin_registries import registries as _registries
from agent.auxiliary_client import set_runtime_main
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
@@ -392,6 +394,9 @@ def run_conversation(
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
@@ -600,18 +605,50 @@ def run_conversation(
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if agent.context_compressor.should_compress(_preflight_tokens):
if not _preflight_deferred:
# Keep the CLI/ACP context display in sync with what preflight
# actually measured. The status bar reads
# ``compressor.last_prompt_tokens``, which otherwise only updates
# from a *successful* API response. When the conversation has grown
# since the last successful call — or when compression then fails
# (e.g. the auxiliary summary model times out) and no fresh usage
# arrives — the bar stays stuck at the old, smaller value while
# preflight reports a much larger number, looking out of sync.
# Seed it with the fresh estimate (only ever revising upward; a real
# ``update_from_response`` will correct it after the next API call).
# Skipped when deferring — a deferred estimate is known to over-count
# vs the last real provider prompt, so trusting it for the display
# would re-introduce the very desync we're avoiding.
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{agent.context_compressor.threshold_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{agent.context_compressor.context_length:,}",
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# May need multiple passes for very large sessions with small
@@ -646,8 +683,8 @@ def run_conversation(
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if _preflight_tokens < agent.context_compressor.threshold_tokens:
break # Under threshold
if not _compressor.should_compress(_preflight_tokens):
break # Under threshold or anti-thrash guard stopped it
# Plugin hook: pre_llm_call
# Fired once per turn before the tool-calling loop. Plugins can
@@ -1457,7 +1494,8 @@ def run_conversation(
if retry_count >= max_retries:
# Try fallback before giving up
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -2370,8 +2408,8 @@ def run_conversation(
and not anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
from agent.azure_identity_adapter import is_token_provider
_is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token")
is_token_provider = _registries.get_provider_service("azure", "is_token_provider")
if agent._try_refresh_anthropic_client_credentials():
print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
continue
@@ -2388,7 +2426,7 @@ def run_conversation(
print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or")
print(f"{agent.log_prefix} `az login` if your developer session expired.")
else:
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
auth_method = "Bearer (OAuth/setup-token)" if (_is_oauth_token is not None and _is_oauth_token(key)) else "x-api-key (API key)"
print(f"{agent.log_prefix} Auth method: {auth_method}")
print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)")
print(f"{agent.log_prefix} Troubleshooting:")
@@ -3059,12 +3097,17 @@ def run_conversation(
) and not is_context_length_error
if is_client_error:
# Try fallback before aborting — a different provider
# may not have the same issue (rate limit, auth, etc.)
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
# Try fallback before aborting — a different provider may
# not have the same issue (rate limit, auth, etc.). Only
# announce the attempt when a fallback chain actually
# exists; otherwise "trying fallback..." is a lie and the
# session looks like it's recovering when it's about to
# abort silently (#35314, #17446).
if agent._has_pending_fallback():
if classified.reason == FailoverReason.content_policy_blocked:
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
else:
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -3207,7 +3250,8 @@ def run_conversation(
retry_count = 0
continue
# Try fallback before giving up entirely
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._has_pending_fallback():
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
@@ -3862,6 +3906,11 @@ def run_conversation(
# inflate completion_tokens with reasoning,
# causing premature compression. (#12026)
_real_tokens = _compressor.last_prompt_tokens
elif _compressor.last_prompt_tokens == -1:
# Compression just ran and no API-reported prompt count
# has arrived yet. Avoid treating a schema-heavy rough
# post-compression estimate as real context pressure.
_real_tokens = 0
else:
# Include tool schemas — with 50+ tools enabled
# these add 20-30K tokens the messages-only
@@ -4443,6 +4492,55 @@ def run_conversation(
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
# Turn-completion explainer.
# When a turn ends abnormally after substantive work — empty content
# after retries, a partial/truncated stream, a still-pending tool
# result, or an iteration/budget limit — the user otherwise gets a
# blank or fragmentary response box with no consolidated reason why
# the agent stopped (#34452). Surface a single user-visible
# explanation derived from ``_turn_exit_reason``, mirroring the
# file-mutation verifier footer pattern above.
#
# Gate carefully so healthy turns stay quiet:
# - ``text_response(...)`` exits never produce an explanation
# (handled inside the formatter), so a terse ``Done.`` is silent.
# - We only ACT when there is no genuinely usable reply this turn:
# an empty response, the "(empty)" terminal sentinel, or a
# suspiciously short partial fragment with no terminating
# punctuation (e.g. "The"). A real short answer keeps its text.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
# A short fragment that is not a normal text_response exit
# and lacks sentence-ending punctuation is treated as a
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
)
if _is_empty_terminal or _is_partial_fragment:
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment, append the reason so
# the user sees both what arrived and why it
# stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
# Plugin hook: transform_llm_output
+49 -196
View File
@@ -537,43 +537,6 @@ class CredentialPool:
self._persist()
return updated
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
OAuth refresh tokens are single-use. When something external (e.g.
Claude Code CLI, or another profile's pool) refreshes the token, it
writes the new pair to ~/.claude/.credentials.json. The pool entry's
refresh token becomes stale. This method detects that and syncs.
"""
if self.provider != "anthropic" or entry.source != "claude_code":
return entry
try:
from agent.anthropic_adapter import read_claude_code_credentials
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
# If the credentials file has a different token pair, sync it
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def _sync_codex_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
"""Sync a Codex device_code pool entry from auth.json if tokens differ.
@@ -863,32 +826,11 @@ class CredentialPool:
return None
try:
if self.provider == "anthropic":
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
refreshed = refresh_anthropic_oauth_pure(
entry.refresh_token,
use_json=entry.source.endswith("hermes_pkce"),
)
updated = replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
# Keep ~/.claude/.credentials.json in sync so that the
# fallback path (resolve_anthropic_token) and other profiles
# see the latest tokens.
if entry.source == "claude_code":
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cph_reg2
_hook = _cph_reg2.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.refresh_oauth is not None:
updated = _hook.refresh_oauth(entry, pool=self)
elif self.provider == "openai-codex":
# Adopt fresher tokens from auth.json before spending the
# refresh_token — single-use tokens consumed by another Hermes
@@ -938,46 +880,18 @@ class CredentialPool:
return entry
except Exception as exc:
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
# For anthropic claude_code entries: the refresh token may have been
# consumed by another process. Check if ~/.claude/.credentials.json
# has a newer token pair and retry once.
if self.provider == "anthropic" and entry.source == "claude_code":
synced = self._sync_anthropic_entry_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(synced, updated)
self._persist()
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file (retry path): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
# Credentials file had a valid (non-expired) token — use it directly
logger.debug("Credentials file has valid token, using without refresh")
return synced
# ── Plugin-registered credential pool hooks ──
# The hook's refresh_oauth already handles retry-with-sync internally,
# so if we got here it means a non-hook provider failed.
from agent.plugin_registries import registries as _cph_reg3
_hook = _cph_reg3.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.sync_from_credentials_file is not None:
# Give the hook a chance to sync from external file
synced = _hook.sync_from_credentials_file(entry)
if synced is not entry:
entry = synced
self._replace_entry(entry, synced)
self._persist()
# For xai-oauth: same race as nous — another process may have
# consumed the refresh token between our proactive sync and the
# HTTP call. Re-check auth.json and adopt the fresh tokens if
@@ -1198,10 +1112,11 @@ class CredentialPool:
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
if self.provider == "anthropic":
if entry.expires_at_ms is None:
return False
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cph_reg
_hook = _cph_reg.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.needs_refresh is not None:
return _hook.needs_refresh(entry)
if self.provider == "openai-codex":
return _codex_access_token_is_expiring(
entry.access_token,
@@ -1235,12 +1150,16 @@ class CredentialPool:
entries_to_prune: List[str] = []
available: List[PooledCredential] = []
for entry in self._entries:
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
if (self.provider == "anthropic" and entry.source == "claude_code"
# ── Plugin-registered credential pool hooks ──
# Sync exhausted entries from external credentials files before
# status/refresh checks. This picks up tokens refreshed by other
# processes (e.g. Claude Code CLI, other Hermes profiles).
from agent.plugin_registries import registries as _cph_reg4
_avail_hook = _cph_reg4.get_credential_pool_hook(self.provider)
if (_avail_hook is not None
and _avail_hook.sync_from_credentials_file is not None
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
synced = self._sync_anthropic_entry_from_credentials_file(entry)
synced = _avail_hook.sync_from_credentials_file(entry)
if synced is not entry:
entry = synced
cleared_any = True
@@ -1634,84 +1553,15 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
def _is_suppressed(_p, _s): # type: ignore[misc]
return False
if provider == "anthropic":
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
# when the user has explicitly configured anthropic as their provider.
# Without this gate, auxiliary client fallback chains silently read
# ~/.claude/.credentials.json without user consent. See PR #4210.
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return changed, active_sources
except ImportError:
pass
# API-key vs OAuth is a user-visible choice at `hermes setup` ("Claude
# Pro/Max subscription" vs "Anthropic API key"). The signal that the
# user picked the API-key path is: ANTHROPIC_API_KEY set in the env,
# AND no OAuth env vars set — `save_anthropic_api_key()` writes the
# API key and zeros ANTHROPIC_TOKEN; `save_anthropic_oauth_token()`
# does the inverse. When that signal is present we MUST NOT seed
# autodiscovered OAuth tokens (~/.claude/.credentials.json from the
# Claude Code CLI, hermes_pkce creds from a previous OAuth login)
# into the anthropic pool — otherwise rotation on a 401/429 silently
# flips the session onto an OAuth credential, which forces the Claude
# Code identity injection, `mcp_` tool-name rewrite, and claude-cli
# User-Agent header (`agent/anthropic_adapter.py:2128`). Users who
# explicitly opted into the API-key path are explicitly opting OUT of
# that masquerade. Prefer ~/.hermes/.env over os.environ for the
# same reason `_seed_from_env` does — that's the authoritative file
# that `hermes setup` writes.
_env_file = load_env()
def _env_val(key: str) -> str:
return (_env_file.get(key) or os.environ.get(key) or "").strip()
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
anthropic_oauth_env = (
_env_val("ANTHROPIC_TOKEN") or _env_val("CLAUDE_CODE_OAUTH_TOKEN")
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cp_reg
_cp_hook = _cp_reg.get_credential_pool_hook(provider)
if _cp_hook is not None and _cp_hook.discover_credentials is not None:
hook_changed, hook_sources = _cp_hook.discover_credentials(
entries, provider, _is_suppressed,
)
api_key_path_explicit = bool(anthropic_api_key and not anthropic_oauth_env)
if api_key_path_explicit:
# Prune any stale autodiscovered OAuth entries that may have been
# seeded into the on-disk pool during a previous OAuth session.
# Without this, switching OAuth -> API key at setup leaves the
# OAuth entries dormant in auth.json forever and rotation on a
# transient 401 could revive them.
retained = [
entry for entry in entries
if entry.source not in {"hermes_pkce", "claude_code"}
]
if len(retained) != len(entries):
entries[:] = retained
changed = True
return changed, active_sources
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
for source_name, creds in (
("hermes_pkce", read_hermes_oauth_credentials()),
("claude_code", read_claude_code_credentials()),
):
if creds and creds.get("accessToken"):
if _is_suppressed(provider, source_name):
continue
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at_ms": creds.get("expiresAt"),
"label": label_from_token(creds.get("accessToken", ""), source_name),
},
)
changed |= hook_changed
active_sources |= hook_sources
elif provider == "nous":
state = _load_provider_state(auth_store, "nous")
has_runtime_material = bool(
@@ -2022,12 +1872,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
env_vars = list(pconfig.api_key_env_vars)
if provider == "anthropic":
env_vars = [
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
]
# ── Plugin-registered credential pool hooks: env var order override ──
from agent.plugin_registries import registries as _env_reg
_env_hook = _env_reg.get_credential_pool_hook(provider)
if _env_hook is not None and _env_hook.env_var_order is not None:
env_vars = _env_hook.env_var_order
for env_var in env_vars:
# Prefer ~/.hermes/.env over os.environ
@@ -2038,7 +1887,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
# ── Plugin-registered credential pool hooks: auth type detection ──
if _env_hook is not None and _env_hook.detect_auth_type is not None:
auth_type = _env_hook.detect_auth_type(token)
else:
auth_type = AUTH_TYPE_API_KEY
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
+6 -13
View File
@@ -247,18 +247,13 @@ def _cmd_restart() -> int:
def _cmd_which(server_id: str) -> int:
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
import shutil as _shutil
from agent.lsp.install import INSTALL_RECIPES, _existing_binary
recipe = INSTALL_RECIPES.get(server_id)
bin_name = (recipe or {}).get("bin", server_id)
staged = hermes_lsp_bin_dir() / bin_name
if staged.exists():
sys.stdout.write(str(staged) + "\n")
return 0
on_path = _shutil.which(bin_name)
if on_path:
sys.stdout.write(on_path + "\n")
resolved = _existing_binary(bin_name)
if resolved:
sys.stdout.write(resolved + "\n")
return 0
sys.stderr.write(f"{server_id}: not installed\n")
return 1
@@ -292,11 +287,9 @@ def _backend_warnings() -> list:
suggestion across common platforms.
"""
import shutil as _shutil
from agent.lsp.install import hermes_lsp_bin_dir
from agent.lsp.install import _existing_binary
notes: list = []
bash_installed = _shutil.which("bash-language-server") is not None or (
(hermes_lsp_bin_dir() / "bash-language-server").exists()
)
bash_installed = _existing_binary("bash-language-server") is not None
if bash_installed and _shutil.which("shellcheck") is None:
notes.append(
"bash-language-server is installed but shellcheck is missing — "
+16 -3
View File
@@ -44,6 +44,7 @@ from __future__ import annotations
import asyncio
import logging
import os
import sys
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@@ -244,15 +245,27 @@ class LSPClient:
await self._cleanup_process()
raise
@staticmethod
def _win_wrap_cmd(cmd: List[str]) -> List[str]:
"""On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
exe = cmd[0]
if exe.lower().endswith((".cmd", ".bat")):
return ["cmd.exe", "/c", *cmd]
return cmd
async def _spawn(self) -> None:
env = dict(os.environ)
if self._env:
env.update(self._env)
cmd = self._command
if sys.platform == "win32":
cmd = self._win_wrap_cmd(cmd)
try:
self._proc = await asyncio.create_subprocess_exec(
self._command[0],
*self._command[1:],
cmd[0],
*cmd[1:],
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -261,7 +274,7 @@ class LSPClient:
)
except FileNotFoundError as e:
raise LSPProtocolError(
f"LSP server binary not found: {self._command[0]} ({e})"
f"LSP server binary not found: {cmd[0]} ({e})"
) from e
# Drain stderr at debug level — if we don't, the pipe buffer
+47 -23
View File
@@ -108,6 +108,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
_install_locks: Dict[str, threading.Lock] = {}
_install_results: Dict[str, Optional[str]] = {}
_install_lock_meta = threading.Lock()
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
def _is_windows() -> bool:
return os.name == "nt"
def hermes_lsp_bin_dir() -> Path:
@@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path:
return p
def _native_binary_candidates(base: Path) -> list[Path]:
"""Return platform-native executable candidates for a staged binary."""
candidates = [base]
if _is_windows():
existing = {str(base).lower()}
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
candidate = Path(str(base) + suffix)
key = str(candidate).lower()
if key not in existing:
candidates.append(candidate)
existing.add(key)
return candidates
def _existing_binary(name: str) -> Optional[str]:
"""Probe the staging dir + PATH for a binary named ``name``."""
staged = hermes_lsp_bin_dir() / name
if staged.exists() and os.access(staged, os.X_OK):
return str(staged)
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
if staged.exists() and os.access(staged, os.X_OK):
return str(staged)
on_path = shutil.which(name)
if on_path:
return on_path
if _is_windows():
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
on_path = shutil.which(f"{name}{suffix}")
if on_path:
return on_path
return None
@@ -250,12 +274,7 @@ def _install_npm(
# Find the bin
nm_bin = staging / "node_modules" / ".bin" / bin_name
if os.name == "nt":
# On Windows npm sometimes drops `.cmd` shims
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
else:
candidates = [nm_bin]
for c in candidates:
for c in _native_binary_candidates(nm_bin):
if c.exists():
# Symlink into our `lsp/bin/` for stable PATH access.
link = hermes_lsp_bin_dir() / c.name
@@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
logger.warning("[install] go install errored for %s: %s", pkg, e)
return None
bin_path = staging / bin_name
if os.name == "nt":
if _is_windows():
bin_path = bin_path.with_suffix(".exe")
if bin_path.exists():
return str(bin_path)
@@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("[install] pip install errored for %s: %s", pkg, e)
return None
# Look for the script
bin_path = pip_target / "bin" / bin_name
if bin_path.exists():
link = hermes_lsp_bin_dir() / bin_name
if not link.exists():
try:
link.symlink_to(bin_path)
except (OSError, NotImplementedError):
try:
shutil.copy2(bin_path, link)
except OSError:
return str(bin_path)
return str(link if link.exists() else bin_path)
# Look for the console script. POSIX wheels generally write to bin/,
# while native Windows installs use Scripts/.
script_dirs = [pip_target / "bin"]
if _is_windows():
script_dirs.append(pip_target / "Scripts")
for script_dir in script_dirs:
for bin_path in _native_binary_candidates(script_dir / bin_name):
if bin_path.exists():
link = hermes_lsp_bin_dir() / bin_path.name
if not link.exists():
try:
link.symlink_to(bin_path)
except (OSError, NotImplementedError):
try:
shutil.copy2(bin_path, link)
except OSError:
return str(bin_path)
return str(link if link.exists() else bin_path)
return None
+5 -2
View File
@@ -1567,8 +1567,11 @@ def get_model_context_length(
and base_url_host_matches(base_url, "amazonaws.com")
):
try:
from agent.bedrock_adapter import get_bedrock_context_length
return get_bedrock_context_length(model)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
get_bedrock_context_length = _bedrock.get("get_bedrock_context_length")
if get_bedrock_context_length is not None:
return get_bedrock_context_length(model)
except ImportError:
pass # boto3 not installed — fall through to generic resolution
+586
View File
@@ -0,0 +1,586 @@
"""Plugin capability registries.
Each plugin's ``register(ctx)`` function populates these registries via
``ctx.register_<capability>()``. The core codebase then queries the
registries instead of importing from plugin packages directly.
This is the **only** coupling point between the core and plugins: the core
imports from ``agent.plugin_registries``, never from ``hermes_agent_*``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Sequence,
Tuple,
Type,
runtime_checkable,
)
# ---------------------------------------------------------------------------
# Auth providers
# ---------------------------------------------------------------------------
@runtime_checkable
class AuthProvider(Protocol):
"""A plugin that can provide or check authentication credentials.
Registered via ``ctx.register_auth_provider(name, provider)``.
Queried by ``hermes_cli/auth_commands.py``, ``doctor.py``, etc.
"""
@property
def name(self) -> str: ...
def has_credentials(self) -> bool:
"""Return True if the required credentials are present in env/config."""
...
def check_env_vars(self) -> Dict[str, str | None]:
"""Return a dict of env-var-name → current-value (or None if unset).
Used by ``hermes doctor`` to display credential status.
"""
...
def resolve_token(self, **kwargs: Any) -> Any:
"""Resolve and return an auth token/credential for the provider.
The return type is provider-specific (string, tuple, object, etc.).
"""
...
def refresh_token(self, **kwargs: Any) -> Any:
"""Refresh an existing token. Raises if refresh is not supported."""
...
@dataclass
class AuthProviderEntry:
provider: AuthProvider
"""The auth provider instance."""
cli_group: str = ""
"""CLI argument group name (e.g. 'Anthropic', 'AWS / Bedrock')."""
setup_subcommands: bool = False
"""Whether this provider adds CLI auth subcommands (login, logout, etc.)."""
# ---------------------------------------------------------------------------
# Transport builders
# ---------------------------------------------------------------------------
@runtime_checkable
class TransportBuilder(Protocol):
"""A plugin that builds clients and converts messages for a model transport.
Registered via ``ctx.register_transport(name, builder)``.
Queried by ``agent/transports/`` and ``agent/auxiliary_client.py``.
"""
def build_client(self, **kwargs: Any) -> Any:
"""Build and return a provider-specific API client."""
...
def build_kwargs(self, **kwargs: Any) -> Dict[str, Any]:
"""Build the kwargs dict for a provider-specific API call."""
...
def convert_messages(self, messages: Sequence[Any], **kwargs: Any) -> Any:
"""Convert internal message format to provider-specific format."""
...
def convert_tools(self, tools: Sequence[Any], **kwargs: Any) -> Any:
"""Convert internal tool format to provider-specific format."""
...
def normalize_response(self, response: Any, **kwargs: Any) -> Any:
"""Normalize a provider-specific response into the internal format."""
...
# ---------------------------------------------------------------------------
# Platform adapters
# ---------------------------------------------------------------------------
@dataclass
class PlatformAdapterEntry:
"""A registered platform adapter.
Registered via ``ctx.register_platform(name, entry)``.
Queried by ``gateway/run.py`` and ``tools/send_message_tool.py``.
"""
name: str
"""Platform identifier (e.g. 'telegram', 'slack')."""
adapter_class: Type
"""The adapter class (e.g. TelegramAdapter)."""
check_requirements: Callable[[], bool]
"""Check if the platform's dependencies are installed and configured."""
available_flag: str = ""
"""Name of the module-level AVAILABLE boolean, if any."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Platform-specific constants (e.g. FEISHU_DOMAIN, LARK_DOMAIN)."""
helper_functions: Dict[str, Callable] = field(default_factory=dict)
"""Platform-specific helper functions (e.g. probe_bot, qr_register)."""
# ---------------------------------------------------------------------------
# Tool providers
# ---------------------------------------------------------------------------
@dataclass
class ToolProviderEntry:
"""A registered tool provider.
Registered via ``ctx.register_tool_provider(name, entry)``.
Queried by ``tools/`` modules.
"""
name: str
"""Tool identifier (e.g. 'tts', 'stt', 'fal', 'daytona')."""
tool_functions: Dict[str, Callable] = field(default_factory=dict)
"""Tool functions keyed by name (e.g. 'text_to_speech_tool', 'transcribe_audio')."""
check_fn: Optional[Callable] = None
"""Check if the tool's dependencies are available."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Tool-specific constants (e.g. MAX_FILE_SIZE)."""
config_functions: Dict[str, Callable] = field(default_factory=dict)
"""Config/utility functions (e.g. _get_provider, _load_stt_config)."""
environment_classes: Dict[str, Type] = field(default_factory=dict)
"""Environment classes for terminal backends (e.g. DaytonaEnvironment)."""
# ---------------------------------------------------------------------------
# Model metadata providers
# ---------------------------------------------------------------------------
@dataclass
class ModelMetadataEntry:
"""A registered model metadata provider.
Registered via ``ctx.register_model_metadata(name, entry)``.
Queried by ``agent/model_metadata.py`` and CLI model commands.
"""
name: str
"""Provider identifier (e.g. 'anthropic', 'bedrock')."""
get_context_length: Optional[Callable[[str], int | None]] = None
"""Return the context length for a model name, or None if unknown."""
list_models: Optional[Callable[[], List[str]]] = None
"""Return a list of known model IDs for this provider."""
constants: Dict[str, Any] = field(default_factory=dict)
"""Provider-specific constants (e.g. _COMMON_BETAS, betas lists)."""
# ---------------------------------------------------------------------------
# Credential pool entries
# ---------------------------------------------------------------------------
@dataclass
class CredentialPoolEntry:
"""A registered credential pool provider.
Registered via ``ctx.register_credential_pool(name, entry)``.
Queried by ``agent/credential_pool.py``.
"""
name: str
"""Provider identifier (e.g. 'anthropic')."""
read_credentials: Optional[Callable] = None
"""Read stored credentials."""
write_credentials: Optional[Callable] = None
"""Write/store credentials."""
refresh_credentials: Optional[Callable] = None
"""Refresh stored credentials."""
read_oauth: Optional[Callable] = None
"""Read OAuth credentials."""
# ---------------------------------------------------------------------------
# Provider resolvers
# ---------------------------------------------------------------------------
@runtime_checkable
class ProviderResolver(Protocol):
"""A plugin that resolves an auxiliary client for a specific provider.
Registered via ``ctx.register_provider_resolver(provider_name, resolver)``.
Queried by ``agent/auxiliary_client.py`` in ``resolve_provider_client()``.
"""
def __call__(
self,
*,
model: str | None = None,
explicit_api_key: str | None = None,
explicit_base_url: str | None = None,
async_mode: bool = False,
is_vision: bool = False,
main_runtime: dict | None = None,
api_mode: str | None = None,
) -> tuple[Any, str] | tuple[None, None]:
"""Return ``(client, default_model)`` or ``(None, None)`` if unavailable."""
...
# ---------------------------------------------------------------------------
# Credential pool hooks
# ---------------------------------------------------------------------------
@dataclass
class CredentialPoolHook:
"""Provider-specific credential pool operations.
Registered via ``ctx.register_credential_pool_hook(provider_name, hook)``.
Queried by ``agent/credential_pool.py``.
"""
sync_from_credentials_file: Optional[Callable] = None
"""Sync a pool entry from an external credentials file (e.g. ~/.claude/.credentials.json)."""
refresh_oauth: Optional[Callable] = None
"""Refresh an OAuth token for a pool entry."""
should_include_in_pool: Optional[Callable] = None
"""Return True if this provider's credentials should be included in the pool."""
needs_refresh: Optional[Callable] = None
"""Return True if an OAuth entry needs a token refresh."""
source_priority: Optional[Callable] = None
"""Return integer priority for a credential source (lower = preferred)."""
discover_credentials: Optional[Callable] = None
"""Discover external credentials and upsert into the pool entries.
Signature: (entries: list, provider: str, is_suppressed: Callable) -> (changed: bool, active_sources: set)
"""
env_var_order: Optional[list] = None
"""Override env var scan order for this provider (e.g. ['ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'])."""
detect_auth_type: Optional[Callable] = None
"""Given a token string, return the auth type for this provider.
Signature: (token: str) -> str (e.g. AUTH_TYPE_OAUTH or AUTH_TYPE_API_KEY)
"""
# ---------------------------------------------------------------------------
# Pricing providers
# ---------------------------------------------------------------------------
# Re-export PricingEntry from usage_pricing — that's the canonical definition
# with Decimal fields. The registry stores these directly keyed by (provider, model).
# Lazy import to avoid circular dependency (usage_pricing imports registries at runtime).
def _get_pricing_entry_class():
from agent.usage_pricing import PricingEntry
return PricingEntry
# ---------------------------------------------------------------------------
# Provider overlays
# ---------------------------------------------------------------------------
@dataclass
class ProviderOverlayEntry:
"""A provider overlay registered by a plugin.
Registered via ``ctx.register_provider_overlay(provider_name, entry)``.
Queried by ``hermes_cli/providers.py``.
This mirrors the fields of ``HermesOverlay`` so that providers.py
can merge plugin-registered overlays seamlessly.
"""
provider_name: str
"""Primary provider name (e.g. 'anthropic', 'bedrock')."""
transport: str = "openai_chat"
"""Transport type: openai_chat | anthropic_messages | codex_responses | bedrock_converse"""
is_aggregator: bool = False
"""Whether this provider aggregates multiple model providers."""
auth_type: str = "api_key"
"""Auth type: api_key | oauth_device_code | oauth_external | aws_sdk | external_process"""
extra_env_vars: Tuple[str, ...] = ()
"""Environment variable names that indicate this provider is configured."""
base_url_override: str = ""
"""Override if models.dev URL is wrong/missing."""
base_url_env_var: str = ""
"""Env var for user-custom base URL."""
display_name: str = ""
"""Human-readable name for the provider (e.g. 'Anthropic', 'AWS Bedrock')."""
aliases: List[str] = field(default_factory=list)
"""Alternative names that resolve to this provider."""
# ---------------------------------------------------------------------------
# The global registries (singleton)
# ---------------------------------------------------------------------------
class PluginRegistries:
"""Central store for all plugin-registered capabilities.
A single instance is created at import time and shared across the
process. Plugins populate it during ``register()``; the core
queries it at runtime.
"""
def __init__(self) -> None:
self.auth_providers: Dict[str, AuthProviderEntry] = {}
self.transport_builders: Dict[str, TransportBuilder] = {}
self._transports: Dict[str, type] = {}
self.platform_adapters: Dict[str, PlatformAdapterEntry] = {}
self.tool_providers: Dict[str, ToolProviderEntry] = {}
self.model_metadata: Dict[str, ModelMetadataEntry] = {}
self.credential_pools: Dict[str, CredentialPoolEntry] = {}
self._provider_services: Dict[str, Dict[str, Any]] = {}
self._provider_resolvers: Dict[str, Callable] = {}
self._credential_pool_hooks: Dict[str, CredentialPoolHook] = {}
self._pricing_providers: Dict[tuple, Any] = {}
self._provider_overlays: Dict[str, ProviderOverlayEntry] = {}
# -- registration methods (called from PluginContext) --------------------
def register_auth_provider(
self,
name: str,
provider: AuthProvider,
*,
cli_group: str = "",
setup_subcommands: bool = False,
) -> None:
self.auth_providers[name] = AuthProviderEntry(
provider=provider,
cli_group=cli_group,
setup_subcommands=setup_subcommands,
)
def register_transport(self, name: str, builder: TransportBuilder) -> None:
self.transport_builders[name] = builder
def register_platform(self, entry: PlatformAdapterEntry) -> None:
self.platform_adapters[entry.name] = entry
def register_tool_provider(self, entry: ToolProviderEntry) -> None:
self.tool_providers[entry.name] = entry
def register_model_metadata(self, entry: ModelMetadataEntry) -> None:
self.model_metadata[entry.name] = entry
def register_credential_pool(self, entry: CredentialPoolEntry) -> None:
self.credential_pools[entry.name] = entry
def register_provider_resolver(self, name: str, resolver: Callable) -> None:
"""Register a provider resolver callable.
The resolver is called by ``resolve_provider_client()`` to create an
auxiliary client for a specific provider. Signature::
def resolver(
*,
model: str | None,
explicit_api_key: str | None,
explicit_base_url: str | None,
async_mode: bool,
is_vision: bool,
main_runtime: dict | None,
api_mode: str | None,
) -> tuple[Any, str] | tuple[None, None]:
...
Returns ``(client, default_model)`` or ``(None, None)``.
"""
self._provider_resolvers[name] = resolver
def register_credential_pool_hook(self, name: str, hook: CredentialPoolHook) -> None:
"""Register a credential pool hook for provider-specific pool operations."""
self._credential_pool_hooks[name] = hook
def register_pricing_provider(self, name: str, entries: List[tuple]) -> None:
"""Register pricing entries for a provider.
Each entry is a (provider, model, PricingEntry) tuple so the
lookup key matches the (provider, model) pattern used by
_OFFICIAL_DOCS_PRICING.
"""
for prov, model, entry in entries:
self._pricing_providers[(prov, model)] = entry
def register_provider_overlay(self, entry: ProviderOverlayEntry) -> None:
"""Register a provider overlay entry from a plugin."""
self._provider_overlays[entry.provider_name] = entry
# -- query helpers -------------------------------------------------------
def get_auth_provider(self, name: str) -> AuthProviderEntry | None:
return self.auth_providers.get(name)
def get_transport(self, name: str) -> TransportBuilder | None:
return self.transport_builders.get(name)
def get_platform(self, name: str) -> PlatformAdapterEntry | None:
return self.platform_adapters.get(name)
def get_tool_provider(self, name: str) -> ToolProviderEntry | None:
return self.tool_providers.get(name)
def get_model_metadata(self, name: str) -> ModelMetadataEntry | None:
return self.model_metadata.get(name)
def get_credential_pool(self, name: str) -> CredentialPoolEntry | None:
return self.credential_pools.get(name)
def get_provider_resolver(self, name: str) -> Callable | None:
"""Return the registered resolver for a provider, or None."""
return self._provider_resolvers.get(name)
def get_credential_pool_hook(self, name: str) -> CredentialPoolHook | None:
"""Return the registered credential pool hook for a provider, or None."""
return self._credential_pool_hooks.get(name)
def get_pricing_entry(self, provider: str, model: str) -> Any:
"""Return a registered pricing entry for (provider, model), or None."""
return self._pricing_providers.get((provider, model))
def all_pricing_entries(self) -> Dict[tuple, Any]:
"""Return all registered pricing entries (keyed by (provider, model))."""
return dict(self._pricing_providers)
def get_provider_overlay(self, name: str) -> ProviderOverlayEntry | None:
"""Return a registered provider overlay, or None."""
return self._provider_overlays.get(name)
def all_provider_overlays(self) -> Dict[str, ProviderOverlayEntry]:
"""Return all registered provider overlays."""
return dict(self._provider_overlays)
def all_auth_providers(self) -> List[AuthProviderEntry]:
return list(self.auth_providers.values())
def all_platforms(self) -> List[PlatformAdapterEntry]:
return list(self.platform_adapters.values())
def all_tool_providers(self) -> List[ToolProviderEntry]:
return list(self.tool_providers.values())
# -- provider services (model-provider namespace) -----------------------
def register_provider_services(self, name: str, services: Dict[str, Any]) -> None:
"""Register a namespace dict of provider-specific services.
This is the escape hatch for model-provider plugins that expose many
symbols (anthropic has 50+). Each plugin registers its public surface
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
looks up specific symbols instead of importing from the plugin
package directly.
Each callable value is stored as a *lazy module-attribute reference*
so that ``unittest.mock.patch("pkg.mod.fn")`` works correctly in
tests the registry re-reads ``mod.fn`` on every lookup instead of
capturing the function object at register time.
Example::
registries.register_provider_services("anthropic", {
"build_anthropic_client": build_anthropic_client,
"resolve_anthropic_token": resolve_anthropic_token,
"_is_oauth_token": _is_oauth_token,
...
})
"""
import sys
def _make_lazy(fn: Any) -> Any:
"""Return a lazy wrapper that re-reads fn from its module each call.
This makes mock.patch() on the module attribute work transparently
the registry never caches the function object, just the reference path.
"""
if not callable(fn):
return fn
module = getattr(fn, "__module__", None)
qualname = getattr(fn, "__qualname__", None)
if not module or not qualname or "." in qualname:
# non-simple attribute (lambda, nested fn, class method) — store directly
return fn
class _LazyRef:
__slots__ = ("_mod", "_attr", "_fallback")
def __init__(self, mod: str, attr: str, fallback: Any) -> None:
self._mod = mod
self._attr = attr
self._fallback = fallback
def _resolve(self) -> Any:
mod = sys.modules.get(self._mod)
return getattr(mod, self._attr, self._fallback) if mod else self._fallback
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self._resolve()(*args, **kwargs)
def __getattr__(self, name: str) -> Any:
if name.startswith("_"):
raise AttributeError(name)
return getattr(self._resolve(), name)
def __repr__(self) -> str: # pragma: no cover
return f"<LazyRef {self._mod}.{self._attr}>"
# Allow isinstance checks and hasattr to pass through
def __bool__(self) -> bool:
return True
return _LazyRef(module, qualname, fn)
self._provider_services[name] = {k: _make_lazy(v) for k, v in services.items()}
def get_provider_service(self, provider: str, name: str) -> Any:
"""Look up a single symbol from a provider's service namespace.
Returns ``None`` if the provider is not registered or the symbol
doesn't exist.
"""
ns = self._provider_services.get(provider)
if ns is None:
return None
return ns.get(name)
def get_provider_namespace(self, provider: str) -> Dict[str, Any]:
"""Return the full service namespace dict for a provider (empty dict if unregistered)."""
return self._provider_services.get(provider, {})
# Module-level singleton — the one and only instance.
registries = PluginRegistries()
+1 -9
View File
@@ -150,10 +150,6 @@ _JWT_RE = re.compile(
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
)
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
# E.164 phone numbers: +<country><number>, 7-15 digits
# Negative lookahead prevents matching hex strings or identifiers
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
@@ -331,7 +327,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
"""Apply all redaction patterns to a block of text.
Safe to call on any string -- non-matching text passes through unchanged.
Disabled by default enable via security.redact_secrets: true in config.yaml.
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
@@ -419,10 +415,6 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
if "&" in text and "=" in text:
text = _redact_form_body(text)
# Discord user/role mentions (<@snowflake_id>)
if "<@" in text:
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
# E.164 phone numbers (Signal, WhatsApp)
if "+" in text:
def _redact_phone(m):
+65 -51
View File
@@ -180,28 +180,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
except Exception:
pass
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
except Exception:
pass
# Checkpoint before destructive terminal commands
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
try:
cmd = function_args.get("command", "")
if _is_destructive_command(cmd):
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
agent._checkpoint_mgr.ensure_checkpoint(
cwd, f"before terminal: {cmd[:60]}"
)
except Exception:
pass
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
# We must know whether the tool will execute before touching
# checkpoint state (dedup slot, real snapshots).
block_result = None
blocked_by_guardrail = False
if _ts_scope_block is not None:
@@ -224,6 +205,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
block_result = agent._guardrail_block_result(guardrail_decision)
blocked_by_guardrail = True
# ── Checkpoint preflight (only for tools that will execute) ──
if block_result is None:
# Checkpoint for file-mutating tools
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
try:
file_path = function_args.get("path", "")
if file_path:
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
except Exception:
pass
# Checkpoint before destructive terminal commands
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
try:
cmd = function_args.get("command", "")
if _is_destructive_command(cmd):
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
agent._checkpoint_mgr.ensure_checkpoint(
cwd, f"before terminal: {cmd[:60]}"
)
except Exception:
pass
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
# ── Logging / callbacks ──────────────────────────────────────────
@@ -301,33 +306,38 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
start = time.time()
try:
result = agent._invoke_tool(
function_name,
function_args,
effective_task_id,
tool_call.id,
messages=messages,
pre_tool_block_checked=True,
)
except Exception as tool_error:
result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
duration = time.time() - start
is_error, _ = _detect_tool_failure(function_name, result)
if is_error:
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
else:
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
results[index] = (function_name, function_args, result, duration, is_error, False)
# Tear down worker-tid tracking. Clear any interrupt bit we may
# have set so the next task scheduled onto this recycled tid
# starts with a clean slate.
with agent._tool_worker_threads_lock:
agent._tool_worker_threads.discard(_worker_tid)
try:
_ra()._set_interrupt(False, _worker_tid)
except Exception:
pass
try:
result = agent._invoke_tool(
function_name,
function_args,
effective_task_id,
tool_call.id,
messages=messages,
pre_tool_block_checked=True,
)
except Exception as tool_error:
result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
duration = time.time() - start
is_error, _ = _detect_tool_failure(function_name, result)
if is_error:
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
else:
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
results[index] = (function_name, function_args, result, duration, is_error, False)
finally:
# Tear down worker-tid tracking. Clear any interrupt bit we may
# have set so the next task scheduled onto this recycled tid
# starts with a clean slate. This MUST be in a finally block
# because BaseException subclasses (CancelledError, KeyboardInterrupt)
# bypass ``except Exception`` and would otherwise leak the tid
# into _interrupted_threads, poisoning the recycled thread.
with agent._tool_worker_threads_lock:
agent._tool_worker_threads.discard(_worker_tid)
try:
_ra()._set_interrupt(False, _worker_tid)
except Exception:
pass
# Start spinner for CLI mode (skip when TUI handles tool progress)
spinner = None
@@ -753,10 +763,14 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks · (/agents to monitor)"
else:
goal_preview = (function_args.get("goal") or "")[:30]
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
spinner_label = (
f"🔀 {goal_preview} · (/agents to monitor)"
if goal_preview
else "🔀 delegating · (/agents to monitor)"
)
spinner = None
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
+12 -2
View File
@@ -47,9 +47,16 @@ def get_transport(api_mode: str):
def _discover_transports() -> None:
"""Import all transport modules to trigger auto-registration."""
"""Import all transport modules to trigger auto-registration.
Also checks the plugin registry for transports registered by plugins
(e.g. anthropic_messages from the anthropic plugin, bedrock_converse
from the bedrock plugin). Plugin-registered transports take priority
over core fallbacks when both exist.
"""
global _discovered
_discovered = True
# Core transport modules (registered automatically — no plugin needed)
try:
import agent.transports.anthropic # noqa: F401
except ImportError:
@@ -62,7 +69,10 @@ def _discover_transports() -> None:
import agent.transports.chat_completions # noqa: F401
except ImportError:
pass
# Plugin-registered transports (override core fallbacks)
try:
import agent.transports.bedrock # noqa: F401
from agent.plugin_registries import registries
for api_mode, transport_cls in registries._transports.items():
_REGISTRY.setdefault(api_mode, transport_cls)
except ImportError:
pass
+31 -65
View File
@@ -1,41 +1,53 @@
"""Anthropic Messages API transport.
"""Anthropic Messages API transport — core module.
Delegates to the existing adapter functions in agent/anthropic_adapter.py.
This transport owns format conversion and normalization NOT client lifecycle.
Owns format conversion and response normalization for the ``anthropic_messages``
wire format. No SDK dependency; all wire-format logic lives in
:mod:`agent.anthropic_format`.
"""
import json
from typing import Any, Dict, List, Optional
from agent.anthropic_format import (
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
_to_plain_data,
)
from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse
from agent.transports.types import NormalizedResponse, ToolCall
class AnthropicTransport(ProviderTransport):
"""Transport for api_mode='anthropic_messages'.
Wraps the existing functions in anthropic_adapter.py behind the
ProviderTransport ABC. Each method delegates no logic is duplicated.
Uses core functions directly from :mod:`agent.anthropic_format` no
plugin registry lookups needed. This means core tests, bedrock tests,
and any other consumer of the anthropic wire format work without the
anthropic plugin being registered.
"""
_STOP_REASON_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"refusal": "content_filter",
"model_context_window_exceeded": "length",
}
@property
def api_mode(self) -> str:
return "anthropic_messages"
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
"""Convert OpenAI messages to Anthropic (system, messages) tuple.
kwargs:
base_url: Optional[str] affects thinking signature handling.
"""
from agent.anthropic_adapter import convert_messages_to_anthropic
"""Convert OpenAI messages to Anthropic (system, messages) tuple."""
base_url = kwargs.get("base_url")
return convert_messages_to_anthropic(messages, base_url=base_url)
return convert_messages_to_anthropic(messages, base_url=base_url,
model=kwargs.get("model"))
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
from agent.anthropic_adapter import convert_tools_to_anthropic
return convert_tools_to_anthropic(tools)
def build_kwargs(
@@ -45,23 +57,7 @@ class AnthropicTransport(ProviderTransport):
tools: Optional[List[Dict[str, Any]]] = None,
**params,
) -> Dict[str, Any]:
"""Build Anthropic messages.create() kwargs.
Calls convert_messages and convert_tools internally.
params (all optional):
max_tokens: int
reasoning_config: dict | None
tool_choice: str | None
is_oauth: bool
preserve_dots: bool
context_length: int | None
base_url: str | None
fast_mode: bool
drop_context_1m_beta: bool
"""
from agent.anthropic_adapter import build_anthropic_kwargs
"""Build Anthropic messages.create() kwargs."""
return build_anthropic_kwargs(
model=model,
messages=messages,
@@ -78,15 +74,7 @@ class AnthropicTransport(ProviderTransport):
)
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
"""Normalize Anthropic response to NormalizedResponse.
Parses content blocks (text, thinking, tool_use), maps stop_reason
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data
from agent.transports.types import ToolCall
"""Normalize Anthropic response to NormalizedResponse."""
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_"
@@ -107,12 +95,6 @@ class AnthropicTransport(ProviderTransport):
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
@@ -141,13 +123,7 @@ 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.
"""
"""Check Anthropic response structure is valid."""
if response is None:
return False
content_blocks = getattr(response, "content", None)
@@ -168,16 +144,6 @@ class AnthropicTransport(ProviderTransport):
return {"cached_tokens": cached, "creation_tokens": written}
return None
# Promote the adapter's canonical mapping to module level so it's shared
_STOP_REASON_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"refusal": "content_filter",
"model_context_window_exceeded": "length",
}
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Anthropic stop_reason to OpenAI finish_reason."""
return self._STOP_REASON_MAP.get(raw_reason, "stop")
+68 -152
View File
@@ -115,6 +115,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
# tokens for the same text).
# Source: https://platform.claude.com/docs/en/about-claude/pricing
# NOTE: The anthropic plugin also registers these — plugin takes priority
# at runtime, but these static entries ensure costs work without the plugin.
(
"anthropic",
"claude-opus-4-7",
@@ -139,7 +141,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-6",
@@ -188,7 +189,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-5",
@@ -225,7 +225,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
(
"anthropic",
"claude-opus-4-20250514",
@@ -250,7 +249,56 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# OpenAI
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
(
"anthropic",
"claude-3-5-sonnet-20241022",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-5-haiku-20241022",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-opus-20240229",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-haiku-20240307",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.25"),
cache_read_cost_per_million=Decimal("0.03"),
cache_write_cost_per_million=Decimal("0.30"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── OpenAI ────────────────────────────────────────────────────────────
(
"openai",
"gpt-4o",
@@ -328,55 +376,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://openai.com/api/pricing/",
pricing_version="openai-pricing-2026-03-16",
),
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
(
"anthropic",
"claude-3-5-sonnet-20241022",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-5-haiku-20241022",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-opus-20240229",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-haiku-20240307",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.25"),
cache_read_cost_per_million=Decimal("0.03"),
cache_write_cost_per_million=Decimal("0.30"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# DeepSeek
(
"deepseek",
@@ -440,80 +439,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
),
# AWS Bedrock — pricing per the Bedrock pricing page.
# Bedrock charges the same per-token rates as the model provider but
# through AWS billing. These are the on-demand prices (no commitment).
# Source: https://aws.amazon.com/bedrock/pricing/
(
"bedrock",
"anthropic.claude-opus-4-6",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-sonnet-4-6",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-sonnet-4-5",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-haiku-4-5",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-pro",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("3.20"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.06"),
output_cost_per_million=Decimal("0.24"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-micro",
): PricingEntry(
input_cost_per_million=Decimal("0.035"),
output_cost_per_million=Decimal("0.14"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
# MiniMax
(
"minimax",
@@ -581,36 +506,27 @@ def resolve_billing_route(
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
def _normalize_anthropic_model_name(model: str) -> str:
"""Normalize Anthropic model name variants to canonical form.
Handles:
- Dot notation: claude-opus-4.7 claude-opus-4-7
- Short aliases: claude-opus-4.7 claude-opus-4-7
- Strips anthropic/ prefix if present
"""
name = model.lower().strip()
if name.startswith("anthropic/"):
name = name[len("anthropic/"):]
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
# But preserve the rest of the name structure
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
return name
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
model = route.model.lower()
# Direct lookup first
# ── Plugin-registered pricing entries take priority ──
from agent.plugin_registries import registries as _preg
plugin_entry = _preg.get_pricing_entry(route.provider, model)
if plugin_entry:
return plugin_entry
# Try provider-specific name normalization via registry
_norm = _preg.get_provider_service(route.provider, "normalize_model_name")
if _norm is not None:
normalized = _norm(model)
if normalized != model:
plugin_entry = _preg.get_pricing_entry(route.provider, normalized)
if plugin_entry:
return plugin_entry
# Fall back to static dict
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
if entry:
return entry
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
if route.provider == "anthropic":
normalized = _normalize_anthropic_model_name(model)
if normalized != model:
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
if entry:
return entry
return None
+1 -1
View File
@@ -163,7 +163,7 @@ model:
# -----------------------------------------------------------------------------
# Working directory behavior:
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
terminal:
backend: "local"
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
+333 -30
View File
@@ -787,8 +787,10 @@ def AIAgent(*args, **kwargs):
def get_tool_definitions(*args, **kwargs):
from hermes_cli.mcp_startup import wait_for_mcp_discovery
from model_tools import get_tool_definitions as _get_tool_definitions
wait_for_mcp_discovery()
return _get_tool_definitions(*args, **kwargs)
@@ -896,9 +898,12 @@ def _prepare_deferred_agent_startup() -> None:
exc_info=True,
)
try:
from tools.mcp_tool import discover_mcp_tools
from hermes_cli.mcp_startup import start_background_mcp_discovery
discover_mcp_tools()
start_background_mcp_discovery(
logger=logger,
thread_name="termux-cli-mcp-discovery",
)
except Exception:
logger.debug(
"MCP tool discovery failed at deferred CLI startup",
@@ -1537,9 +1542,17 @@ def _query_osc11_background() -> str | None:
Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
within a few ms. We wait up to 100ms total before giving up.
Returns "#RRGGBB" or None on timeout / non-tty.
Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
late reply lands after prompt_toolkit has grabbed the tty its payload
leaks in as typed text and the BEL terminator reads as Ctrl+G (open
editor), trapping the user in a stray editor. Remote sessions fall back to
COLORFGBG / env hints / the dark default instead.
"""
if not sys.stdin.isatty() or not sys.stdout.isatty():
return None
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
return None
try:
import termios
import tty
@@ -1587,8 +1600,11 @@ def _query_osc11_background() -> str | None:
r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
return f"#{r:02X}{g:02X}{b:02X}"
finally:
# TCSAFLUSH discards any unread input as it restores the original
# attributes — scrubs a slow/partial OSC 11 reply out of the tty
# buffer before prompt_toolkit can read it as keystrokes.
try:
termios.tcsetattr(fd, termios.TCSANOW, old)
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
except Exception:
pass
@@ -3248,6 +3264,12 @@ class HermesCLI:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._model_picker_state = None
# Armed when a bare `/resume` prints the recent-sessions list so the
# very next bare numeric input (e.g. `3`) resolves to that session.
# Holds the exact list used for index resolution; one-shot (cleared on
# the next submitted input, whether it's the selection or anything
# else). See #34584.
self._pending_resume_sessions = None
self._secret_state = None
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
@@ -3555,8 +3577,17 @@ class HermesCLI:
compressor = getattr(agent, "context_compressor", None)
if compressor:
# last_prompt_tokens is parked at the -1 sentinel right after a
# compression, until the next real API call reports a prompt count
# (awaiting_real_usage_after_compression). The status bar must not
# render that sentinel verbatim — it produced "-1/200K" / "-1%".
# Clamp it to 0 so the one transitional turn reads as empty context.
context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
if context_tokens < 0:
context_tokens = 0
context_length = getattr(compressor, "context_length", 0) or 0
if context_length < 0:
context_length = 0
snapshot["context_tokens"] = context_tokens
snapshot["context_length"] = context_length or None
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
@@ -4865,6 +4896,10 @@ class HermesCLI:
if not self._ensure_runtime_credentials():
return False
from hermes_cli.mcp_startup import wait_for_mcp_discovery
wait_for_mcp_discovery()
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
if self._session_db is None:
try:
@@ -6252,8 +6287,10 @@ class HermesCLI:
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
# provider). Never invoke it; just identify the auth surface.
from agent.azure_identity_adapter import is_token_provider
if is_token_provider(self.api_key):
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
is_token_provider = _azure_ns.get("is_token_provider")
if is_token_provider and is_token_provider(self.api_key):
api_key_display = "Microsoft Entra ID"
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
@@ -6693,10 +6730,21 @@ class HermesCLI:
if not target:
_cprint(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"):
# Arm a one-shot pending-resume selection so the user can type
# just the number (`3`) on the next line instead of having to
# retype `/resume 3`. The list here must match the one shown by
# _show_recent_sessions and used for index resolution below —
# all three go through _list_recent_sessions(limit=10). See
# #34584.
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
return
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
return
# Any explicit /resume <target> supersedes a previously-armed bare
# numbered prompt.
self._pending_resume_sessions = None
if not self._session_db:
from hermes_state import format_session_db_unavailable
_cprint(f" {format_session_db_unavailable()}")
@@ -6810,6 +6858,44 @@ class HermesCLI:
else:
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
def _consume_pending_resume_selection(self, text: str) -> bool:
"""Resolve a bare numeric reply that follows a bare ``/resume`` prompt.
After ``/resume`` (no args) prints the recent-sessions list it arms
``self._pending_resume_sessions``. The next submitted input is given
one chance to be a bare session number (``3``); if so we resume that
session here. Anything else (another command, free text, blank) simply
disarms the prompt and is handled normally by the caller.
Returns True if the input was consumed as a resume selection (caller
must not treat it as chat); False otherwise. The pending state is
always one-shot: it is cleared on the first submitted input regardless
of outcome. See #34584.
"""
pending = self._pending_resume_sessions
if not pending:
return False
# One-shot: disarm now so a non-matching input can't leave the prompt
# armed and hijack a later number the user meant as chat.
self._pending_resume_sessions = None
if not isinstance(text, str):
return False
stripped = text.strip()
# Only a pure number selects; let "/resume 3", titles, or any other
# text fall through to normal handling.
if not stripped.isdigit():
return False
index = int(stripped)
if index < 1 or index > len(pending):
_cprint(f" Resume index {index} is out of range.")
_cprint(" Use /resume with no arguments to see available sessions.")
return True
self._handle_resume_command(f"/resume {index}")
return True
def _handle_sessions_command(self, cmd_original: str) -> None:
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
@@ -8333,7 +8419,14 @@ class HermesCLI:
_base_word = cmd_lower.split()[0].lstrip("/")
_cmd_def = _resolve_cmd(_base_word)
canonical = _cmd_def.name if _cmd_def else _base_word
# A bare `/resume` prompt is one-shot: any command other than the
# resume/sessions handlers (which manage the pending state themselves)
# disarms it so a later number isn't swallowed as a stale selection.
# See #34584.
if canonical not in {"resume", "sessions"}:
self._pending_resume_sessions = None
if canonical in {"quit", "exit"}:
# Parse --delete flag: /exit --delete also removes the current
# session's transcripts + SQLite history. Ported from
@@ -9885,10 +9978,20 @@ class HermesCLI:
def _manual_compress(self, cmd_original: str = ""):
"""Manually trigger context compression on the current conversation.
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else. Inspired by
Claude Code's ``/compact <focus>`` feature.
Two modes:
* ``/compress [<focus>]`` compress the *whole* history. An
optional focus topic guides the summariser to preserve
information related to *focus* while being more aggressive
about discarding everything else. Inspired by Claude Code's
``/compact <focus>`` feature.
* ``/compress here [N]`` boundary-aware compression. Summarize
everything *except* the most recent ``N`` exchanges (default
2), which are preserved verbatim. Inspired by Claude Code's
Rewind "Summarize up to here" action (v2.1.139, May 2026,
https://code.claude.com/docs/en/whats-new/2026-w20). Lets the
user pick the compression boundary instead of leaving it to
the automatic token-budget heuristic.
"""
if not self.conversation_history or len(self.conversation_history) < 4:
print("(._.) Not enough conversation to compress (need at least 4 messages).")
@@ -9902,12 +10005,21 @@ class HermesCLI:
print("(._.) Compression is disabled in config.")
return
# Extract optional focus topic from the command (e.g. "/compress database schema")
focus_topic = ""
from hermes_cli.partial_compress import (
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
# Args after the command word (e.g. "/compress here 3" -> "here 3").
raw_args = ""
if cmd_original:
parts = cmd_original.strip().split(None, 1)
if len(parts) > 1:
focus_topic = parts[1].strip()
_parts = cmd_original.strip().split(None, 1)
if len(_parts) > 1:
raw_args = _parts[1].strip()
partial, keep_last, focus_topic = parse_partial_compress_args(raw_args)
focus_topic = focus_topic or ""
original_count = len(self.conversation_history)
with self._busy_command("Compressing context..."):
@@ -9915,6 +10027,22 @@ class HermesCLI:
from agent.model_metadata import estimate_request_tokens_rough
from agent.manual_compression_feedback import summarize_manual_compression
original_history = list(self.conversation_history)
# Boundary-aware split: only the head is summarized; the
# most recent `keep_last` exchanges ride along verbatim.
tail: list = []
head = original_history
if partial:
head, tail = split_history_for_partial_compress(
original_history, keep_last
)
if not tail:
# Split degenerated (everything would be kept, or
# no head left to compress). Fall back to full
# compression so the user still gets an action.
partial = False
head = original_history
# Include system prompt + tool schemas in the estimate —
# a transcript-only number understates real request pressure
# and can even appear to grow after compression because a
@@ -9926,7 +10054,11 @@ class HermesCLI:
system_prompt=_sys_prompt,
tools=_tools,
)
if focus_topic:
if partial:
print(f"🗜️ Summarizing up to here: compressing {len(head)} of "
f"{original_count} messages (~{approx_tokens:,} tokens), "
f"keeping last {keep_last} exchange(s) verbatim...")
elif focus_topic:
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
f"focus: \"{focus_topic}\"...")
else:
@@ -9939,12 +10071,21 @@ class HermesCLI:
# which already contain the agent identity — resulting in the
# identity block appearing twice (issue #15281).
compressed, _ = self.agent._compress_context(
original_history,
head,
None,
approx_tokens=approx_tokens,
focus_topic=focus_topic or None,
force=True,
)
# Re-append the verbatim tail after the compressed head.
# The split guarantees `tail` begins on a user turn, so the
# compressed-head -> tail boundary is normally valid
# (the head's compressed output ends on assistant/tool).
# rejoin_compressed_head_and_tail() additionally guards the
# seam against any illegal user->user / assistant->assistant
# adjacency, defending provider role-alternation rules.
if partial and tail:
compressed = rejoin_compressed_head_and_tail(compressed, tail)
self.conversation_history = compressed
# _compress_context ends the old session and creates a new child
# session on the agent (run_agent.py::_compress_context). Sync the
@@ -10984,7 +11125,14 @@ class HermesCLI:
return
self._voice_tts_done.clear()
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
text_to_speech_tool = _tts_provider.tool_functions.get("text_to_speech_tool")
check_tts_requirements = _tts_provider.check_fn
if text_to_speech_tool is None:
raise ImportError("text_to_speech_tool not found in tts provider")
from tools.voice_mode import play_audio_file
# Strip markdown and non-speech content for cleaner TTS
@@ -11167,8 +11315,10 @@ class HermesCLI:
status = "enabled" if self._voice_tts else "disabled"
if self._voice_tts:
from tools.tts_tool import check_tts_requirements
if not check_tts_requirements():
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
check_tts_requirements = _tts_provider.check_fn if _tts_provider else None
if check_tts_requirements and not check_tts_requirements():
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
@@ -11790,13 +11940,17 @@ class HermesCLI:
if self._voice_tts:
try:
from tools.tts_tool import (
_load_tts_config as _load_tts_cfg,
_get_provider as _get_prov,
_import_elevenlabs,
_import_sounddevice,
stream_tts_to_speaker,
)
from agent.plugin_registries import registries
_tts_provider = registries.get_tool_provider("tts")
if _tts_provider is None:
raise ImportError("tts tool provider not registered")
_load_tts_cfg = _tts_provider.config_functions.get("_load_tts_config")
_get_prov = _tts_provider.config_functions.get("_get_provider")
_import_elevenlabs = _tts_provider.config_functions.get("_import_elevenlabs")
_import_sounddevice = _tts_provider.config_functions.get("_import_sounddevice")
stream_tts_to_speaker = _tts_provider.tool_functions.get("stream_tts_to_speaker")
if not all([_load_tts_cfg, _get_prov, stream_tts_to_speaker]):
raise ImportError("streaming TTS functions not found in tts provider")
_tts_cfg = _load_tts_cfg()
if _get_prov(_tts_cfg) == "elevenlabs":
# Verify both ElevenLabs SDK and audio output are available
@@ -12835,6 +12989,13 @@ class HermesCLI:
if event.app.is_running:
event.app.exit()
event.app.current_buffer.reset(append_to_history=True)
# Force a repaint: process_command() prints through
# patch_stdout (scrolls output above the prompt) and never
# invalidates the app, so the just-cleared input area can
# keep showing the submitted text until some unrelated
# redraw fires. Every other early-return branch in this
# handler invalidates after reset — match them.
event.app.invalidate()
return
# Handle /steer while the agent is running immediately on the
@@ -12846,6 +13007,13 @@ class HermesCLI:
if self._should_handle_steer_command_inline(text, has_images=has_images):
self.process_command(text)
event.app.current_buffer.reset(append_to_history=True)
# Force a repaint after clearing the buffer. /steer is
# dispatched mid-run while the agent streams output through
# patch_stdout; process_command() never invalidates the
# app, so without this the submitted "/steer <text>" can
# linger in the input area (looking unsent) and invite an
# accidental re-submit. See issue #34569.
event.app.invalidate()
return
# Snapshot and clear attached images
@@ -13979,7 +14147,12 @@ class HermesCLI:
reserved_below = 6
available = max(0, term_rows - reserved_below)
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
# The compact decision must reserve room for at least one question
# row on top of the choices, otherwise full chrome (3 blank
# separators) gets kept when there is no room for it and the panel
# overflows the viewport — HSplit then clips the panel's tail,
# silently dropping the choices (the reported bug).
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
use_compact_chrome = mandatory_full > available
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
@@ -13987,9 +14160,24 @@ class HermesCLI:
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
# When the choices alone (plus compact chrome) already exceed the
# viewport, drop the question entirely — the choices are the only
# thing the user must see to make a selection. Without this the
# question would still claim its 1-row floor above and push the
# tail of the choices off-screen (HSplit clips the overflow).
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
if choices_overflow:
max_question_rows = 0
question_wrapped = _wrap_panel_text(question, inner_text_width)
if len(question_wrapped) > max_question_rows:
keep = max(1, max_question_rows - 1)
if max_question_rows <= 0:
question_wrapped = []
elif len(question_wrapped) > max_question_rows:
# The truncation marker is itself a row, so it must count
# against the budget. With a 1-row budget there is no room for
# both a question line and the marker — show the marker alone
# so the rendered question never exceeds max_question_rows.
keep = max(0, max_question_rows - 1)
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
lines = []
@@ -14523,6 +14711,17 @@ class HermesCLI:
+ (f"\n{_remainder}" if _remainder else "")
)
# A bare number right after a bare `/resume` prompt selects
# that session (see #34584). Checked before chat routing so
# the digit isn't sent to the agent as a message.
if (
not _file_drop
and self._pending_resume_sessions
and isinstance(user_input, str)
and self._consume_pending_resume_selection(user_input)
):
continue
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
_cprint(f"\n⚙️ {user_input}")
try:
@@ -14899,6 +15098,96 @@ class HermesCLI:
# Main Entry Point
# ============================================================================
def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
"""Drive a kanban goal_mode worker through the Ralph-style goal loop.
Called from the quiet single-query path AFTER the worker's first turn,
only when ``HERMES_KANBAN_GOAL_MODE`` is set (dispatcher-spawned
goal_mode card). Wires the worker's ``run_conversation`` and the kanban
DB into ``goals.run_kanban_goal_loop``. All errors are swallowed by the
caller a broken goal loop must never wedge a worker, the dispatcher's
claim TTL / crash detection is the backstop.
"""
import os as _os
task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
if not task_id:
return
from hermes_cli import kanban_db as _kb
from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS
# Resolve goal text from the card (title + body = the acceptance
# criteria the judge evaluates against).
conn = _kb.connect()
try:
task = _kb.get_task(conn, task_id)
finally:
try:
conn.close()
except Exception:
pass
if task is None:
return
goal_parts = [task.title or ""]
if task.body:
goal_parts.append(task.body)
goal_text = "\n\n".join(p for p in goal_parts if p).strip()
if not goal_text:
return
max_turns = task.goal_max_turns or _DEF_TURNS
def _run_turn(prompt: str) -> str:
result = cli.agent.run_conversation(
user_message=prompt,
conversation_history=cli.conversation_history,
)
# Keep session_id in sync if mid-run compression rotated it.
if (
getattr(cli.agent, "session_id", None)
and cli.agent.session_id != cli.session_id
):
cli.session_id = cli.agent.session_id
resp = result.get("final_response", "") if isinstance(result, dict) else str(result)
if resp:
print(resp)
return resp or ""
def _task_status() -> "str | None":
c = _kb.connect()
try:
t = _kb.get_task(c, task_id)
return t.status if t is not None else None
finally:
try:
c.close()
except Exception:
pass
def _block(reason: str) -> None:
c = _kb.connect()
try:
_kb.block_task(c, task_id, reason=reason)
finally:
try:
c.close()
except Exception:
pass
_run_loop(
task_id=task_id,
goal_text=goal_text,
run_turn=_run_turn,
task_status_fn=_task_status,
block_fn=_block,
max_turns=max_turns,
first_response=first_response or "",
log=lambda m: logger.info("%s", m),
)
def main(
query: str = None,
q: str = None,
@@ -15296,6 +15585,20 @@ def main(
print(f"Error: {result['error']}", file=sys.stderr)
elif response:
print(response)
# Kanban goal-loop mode: a worker spawned for a
# goal_mode card keeps working in THIS session until an
# auxiliary judge agrees the card is done, the worker
# terminates the task itself, or the turn budget runs
# out (→ sticky block). Gated on the env vars the
# dispatcher sets in `_default_spawn`; a no-op for every
# normal worker and every non-kanban `-q` run.
if os.environ.get("HERMES_KANBAN_GOAL_MODE") == "1":
try:
_run_kanban_goal_loop_q(cli, response)
except Exception as _goal_exc:
logger.debug("kanban goal loop failed: %s", _goal_exc)
# Session ID goes to stderr so piped stdout is clean.
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
+298 -1
View File
@@ -27,10 +27,37 @@ from pathlib import Path
import pytest
# Ensure project root is importable
PROJECT_ROOT = Path(__file__).parent.parent
PROJECT_ROOT = Path(__file__).parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# ── Plugin directory sys.path shadow fix ────────────────────────────────────
# pytest adds ``plugins/model-providers/`` to sys.path because
# ``plugins/model-providers/anthropic/__init__.py`` (a provider profile) exists.
# This makes ``import anthropic`` shadow the real SDK package with the plugin
# directory. We fix this via pytest_load_initial_conftests (runs before pytest
# adds package roots) AND inline here as belt-and-suspenders.
_bad_path = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad_path in sys.path:
sys.path.remove(_bad_path)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
def pytest_load_initial_conftests(early_config, parser, args):
"""Remove plugin dirs from sys.path before pytest adds package roots.
This must run as early as possible before hermes_agent_anthropic is
imported to prevent ``plugins/model-providers/anthropic/__init__.py``
(a provider profile) from shadowing the real ``anthropic`` SDK.
"""
_bad = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad in sys.path:
sys.path.remove(_bad)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
# ── Per-file process isolation ──────────────────────────────────────────────
# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh
@@ -182,6 +209,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_SESSION_SOURCE",
"HERMES_SESSION_KEY",
"HERMES_GATEWAY_SESSION",
"_HERMES_GATEWAY",
"HERMES_PLATFORM",
"HERMES_MODEL",
"HERMES_INFERENCE_MODEL",
@@ -533,6 +561,43 @@ def pytest_configure(config): # noqa: D401 — pytest hook
)
def pytest_collection_finish(session) -> None: # noqa: D401 — pytest hook
"""Warn when pytest is invoked directly instead of via the parallel runner.
The canonical runner (scripts/run_tests_parallel.py) spawns one
pytest subprocess per file, giving each a fresh Python interpreter.
This prevents cross-file module-level state leakage (especially the
plugin-registry singleton) from causing ordering-dependent failures.
Running ``pytest tests/`` directly collapses all files into one
process and WILL see spurious failures that don't exist in CI.
The runner sets HERMES_PARALLEL_RUNNER=1 in each subprocess so we
can detect the difference here.
"""
if os.environ.get("HERMES_PARALLEL_RUNNER"):
return # launched by the runner — all good
n = len(session.items)
if n < 50:
return # single-file or tiny run — fine to use bare pytest
_YELLOW = "\033[33m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
print(
f"\n{_BOLD}{_YELLOW}⚠ hermes-agent test suite warning{_RESET}\n"
f" You're running {n} tests directly under pytest.\n"
f" Cross-file module-state leakage (esp. the plugin registry\n"
f" singleton) can cause ordering-dependent failures that don't\n"
f" exist in CI.\n\n"
f" Use the canonical runner instead:\n"
f" {_BOLD}python scripts/run_tests_parallel.py{_RESET}\n\n"
f" To suppress this warning (e.g. for a focused multi-file run):\n"
f" {_BOLD}HERMES_PARALLEL_RUNNER=1 pytest ...{_RESET}\n",
file=sys.stderr,
)
@pytest.fixture(autouse=True)
def _live_system_guard(request, monkeypatch):
"""Block real os.kill / systemctl / gateway-pid scans during tests.
@@ -838,3 +903,235 @@ def _live_system_guard(request, monkeypatch):
pass
yield
# ── Anthropic registry seed (shared across all test subdirs) ────────────────
# Defined in root conftest so tests/hermes_cli, tests/run_agent,
# tests/tools, tests/gateway all get it. tests/agent has its own copy too.
from contextlib import contextmanager
from unittest.mock import MagicMock
__all__ = ["mock_anthropic_provider"]
def _mock_endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""Functional mock — detects Anthropic-wire endpoints by URL pattern.
Reproduces the real plugin's logic without importing it.
"""
if not base_url:
return False
normalized = base_url.lower().rstrip("/")
if normalized.endswith("/anthropic"):
return True
# api.anthropic.com
if "api.anthropic.com" in normalized:
return True
# kimi coding plan
if "api.kimi.com" in normalized and "/coding" in normalized:
return True
return False
def _mock_is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Functional mock — detects Anthropic-compat endpoints.
Reproduces the real plugin's logic: named compat providers OR /anthropic URL suffix.
"""
_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
if provider in _COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _mock_convert_openai_images_to_anthropic(messages: list) -> list:
"""Functional mock — converts OpenAI image_url blocks to Anthropic image blocks."""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _mock_maybe_wrap_anthropic(client_obj, model, api_key, base_url, api_mode=None):
"""Functional mock for maybe_wrap_anthropic — wraps when endpoint is Anthropic-wire.
Reproduces the real plugin's wrapping logic without importing it.
Uses the real AnthropicAuxiliaryClient from core (no SDK dependency).
"""
# Already wrapped — don't double-wrap
from agent.anthropic_aux import (AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient)
if isinstance(client_obj, (AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient)):
return client_obj
# Check for other specialized adapters we should never re-dispatch
try:
from agent.auxiliary_client import CodexAuxiliaryClient
if isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or _mock_endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
# Use the registry's build_anthropic_client to construct a real(ish) client
from agent.plugin_registries import registries
build_fn = registries.get_provider_service("anthropic", "build_anthropic_client")
if build_fn is None:
return client_obj
try:
real_client = build_fn(api_key, base_url)
except Exception:
return client_obj
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _make_base_anthropic_namespace() -> dict:
"""Build a minimal anthropic service namespace with safe mock stubs.
Wire-format code (build_anthropic_kwargs, convert_messages_to_anthropic,
AnthropicAuxiliaryClient, etc.) has moved to core modules and is no
longer looked up via the registry. Only SDK-dependent orchestration
(maybe_wrap_anthropic, is_anthropic_compat_endpoint, client building,
auth) still needs mock stubs here.
"""
mock_client = MagicMock(name="anthropic_client")
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-mock"
def _resolve_token():
"""Return token from env vars if set — mimics the real resolve_anthropic_token."""
import os
return (os.environ.get("ANTHROPIC_TOKEN")
or os.environ.get("ANTHROPIC_API_KEY"))
return {
# SDK-dependent client building
"build_anthropic_client": MagicMock(return_value=mock_client),
"build_anthropic_bedrock_client": MagicMock(return_value=mock_client),
"resolve_anthropic_token": _resolve_token,
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
"is_claude_code_token_valid": MagicMock(return_value=False),
"read_claude_code_credentials": MagicMock(return_value=None),
"write_claude_code_credentials": MagicMock(),
"refresh_oauth_token": MagicMock(return_value=None),
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
"_HERMES_OAUTH_FILE": MagicMock(),
# Resolve / endpoint detection (still plugin-provided, still needs mocking)
"maybe_wrap_anthropic": _mock_maybe_wrap_anthropic,
"endpoint_speaks_anthropic_messages": _mock_endpoint_speaks_anthropic_messages,
"is_anthropic_compat_endpoint": _mock_is_anthropic_compat_endpoint,
"convert_openai_images_to_anthropic": _mock_convert_openai_images_to_anthropic,
"ANTHROPIC_DEFAULT_BASE_URL": "https://api.anthropic.com",
"_ANTHROPIC_COMPAT_PROVIDERS": frozenset(),
"resolve_auxiliary_client": MagicMock(return_value=(mock_client, "claude-3-5-sonnet-20241022")),
}
@contextmanager
def mock_anthropic_provider(**overrides):
"""Patch the anthropic registry namespace. Use in core tests instead of
patching hermes_agent_anthropic.* directly.
Usage:
with mock_anthropic_provider(build_anthropic_client=my_mock):
result = resolve_provider_client(...)
"""
from agent.plugin_registries import registries
base = _make_base_anthropic_namespace()
base.update(overrides)
with patch.dict(registries._provider_services, {"anthropic": base}):
yield base
@pytest.fixture(autouse=True)
def _seed_anthropic_registry(request):
"""Install mock anthropic namespace before each test, restore after.
Skips for plugin tests they have their own conftest that registers the
real plugin, and we must NOT block them with _guarded_register.
Uses patch.dict so it's guaranteed to restore even when plugin tests
in other directories (which use the real plugin) run before us in the
same process. Function-scoped (not session) so it re-seeds after each
plugin test that overwrites the registry.
Also clears _provider_resolvers["anthropic"] so a real plugin registration
that leaked from another test file doesn't affect core unit tests.
Also blocks _ensure_plugins_discovered() so that code paths that lazily
trigger plugin loading (e.g. get_plugin_auxiliary_tasks via
_resolve_task_provider_model) don't overwrite the mock namespace.
"""
# Skip for plugin tests — they manage the registry themselves
node_path = str(request.fspath)
if "/plugins/" in node_path:
yield
return
from unittest.mock import patch
from agent.plugin_registries import registries
ns = _make_base_anthropic_namespace()
# Guard registries.register_provider_services so that if discover_and_load()
# fires during a test (e.g. via get_plugin_auxiliary_tasks in
# _resolve_task_provider_model), it can't overwrite our mock anthropic
# namespace. We only block "anthropic" — other providers / hooks proceed
# normally so tests like test_context_engine.py still work.
_orig_register = registries.register_provider_services
def _guarded_register(name, services):
if name == "anthropic":
return # mock namespace wins — don't let the real plugin clobber it
return _orig_register(name, services)
_orig_resolver = registries._provider_resolvers.pop("anthropic", None)
with patch.dict(registries._provider_services, {"anthropic": ns}), \
patch.object(registries, "register_provider_services", _guarded_register):
yield
# Restore resolver (None means "not registered", which is correct for
# core unit tests; plugin tests that need the real resolver load it themselves)
if _orig_resolver is not None:
registries._provider_resolvers["anthropic"] = _orig_resolver
else:
registries._provider_resolvers.pop("anthropic", None)
+73 -6
View File
@@ -474,6 +474,13 @@ class GatewayConfig:
# Delivery settings
always_log_local: bool = True # Always save cron outputs to local files
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
# ".") pre-send. These are model hallucinations emitted when a persona has
# nothing actionable to say; in bot-to-bot channels they mirror back and
# forth, burning tokens and crashing models. Substrate-level guard that
# survives SOUL.md/prompt drift across providers. Opt out with False for
# raw passthrough.
filter_silence_narration: bool = True
# STT settings
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
@@ -582,6 +589,7 @@ class GatewayConfig:
"quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local,
"filter_silence_narration": self.filter_silence_narration,
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
@@ -650,6 +658,9 @@ class GatewayConfig:
quick_commands=quick_commands,
sessions_dir=sessions_dir,
always_log_local=_coerce_bool(data.get("always_log_local"), True),
filter_silence_narration=_coerce_bool(
data.get("filter_silence_narration"), True
),
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
@@ -757,21 +768,32 @@ def load_gateway_config() -> GatewayConfig:
if "always_log_local" in yaml_cfg:
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
if "filter_silence_narration" in yaml_cfg:
gw_data["filter_silence_narration"] = yaml_cfg[
"filter_silence_narration"
]
if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
yaml_cfg.get("unauthorized_dm_behavior"),
"pair",
)
# Merge platforms section from config.yaml into gw_data so that
# nested keys like platforms.webhook.extra.routes are loaded.
yaml_platforms = yaml_cfg.get("platforms")
# Merge platform config into gw_data so runtime-only settings under
# ``gateway.platforms`` are loaded the same way as top-level
# ``platforms``. Merge nested first so top-level config keeps
# precedence, matching the existing gateway.streaming fallback.
gateway_cfg = yaml_cfg.get("gateway")
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
platforms_data = gw_data.setdefault("platforms", {})
if not isinstance(platforms_data, dict):
platforms_data = {}
gw_data["platforms"] = platforms_data
if isinstance(yaml_platforms, dict):
for plat_name, plat_block in yaml_platforms.items():
def _merge_platform_map(source_platforms: Any) -> None:
if not isinstance(source_platforms, dict):
return
for plat_name, plat_block in source_platforms.items():
if not isinstance(plat_block, dict):
continue
existing = platforms_data.get(plat_name, {})
@@ -785,6 +807,10 @@ def load_gateway_config() -> GatewayConfig:
if merged_extra:
merged["extra"] = merged_extra
platforms_data[plat_name] = merged
_merge_platform_map(gateway_platforms)
_merge_platform_map(yaml_cfg.get("platforms"))
if platforms_data:
gw_data["platforms"] = platforms_data
# Iterate built-in platforms plus any registered plugin platforms
# so plugin authors get the same shared-key bridging (#24836).
@@ -890,6 +916,18 @@ def load_gateway_config() -> GatewayConfig:
if entry.apply_yaml_config_fn is None:
continue
platform_cfg = yaml_cfg.get(entry.name)
# Fall back to the platform's block under ``platforms`` /
# ``gateway.platforms`` so adapter hooks still run when the
# user configured the platform only under those nested paths
# (e.g. ``platforms.discord.extra.allow_from``) and not via a
# top-level ``discord:`` block.
if not isinstance(platform_cfg, dict):
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
if isinstance(_src, dict):
_candidate = _src.get(entry.name)
if isinstance(_candidate, dict):
platform_cfg = _candidate
break
if not isinstance(platform_cfg, dict):
continue
try:
@@ -1297,7 +1335,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.SLACK].enabled = True
else:
slack_config = config.platforms[Platform.SLACK]
enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False))
enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False))
if not slack_config.enabled and not enabled_was_explicit:
# Top-level Slack settings such as channel prompts should not
# turn an env-token setup into a disabled platform. Only an
@@ -1846,6 +1884,28 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# explicitly configured in YAML / env (existing_cfg with
# enabled=True means the user wrote it themselves or another
# env-var bridge enabled it — keep that decision).
#
# If the user explicitly set enabled: false in YAML (signalled
# by _enabled_explicit in extra), honour that even when the
# platform is currently disabled. Without this check, the
# is_connected probe force-enables plugin platforms that the
# user intentionally turned off. (See #31116, #32656.)
_was_explicitly_disabled = (
existing_cfg is not None
and not existing_cfg.enabled
and existing_cfg.extra.get("_enabled_explicit", False)
)
if _was_explicitly_disabled:
# User explicitly set enabled: false in YAML — honour that
# even though check_fn() says the SDK is importable. Without
# this guard, the generic plugin loop force-enables platforms
# the user intentionally turned off. (See #31116, #32656.)
logger.debug(
"Plugin platform '%s' explicitly disabled (enabled: false in "
"YAML) — skipping auto-enable",
entry.name,
)
continue
if existing_cfg is None or not existing_cfg.enabled:
if entry.is_connected is not None:
try:
@@ -1918,3 +1978,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
)
except Exception as e:
logger.debug("Plugin platform enable pass failed: %s", e)
# Clean up internal _enabled_explicit flags that the shared-key bridge
# loop injects into PlatformConfig.extra. These are consumed above
# (e.g. the Slack env-token logic, the generic plugin loop) but must
# not leak into user-visible extra dicts.
for _pconfig in config.platforms.values():
_pconfig.extra.pop("_enabled_explicit", None)
+61
View File
@@ -9,6 +9,8 @@ Routes messages to the appropriate destination based on:
"""
import logging
import os
import re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
@@ -21,6 +23,32 @@ logger = logging.getLogger(__name__)
MAX_PLATFORM_OUTPUT = 4000
TRUNCATED_VISIBLE = 3800
# Matches strings that are *only* a "silence" narration with optional markdown
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
# wild. Anchored to start/end so substantive messages that merely *contain* the
# word "silent" are never matched.
_SILENCE_NARRATION = re.compile(
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
re.IGNORECASE,
)
def _is_silence_narration(content: Optional[str]) -> bool:
"""Return True when ``content`` is *only* a silence-narration token.
Length-guarded (real messages are longer) and anchored to the whole string
so legitimate prose like "The deployment ran silently" or "Silence is
golden here is the plan..." is never flagged.
"""
if not content:
return False
stripped = content.strip()
if not stripped or len(stripped) > 64: # length guard
return False
return bool(_SILENCE_NARRATION.match(stripped))
from .config import Platform, GatewayConfig
from .session import SessionSource
@@ -261,6 +289,18 @@ class DeliveryRouter:
path.write_text(content)
return path
def _filter_silence_narration_enabled(self) -> bool:
"""Whether the outbound silence-narration filter is active.
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
otherwise the ``gateway.filter_silence_narration`` config flag wins
(default True).
"""
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
return bool(getattr(self.config, "filter_silence_narration", True))
async def _deliver_to_platform(
self,
target: DeliveryTarget,
@@ -286,6 +326,27 @@ class DeliveryRouter:
+ f"\n\n... [truncated, full output saved to {saved_path}]"
)
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
# In bot-to-bot channels these tokens mirror back and forth until a
# model crashes with "no content after all retries". Behavioral prompt
# rules drift across providers; this single chokepoint covers every
# platform adapter regardless of which persona's prompt failed.
# Local/file delivery (_deliver_local) is a separate path and is never
# filtered — saved silence has no loop risk.
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
logger.warning(
"Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value,
target.chat_id,
content[:40],
)
return {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
send_metadata = dict(metadata or {})
is_named_telegram_private_topic = False
named_telegram_private_topic_name: Optional[str] = None
+40
View File
@@ -1605,6 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
)
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
await queue.put(_event_payload("assistant.completed", {
"session_id": effective_session_id,
"message_id": message_id,
@@ -1617,6 +1618,7 @@ class APIServerAdapter(BasePlatformAdapter):
"session_id": effective_session_id,
"message_id": message_id,
"completed": True,
"messages": turn_messages,
"usage": usage,
}))
except Exception as exc:
@@ -3329,6 +3331,44 @@ class APIServerAdapter(BasePlatformAdapter):
return len(prior)
return 0
@classmethod
def _turn_transcript_messages(
cls,
conversation_history: List[Dict[str, Any]],
user_message: Any,
result: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Return this turn's assistant/tool messages in client-safe shape.
The streaming SSE contract delivers all assistant text as
``assistant.delta`` events under one ``message_id`` interleaved with
``tool.*`` events, and a single ``assistant.completed`` carrying only
the final reply. A client that accumulates deltas into one buffer
cannot reconstruct *intermediate* assistant text segments that preceded
tool calls so when the page is re-opened mid/post-stream those
segments appear lost, even though state.db persisted them correctly.
Emitting the authoritative per-turn transcript on ``run.completed`` lets
any SSE consumer reconcile its live view against ground truth without a
separate ``GET /messages`` round-trip. Purely additive: clients that
ignore the field are unaffected. Refs #34703.
"""
agent_messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(agent_messages, list) or not agent_messages:
return []
start = cls._response_messages_turn_start_index(
conversation_history, user_message, result
)
turn = agent_messages[start:]
out: List[Dict[str, Any]] = []
for msg in turn:
if not isinstance(msg, dict):
continue
if msg.get("role") not in {"assistant", "tool"}:
continue
out.append(cls._message_response(msg))
return out
@staticmethod
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
"""
+111 -39
View File
@@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
@@ -827,6 +827,7 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
_HERMES_HOME = get_hermes_home()
_HERMES_ROOT = get_default_hermes_root()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
@@ -954,11 +955,14 @@ def _media_delivery_denied_paths() -> List[Path]:
home = Path(os.path.expanduser("~"))
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
denied.append(home / sub)
# The Hermes home itself contains credentials (auth.json, .env) — only the
# cache subdirectories under it are explicitly allowlisted above.
denied.append(_HERMES_HOME / ".env")
denied.append(_HERMES_HOME / "auth.json")
denied.append(_HERMES_HOME / "credentials")
# The active Hermes profile and shared Hermes root both contain control
# files and credentials. Only cache subdirectories under them are
# explicitly allowlisted above.
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
denied.append(hermes_root / ".env")
denied.append(hermes_root / "auth.json")
denied.append(hermes_root / "credentials")
denied.append(hermes_root / "config.yaml")
return denied
@@ -1131,6 +1135,77 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
}
# ---------------------------------------------------------------------------
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
#
# Both extractors that turn response text into native attachments derive their
# extension set from this tuple:
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
#
# Historically these two carried independently-maintained extension lists.
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
# ``extract_local_files`` had a broad one. Combined with the unconditional
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
# match, got stripped from the body by the loose cleanup regex, and was then
# invisible to extract_local_files — the file was never delivered (issue
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
# from the same set means a tag is only stripped when its extension is one we
# can actually deliver, so an unknown-extension path survives in the body
# instead of vanishing.
#
# Covers images (inline), video (inline where supported), audio (voice/audio),
# documents/spreadsheets/presentations (send_document), archives, and rendered
# web output. The dispatch partition (image vs video vs document) lives in
# ``gateway/run.py``.
# ---------------------------------------------------------------------------
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
# Images (embed inline)
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
# Video (embed inline where supported)
".mp4", ".mov", ".avi", ".mkv", ".webm",
# Audio (delivered as voice/audio where supported)
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
# Documents (uploaded as file attachments)
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
# Spreadsheets / data
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
# Presentations
".pptx", ".ppt", ".odp", ".key",
# Archives
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
# Web / rendered output
".html", ".htm",
)
# Regex alternation fragment of bare extensions (no leading dot), e.g.
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
# longest-first so the alternation never matches a shorter ext as a prefix of
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
_MEDIA_EXT_ALTERNATION = "|".join(
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
)
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
# an unknown extension is left in the text so it can still be picked up by the
# bare-path detector (extract_local_files) downstream rather than silently
# deleted. Shared by the non-streaming dispatch path and the streaming
# consumer so both behave identically.
# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),
# ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
MEDIA_TAG_CLEANUP_RE = re.compile(
r'''[`"']?MEDIA:\s*'''
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
re.IGNORECASE,
)
def get_document_cache_dir() -> Path:
"""Return the document cache directory, creating it if it doesn't exist."""
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -2542,10 +2617,10 @@ class BasePlatformAdapter(ABC):
cleaned = cleaned.replace("[[as_document]]", "")
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
# and quoted/backticked paths for LLM-formatted outputs.
media_pattern = re.compile(
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
)
# and quoted/backticked paths for LLM-formatted outputs. The extension
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
media_pattern = MEDIA_TAG_CLEANUP_RE
for match in media_pattern.finditer(content):
path = match.group("path").strip()
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
@@ -2591,31 +2666,15 @@ class BasePlatformAdapter(ABC):
Tuple of (list of expanded file paths, cleaned text with the
raw path strings removed).
"""
_LOCAL_MEDIA_EXTS = (
# Images (embed inline)
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
# Video (embed inline where supported)
'.mp4', '.mov', '.avi', '.mkv', '.webm',
# Audio (delivered as voice/audio where supported)
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
# Documents (uploaded as file attachments)
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
# Spreadsheets / data
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
# Presentations
'.pptx', '.ppt', '.odp', '.key',
# Archives
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
# Web / rendered output
'.html', '.htm',
)
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
# and relative paths (./foo.png)
# (?:~/|/) anchors to absolute or home-relative paths
# (?:~/|/) anchors to absolute or home-relative Unix paths
# (?:[A-Za-z]:[/\\]) anchors to Windows drive-letter paths (#34632)
path_re = re.compile(
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
re.IGNORECASE,
)
@@ -3681,6 +3740,7 @@ class BasePlatformAdapter(ABC):
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
is_ephemeral_response = isinstance(response, EphemeralReply)
# Slash-command handlers may return an EphemeralReply sentinel to
# request that their reply message auto-delete after a TTL (used
@@ -3729,16 +3789,25 @@ class BasePlatformAdapter(ABC):
# Strip any remaining internal directives from message body (fixes #1561)
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
text_content = text_content.replace("[[as_document]]", "").strip()
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
# Strip only MEDIA: tags whose path has a deliverable extension
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
# extension is intentionally left in the body so extract_local_files
# below can still pick up the bare path — otherwise the file would
# be silently dropped (issue #34517).
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax)
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
local_files = []
if not is_ephemeral_response:
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax). Skip
# system/command notices so config paths stay visible text
# instead of becoming native uploads.
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
@@ -3750,8 +3819,11 @@ class BasePlatformAdapter(ABC):
and text_content
and not media_files):
try:
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
check_tts_requirements = _tts.check_fn if _tts else None
if check_tts_requirements and text_to_speech_tool and check_tts_requirements():
import json as _json
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
+9 -1
View File
@@ -14,6 +14,7 @@ import logging
import os
import re
import uuid
from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import quote
@@ -60,6 +61,8 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
_PHONE_RE = re.compile(r"\+?\d{7,15}")
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
def _redact(text: str) -> str:
"""Redact phone numbers and emails from log output."""
@@ -128,7 +131,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
self._runner = None
self._private_api_enabled: Optional[bool] = None
self._helper_connected: bool = False
self._guid_cache: Dict[str, str] = {}
self._guid_cache: OrderedDict[str, str] = OrderedDict()
# ------------------------------------------------------------------
# API helpers
@@ -365,6 +368,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if ";" in target:
return target
if target in self._guid_cache:
self._guid_cache.move_to_end(target)
return self._guid_cache[target]
try:
payload = await self._api_post(
@@ -377,10 +381,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if identifier == target:
if guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
for part in chat.get("participants", []) or []:
if (part.get("address") or "").strip() == target and guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
except Exception:
pass
+115 -6
View File
@@ -1180,12 +1180,48 @@ class WeixinAdapter(BasePlatformAdapter):
default=False,
)
# Text debounce batching (mirrors Telegram adapter pattern).
# iLink delivers messages individually, so rapid multi-message
# bursts (forwarded batches, paste-splits) each trigger a
# separate agent invocation. Default 3s delay / 5s split delay
# are tuned for iLink's typical delivery cadence. Tunable via
# config.yaml under
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 3.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 5.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
if self._account_id and not self._token:
persisted = load_weixin_account(hermes_home, self._account_id)
if persisted:
self._token = str(persisted.get("token") or "").strip()
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
@staticmethod
def _coerce_list(value: Any) -> List[str]:
if value is None:
@@ -1247,6 +1283,11 @@ class WeixinAdapter(BasePlatformAdapter):
async def disconnect(self) -> None:
_LIVE_ADAPTERS.pop(self._token, None)
self._running = False
for task in self._pending_text_batch_tasks.values():
if not task.done():
task.cancel()
self._pending_text_batches.clear()
self._pending_text_batch_tasks.clear()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
@@ -1395,12 +1436,10 @@ class WeixinAdapter(BasePlatformAdapter):
timestamp=datetime.now(),
)
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
await self.handle_message(event)
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
@@ -1409,6 +1448,76 @@ class WeixinAdapter(BasePlatformAdapter):
return sender_id in self._allow_from
return True
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Text debounce batching
# ------------------------------------------------------------------
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When users forward multiple messages or send rapid-fire texts
via WeChat, each arrives as a separate iLink message. This
concatenates them and waits for a short quiet period before
dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
if self._pending_text_batch_tasks.get(key) is not current_task:
return
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
item_type = item.get("type")
if item_type == ITEM_IMAGE:
+102 -2
View File
@@ -278,6 +278,43 @@ class WhatsAppAdapter(BasePlatformAdapter):
# notification before the normal "✓ whatsapp disconnected" fires.
self._shutting_down: bool = False
# Text debounce batching (mirrors Telegram adapter pattern).
# WhatsApp often delivers multiple messages in rapid succession
# (e.g. forwarded batches, paste-splits) — without debounce each
# message triggers a separate agent invocation, wasting tokens and
# flooding the user with reply fragments. Default 5s delay /
# 10s split delay are conservative for WhatsApp's delivery cadence.
# Tunable via config.yaml under
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 5.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 10.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
def _effective_reply_prefix(self) -> str:
"""Return the prefix the Node bridge will add in self-chat mode."""
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
@@ -1139,7 +1176,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
for msg_data in messages:
event = await self._build_message_event(msg_data)
if event:
await self.handle_message(event)
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
except asyncio.CancelledError:
break
except Exception as e:
@@ -1151,7 +1191,67 @@ class WhatsAppAdapter(BasePlatformAdapter):
await asyncio.sleep(5)
await asyncio.sleep(1) # Poll interval
# ── Text debounce batching ──────────────────────────────────────
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When WhatsApp delivers rapid-fire messages (e.g. forwarded
batches), this concatenates them and waits for a short quiet
period before dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
+478 -138
View File
@@ -1730,6 +1730,14 @@ class GatewayRunner:
self._running_agents: Dict[str, Any] = {}
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
# Last successfully-resolved (non-empty) model, keyed by session. Used
# as a fallback when a fresh config read transiently returns an empty
# model (e.g. an mtime-keyed config-cache miss during a post-interrupt
# recovery turn). Without this, the agent is built with model="" and
# every API call fails HTTP 400 "No models provided" — the session goes
# silent until the user manually re-sends. See #35314. ``"*"`` holds a
# process-wide last-known-good for sessions seen for the first time.
self._last_resolved_model: Dict[str, str] = {}
# Overflow buffer for explicit /queue commands. The adapter-level
# _pending_messages dict is a single slot per session (designed for
# "next-turn" follow-ups where repeated sends collapse into one
@@ -2488,6 +2496,32 @@ class GatewayRunner:
except Exception:
pass
# Final safety net (#35314): if resolution still produced an empty
# model — e.g. a transient config-cache miss during a post-interrupt
# recovery turn returned an empty user_config — reuse the last model we
# successfully resolved for this session (or, failing that, the most
# recent one resolved process-wide). Building an agent with model=""
# makes every API call fail HTTP 400 "No models provided" and the
# session goes silent until the user manually re-sends. ``getattr``
# guards against bare test runners built via ``object.__new__``.
_last_good = getattr(self, "_last_resolved_model", None)
if _last_good is not None:
if not model:
_recovered = _last_good.get(resolved_session_key or "") or _last_good.get("*")
if _recovered:
logger.warning(
"Empty model resolved for session=%s — recovering "
"last-known-good model %s (config read likely returned "
"empty; see #35314)",
resolved_session_key or "", _recovered,
)
model = _recovered
elif model:
# Cache the good resolution for future recovery turns.
if resolved_session_key:
_last_good[resolved_session_key] = model
_last_good["*"] = model
return model, runtime_kwargs
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
@@ -2784,10 +2818,12 @@ class GatewayRunner:
"""Mark a queued platform as paused — keep it in ``_failed_platforms``
but stop the reconnect watcher from hammering it.
Used by the circuit breaker after ``_PAUSE_AFTER_FAILURES`` consecutive
retryable failures, and by ``/platform pause <name>`` for manual
intervention. Paused platforms are surfaced in ``/platform list``
and resumed with ``/platform resume <name>``.
Used by ``/platform pause <name>`` for manual operator intervention.
Paused platforms are surfaced in ``/platform list`` and resumed with
``/platform resume <name>``. Note: the reconnect watcher does NOT
auto-pause retryable (network/DNS) failures keep retrying at the
backoff cap indefinitely so a transient outage self-heals without
manual intervention.
"""
info = getattr(self, "_failed_platforms", {}).get(platform)
if info is None:
@@ -3507,9 +3543,13 @@ class GatewayRunner:
)
continue
# Include thread_id if present so the message lands in the
# correct forum topic / thread.
metadata = {"thread_id": thread_id} if thread_id else None
metadata = self._thread_metadata_for_target(
platform,
chat_id,
thread_id,
chat_type=getattr(source, "chat_type", None) if source is not None else None,
adapter=adapter,
)
result = await adapter.send(chat_id, msg, metadata=metadata)
if result is not None and getattr(result, "success", True) is False:
@@ -3555,7 +3595,12 @@ class GatewayRunner:
continue
try:
metadata = {"thread_id": home.thread_id} if home.thread_id else None
metadata = self._thread_metadata_for_target(
platform,
home.chat_id,
home.thread_id,
adapter=adapter,
)
if metadata:
result = await adapter.send(str(home.chat_id), msg, metadata=metadata)
else:
@@ -4429,10 +4474,19 @@ class GatewayRunner:
# Drain any recovered process watchers (from crash recovery checkpoint)
try:
from tools.process_registry import process_registry
while process_registry.pending_watchers:
watcher = process_registry.pending_watchers.pop(0)
# Detach the current batch atomically: reassigning to a fresh list
# takes ownership of exactly the watchers present now, so any watcher
# appended concurrently during the yield below isn't silently dropped
# by a clear() on the shared list.
watchers = process_registry.pending_watchers
process_registry.pending_watchers = []
# Process in batches of 100 with event-loop yield points to avoid
# O(n^2) event-loop blocking when recovering thousands of watchers.
for i, watcher in enumerate(watchers):
asyncio.create_task(self._run_process_watcher(watcher))
logger.info("Resumed watcher for recovered process %s", watcher.get("session_id"))
if i % 100 == 99:
await asyncio.sleep(0)
except Exception as e:
logger.error("Recovered watcher setup error: %s", e)
@@ -5865,15 +5919,17 @@ class GatewayRunner:
"""Background task that periodically retries connecting failed platforms.
Uses exponential backoff: 30s 60s 120s 240s 300s (cap).
Retryable failures keep retrying at the backoff cap indefinitely
but if a platform fails ``_PAUSE_AFTER_FAILURES`` times in a row
without ever succeeding, it is *paused*: kept in the retry queue
but no longer hammered. The user surfaces it with ``/platform list``
and resumes it with ``/platform resume <name>``. Non-retryable
failures (bad auth, etc.) still drop out of the queue immediately.
Retryable failures (network/DNS blips) keep retrying at the backoff
cap indefinitely they self-heal once connectivity returns, so a
transient outage never requires manual intervention. Non-retryable
failures (bad auth, etc.) drop out of the queue immediately. The
circuit breaker (``_pause_failed_platform`` / ``/platform pause``)
remains available for manual operator control via ``/platform list``
and ``/platform resume <name>``, but is no longer triggered
automatically auto-pausing a recovered platform was the cause of
bots silently staying dead after a transient DNS failure.
"""
_BACKOFF_CAP = 300 # 5 minutes max between retries
_PAUSE_AFTER_FAILURES = 10 # circuit-breaker threshold
await asyncio.sleep(10) # initial delay — let startup finish
while self._running:
@@ -5968,14 +6024,14 @@ class GatewayRunner:
"Reconnect %s failed, next retry in %ds",
platform.value, backoff,
)
if attempt >= _PAUSE_AFTER_FAILURES:
self._pause_failed_platform(
platform,
reason=(
adapter.fatal_error_message
or "failed to reconnect"
),
)
# Retryable failures (network/DNS blips) keep retrying
# at the backoff cap indefinitely — they self-heal once
# connectivity returns. We do NOT auto-pause them: a
# transient outage must never require manual `/platform
# resume` to recover. Non-retryable failures (bad auth,
# etc.) already drop out of the queue via the
# `not fatal_error_retryable` branch above, so anything
# reaching here is by definition retryable.
except Exception as e:
self._update_platform_runtime_status(
platform.value,
@@ -5990,8 +6046,9 @@ class GatewayRunner:
"Reconnect %s error: %s, next retry in %ds",
platform.value, e, backoff,
)
if attempt >= _PAUSE_AFTER_FAILURES:
self._pause_failed_platform(platform, reason=str(e))
# A raised exception during reconnect (connect timeout, DNS
# resolution failure, etc.) is inherently transient — keep
# retrying at the backoff cap rather than auto-pausing.
# Check every 10 seconds for platforms that need reconnection
for _ in range(10):
@@ -6365,6 +6422,29 @@ class GatewayRunner:
# plugin adapters don't need a custom factory signature.
if hasattr(adapter, "gateway_runner"):
adapter.gateway_runner = self
# ── Telegram: notification mode from config ──
# Applied here (not in the adapter factory) because it
# reads gateway-local config that only the gateway runner
# has access to.
if platform.value == "telegram":
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
# Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms).
@@ -6378,49 +6458,13 @@ class GatewayRunner:
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
# Fall through to built-in adapters below
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
logger.warning("Telegram: python-telegram-bot not installed")
return None
adapter = TelegramAdapter(config)
# Apply Telegram notification mode from config. Controls whether
# intermediate messages (tool progress, streaming, status) trigger
# push notifications. Supports ENV override for quick testing.
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
elif platform == Platform.WHATSAPP:
if platform == Platform.WHATSAPP:
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
if not check_whatsapp_requirements():
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
return None
return WhatsAppAdapter(config)
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
return None
return SlackAdapter(config)
elif platform == Platform.SIGNAL:
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
if not check_signal_requirements():
@@ -6449,20 +6493,6 @@ class GatewayRunner:
return None
return SmsAdapter(config)
elif platform == Platform.DINGTALK:
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
if not check_dingtalk_requirements():
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
return None
return DingTalkAdapter(config)
elif platform == Platform.FEISHU:
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
if not check_feishu_requirements():
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM_CALLBACK:
from gateway.platforms.wecom_callback import (
WecomCallbackAdapter,
@@ -6487,13 +6517,6 @@ class GatewayRunner:
return None
return WeixinAdapter(config)
elif platform == Platform.MATRIX:
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
if not check_matrix_requirements():
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
return None
return MatrixAdapter(config)
elif platform == Platform.API_SERVER:
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
if not check_api_server_requirements():
@@ -7899,7 +7922,7 @@ class GatewayRunner:
result = await result
return str(result) if result else None
except Exception as e:
logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
logger.warning("Plugin command dispatch failed: %s", e)
# Skill slash commands: /skill-name loads the skill and sends to agent.
# resolve_skill_command_key() handles the Telegram underscore/hyphen
@@ -7931,7 +7954,7 @@ class GatewayRunner:
)
# Fall through to normal message processing with bundle content
except Exception as exc:
logger.debug("Bundle dispatch failed (non-fatal): %s", exc)
logger.warning("Bundle dispatch failed: %s", exc)
if command and not locals().get("_bundle_handled", False):
try:
@@ -9122,9 +9145,15 @@ class GatewayRunner:
# Check for pending process watchers (check_interval on background processes)
try:
from tools.process_registry import process_registry
while process_registry.pending_watchers:
watcher = process_registry.pending_watchers.pop(0)
# Detach the current batch atomically (see crash-recovery drain
# above): reassign to a fresh list so a watcher appended by a
# concurrent session during the yield isn't dropped by clear().
watchers = process_registry.pending_watchers
process_registry.pending_watchers = []
for i, watcher in enumerate(watchers):
asyncio.create_task(self._run_process_watcher(watcher))
if i % 100 == 99:
await asyncio.sleep(0)
except Exception as e:
logger.error("Process watcher setup error: %s", e)
@@ -10070,6 +10099,45 @@ class GatewayRunner:
return "\n".join(lines)
def _sibling_thread_run_keys(self, source: SessionSource, own_key: str) -> list:
"""Find running-agent keys for OTHER participants in the same thread.
Only applies when the message originates in a thread. In per-user
thread mode (``thread_sessions_per_user=True``) each participant gets
an isolated session key of the form
``agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}:{user_id}``,
so a run started by another user is invisible to the caller's own
``/stop``. This returns the keys of any *actually running* agents
(not the pending sentinel, not the caller's own key) whose key shares
the caller's ``{chat_id}:{thread_id}`` prefix.
Returns an empty list when the source is not in a thread, or when no
sibling runs exist callers must still gate on authorization.
"""
thread_id = getattr(source, "thread_id", None)
chat_id = getattr(source, "chat_id", None)
if not thread_id or not chat_id:
return []
platform = source.platform.value
chat_type = getattr(source, "chat_type", None) or ""
# Prefix that every per-user key in this thread shares, up to and
# including the thread_id segment. Matching either the exact
# shared-thread key or any key with a further (user_id) segment
# (prefix + ":") avoids cross-matching an unrelated thread whose id
# merely starts with this one.
prefix = ":".join(
["agent:main", platform, chat_type, str(chat_id), str(thread_id)]
)
matches = []
for key, agent in list(self._running_agents.items()):
if key == own_key:
continue
if agent is _AGENT_PENDING_SENTINEL or not agent:
continue
if key == prefix or key.startswith(prefix + ":"):
matches.append(key)
return matches
async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /stop command - interrupt a running agent.
@@ -10106,8 +10174,31 @@ class GatewayRunner:
invalidation_reason="stop_command_handler",
)
return EphemeralReply(t("gateway.stop.stopped"))
else:
return t("gateway.stop.no_active")
# No run under the caller's own session key. In a per-user thread
# (thread_sessions_per_user=True) each participant is isolated even
# inside one shared thread, so a run another user started lives under
# a different key. Authorized users should still be able to /stop it
# (#bernard-thread-stop). Fall back to interrupting any running
# agent(s) that share this thread, gated on authorization.
sibling_keys = self._sibling_thread_run_keys(source, session_key)
if sibling_keys and self._is_user_authorized(source):
for sibling_key in sibling_keys:
await self._interrupt_and_clear_session(
sibling_key,
source,
interrupt_reason=_INTERRUPT_REASON_STOP,
invalidation_reason="stop_command_thread_sibling",
)
logger.info(
"STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s",
session_key,
len(sibling_keys),
", ".join(sibling_keys),
)
return EphemeralReply(t("gateway.stop.stopped"))
return t("gateway.stop.no_active")
async def _handle_platform_command(self, event: MessageEvent) -> str:
"""Handle ``/platform list|pause|resume [name]`` — surface and
@@ -10236,6 +10327,7 @@ class GatewayRunner:
notify_data = {
"platform": event.source.platform.value if event.source.platform else None,
"chat_id": event.source.chat_id,
"chat_type": event.source.chat_type,
}
if event.source.thread_id:
notify_data["thread_id"] = event.source.thread_id
@@ -10549,6 +10641,22 @@ class GatewayRunner:
except Exception as exc:
logger.warning("Picker model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the
# dashboard shows the updated model (#34850).
_sess_db = getattr(_self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = _self.session_store.get_or_create_session(
event.source
)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store model note + session override
if not hasattr(_self, "_pending_model_notes"):
_self._pending_model_notes = {}
@@ -10686,6 +10794,20 @@ class GatewayRunner:
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
@@ -11650,7 +11772,12 @@ class GatewayRunner:
audio_path = None
actual_path = None
try:
from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
from agent.plugin_registries import registries
_tts_entry = registries.get_tool_provider("tts")
if _tts_entry is None:
return
text_to_speech_tool = _tts_entry.tool_functions["text_to_speech_tool"]
_strip_markdown_for_tts = _tts_entry.tool_functions["_strip_markdown_for_tts"]
tts_text = _strip_markdown_for_tts(text[:4000])
if not tts_text:
@@ -11743,9 +11870,16 @@ class GatewayRunner:
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
media_files, _ = adapter.extract_media(response)
media_files, cleaned = adapter.extract_media(response)
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
_, cleaned = adapter.extract_images(response)
# Chain the cleaned text through each extractor (extract_media →
# extract_images → extract_local_files) so MEDIA: tags and image URLs
# are removed before the bare-path auto-detect runs. Previously the
# cleaned text from extract_media was dropped (``_``) and
# extract_local_files scanned text that still contained MEDIA: tags,
# producing false-positive bare-path matches with the MEDIA: prefix
# glued on. This matches the chain order in gateway/platforms/base.py.
_, cleaned = adapter.extract_images(cleaned)
local_files, _ = adapter.extract_local_files(cleaned)
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
@@ -12442,6 +12576,12 @@ class GatewayRunner:
Accepts an optional focus topic: ``/compress <focus>`` guides the
summariser to preserve information related to *focus* while being
more aggressive about discarding everything else.
Also accepts the boundary-aware form ``/compress here [N]``:
summarize everything except the most recent ``N`` exchanges
(default 2), kept verbatim. Inspired by Claude Code's Rewind
"Summarize up to here" action (v2.1.139, May 2026,
https://code.claude.com/docs/en/whats-new/2026-w20).
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
@@ -12450,8 +12590,15 @@ class GatewayRunner:
if not history or len(history) < 4:
return t("gateway.compress.not_enough")
# Extract optional focus topic from command args
focus_topic = (event.get_command_args() or "").strip() or None
# Parse args: either a focus topic (full compress) or the
# boundary-aware "here [N]" form (partial compress).
from hermes_cli.partial_compress import (
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
_raw_args = (event.get_command_args() or "").strip()
partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args)
try:
from run_agent import AIAgent
@@ -12472,6 +12619,19 @@ class GatewayRunner:
if m.get("role") in {"user", "assistant"} and m.get("content")
]
# Boundary-aware split: only the head is summarized; the most
# recent `keep_last` exchanges are preserved verbatim. The
# split snaps the tail to a user-turn start so the rejoined
# transcript keeps role alternation valid.
tail: list = []
head = msgs
if partial:
head, tail = split_history_for_partial_compress(msgs, keep_last)
if not tail:
# Degenerate split — fall back to full compression.
partial = False
head = msgs
tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
@@ -12495,15 +12655,20 @@ class GatewayRunner:
)
compressor = tmp_agent.context_compressor
if not compressor.has_content_to_compress(msgs):
if not compressor.has_content_to_compress(head):
return t("gateway.compress.nothing_to_do")
loop = asyncio.get_running_loop()
compressed, _ = await loop.run_in_executor(
None,
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
)
# Re-append the verbatim tail after the compressed head,
# guarding the seam against illegal role adjacency.
if partial and tail:
compressed = rejoin_compressed_head_and_tail(compressed, tail)
# _compress_context already calls end_session() on the old session
# (preserving its full transcript in SQLite) and creates a new
# session_id for the continuation. Write the compressed messages
@@ -14013,13 +14178,34 @@ class GatewayRunner:
reply_to_message_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Build the metadata dict platforms need for thread-aware replies."""
thread_id = getattr(source, "thread_id", None)
return self._thread_metadata_for_target(
getattr(source, "platform", None),
getattr(source, "chat_id", None),
getattr(source, "thread_id", None),
chat_type=getattr(source, "chat_type", None),
reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None),
)
def _thread_metadata_for_target(
self,
platform: Optional[Platform],
chat_id: Optional[str],
thread_id: Optional[str],
*,
chat_type: Optional[str] = None,
reply_to_message_id: Optional[str] = None,
adapter: Optional[Any] = None,
) -> Optional[Dict[str, Any]]:
"""Build thread metadata for synthetic sends that only have routing state."""
if thread_id is None:
return None
metadata: Dict[str, Any] = {"thread_id": thread_id}
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
if self._is_telegram_dm_topic_target(
platform,
chat_id,
thread_id,
chat_type=chat_type,
adapter=adapter,
):
metadata["telegram_dm_topic_reply_fallback"] = True
# Telegram DM topic lanes need direct_messages_topic_id in metadata
@@ -14028,11 +14214,42 @@ class GatewayRunner:
tid = str(thread_id)
if tid and tid not in {"", "1"}:
metadata["direct_messages_topic_id"] = tid
anchor = reply_to_message_id or getattr(source, "message_id", None)
if anchor is not None:
metadata["telegram_reply_to_message_id"] = str(anchor)
if reply_to_message_id is not None:
metadata["telegram_reply_to_message_id"] = str(reply_to_message_id)
return metadata
@staticmethod
def _is_telegram_dm_topic_target(
platform: Optional[Platform],
chat_id: Optional[str],
thread_id: Optional[str],
*,
chat_type: Optional[str] = None,
adapter: Optional[Any] = None,
) -> bool:
"""Return True when a target is a Telegram private DM topic lane."""
if platform != Platform.TELEGRAM or thread_id is None:
return False
if chat_type == "dm":
return True
# Inspect operator-declared DM topics via the adapter's lookup. Resolve
# the method on the CLASS, not the instance: getattr() on a MagicMock
# auto-creates a callable child for any attribute, so an instance-level
# lookup would report a DM topic for every test double. Only a
# dict-shaped return counts as an operator-declared topic — a bare
# MagicMock or other sentinel must not. Mirrors the guard in
# _rename_telegram_topic_for_session_title.
if adapter is not None and chat_id:
get_dm_topic_info = getattr(type(adapter), "_get_dm_topic_info", None)
if callable(get_dm_topic_info):
try:
topic_info = get_dm_topic_info(adapter, str(chat_id), str(thread_id))
except Exception:
logger.debug("Failed to inspect Telegram DM topic metadata", exc_info=True)
else:
return isinstance(topic_info, dict)
return False
@staticmethod
def _reply_anchor_for_event(event: MessageEvent) -> Optional[str]:
"""Return the platform-specific reply anchor for GatewayRunner sends."""
@@ -14241,6 +14458,7 @@ class GatewayRunner:
pending = {
"platform": event.source.platform.value,
"chat_id": event.source.chat_id,
"chat_type": event.source.chat_type,
"user_id": event.source.user_id,
"session_key": session_key,
"timestamp": datetime.now().isoformat(),
@@ -14391,12 +14609,19 @@ class GatewayRunner:
pending = json.loads(path.read_text())
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
session_key = pending.get("session_key")
thread_id = pending.get("thread_id")
metadata = {"thread_id": thread_id} if thread_id else None
if platform_str and chat_id:
platform = Platform(platform_str)
adapter = self.adapters.get(platform)
metadata = self._thread_metadata_for_target(
platform,
chat_id,
thread_id,
chat_type=chat_type,
adapter=adapter,
)
# Fallback session key if not stored (old pending files)
if not session_key:
session_key = f"{platform_str}:{chat_id}"
@@ -14600,6 +14825,7 @@ class GatewayRunner:
pending = json.loads(claimed_path.read_text())
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
chat_type = pending.get("chat_type")
thread_id = pending.get("thread_id")
if not exit_code_path.exists():
@@ -14622,7 +14848,13 @@ class GatewayRunner:
adapter = self.adapters.get(platform)
if adapter and chat_id:
metadata = {"thread_id": thread_id} if thread_id else None
metadata = self._thread_metadata_for_target(
platform,
chat_id,
thread_id,
chat_type=chat_type,
adapter=adapter,
)
# Strip ANSI escape codes for clean display
output = re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
if output:
@@ -14664,6 +14896,7 @@ class GatewayRunner:
data = json.loads(notify_path.read_text())
platform_str = data.get("platform")
chat_id = data.get("chat_id")
chat_type = data.get("chat_type")
thread_id = data.get("thread_id")
if not platform_str or not chat_id:
@@ -14686,7 +14919,13 @@ class GatewayRunner:
)
return None
metadata = {"thread_id": thread_id} if thread_id else None
metadata = self._thread_metadata_for_target(
platform,
chat_id,
thread_id,
chat_type=chat_type,
adapter=adapter,
)
result = await adapter.send(
str(chat_id),
"♻ Gateway restarted successfully. Your session continues.",
@@ -14750,7 +14989,12 @@ class GatewayRunner:
continue
try:
metadata = {"thread_id": home.thread_id} if home.thread_id else None
metadata = self._thread_metadata_for_target(
platform,
home.chat_id,
home.thread_id,
adapter=adapter,
)
if metadata:
result = await adapter.send(str(home.chat_id), message, metadata=metadata)
else:
@@ -14941,9 +15185,32 @@ class GatewayRunner:
return f"{prefix}\n\n{user_text}"
return prefix
from tools.transcription_tools import transcribe_audio
from agent.plugin_registries import registries
_stt_entry = registries.get_tool_provider("stt")
enriched_parts = []
if _stt_entry is None or "transcribe_audio" not in _stt_entry.tool_functions:
# No STT plugin registered — treat each audio path the same way
# as a "No STT provider" transcription failure.
for path in audio_paths:
abs_path = os.path.abspath(path)
duration_str = await _probe_audio_duration(abs_path)
if duration_str:
enriched_parts.append(
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
)
else:
enriched_parts.append(f"[The user sent a voice message: {abs_path}]")
if not enriched_parts:
return user_text
prefix = "\n\n".join(enriched_parts)
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
if user_text:
return f"{prefix}\n\n{user_text}"
return prefix
transcribe_audio = _stt_entry.tool_functions["transcribe_audio"]
for path in audio_paths:
try:
logger.debug("Transcribing user voice: %s", path)
@@ -15293,8 +15560,52 @@ class GatewayRunner:
("compression", "target_ratio"),
("compression", "protect_last_n"),
("agent", "disabled_toolsets"),
("memory", "provider"),
)
_HONCHO_CACHE_BUSTING_KEYS = (
"honcho.peer_name",
"honcho.ai_peer",
"honcho.pin_peer_name",
"honcho.runtime_peer_prefix",
"honcho.user_peer_aliases",
)
_HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {}
@classmethod
def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]:
return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS}
@classmethod
def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]:
"""Extract Honcho identity keys, memoized by honcho.json mtime."""
try:
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
path = resolve_config_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = None
memo_key = (str(path), mtime_ns)
cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key)
if cached is not None:
return dict(cached)
hcfg = HonchoClientConfig.from_global_config(config_path=path)
aliases = hcfg.user_peer_aliases or {}
values = {
"honcho.peer_name": hcfg.peer_name,
"honcho.ai_peer": hcfg.ai_peer,
"honcho.pin_peer_name": bool(hcfg.pin_peer_name),
"honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "",
"honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [],
}
cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values}
return dict(values)
except Exception:
return cls._empty_honcho_cache_busting_config()
@classmethod
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
"""Pull values that must bust the cached agent.
@@ -15325,26 +15636,12 @@ class GatewayRunner:
out["tools.registry_generation"] = None
# Honcho identity-mapping keys live in honcho.json, not user_config.
# HonchoSessionManager freezes the resolved peer_name / ai_peer /
# pin / aliases / prefix at construction; without busting here,
# mid-flight honcho.json edits go unread until the next unrelated
# cache eviction.
try:
from plugins.memory.honcho.client import HonchoClientConfig
hcfg = HonchoClientConfig.from_global_config()
out["honcho.peer_name"] = hcfg.peer_name
out["honcho.ai_peer"] = hcfg.ai_peer
out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name)
out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or ""
aliases = hcfg.user_peer_aliases or {}
out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else []
except Exception:
out["honcho.peer_name"] = None
out["honcho.ai_peer"] = None
out["honcho.pin_peer_name"] = None
out["honcho.runtime_peer_prefix"] = None
out["honcho.user_peer_aliases"] = None
# Only read that file when Honcho is the active memory provider.
provider = cfg_get(cfg, "memory", "provider")
if isinstance(provider, str) and provider.lower() == "honcho":
out.update(cls._extract_honcho_cache_busting_config())
else:
out.update(cls._empty_honcho_cache_busting_config())
return out
@@ -17183,7 +17480,7 @@ class GatewayRunner:
_hc = _hm.get("content", "")
if "MEDIA:" in _hc:
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
@@ -17478,18 +17775,38 @@ class GatewayRunner:
# append any that aren't already present in the final response, so the
# adapter's extract_media() can find and deliver the files exactly once.
#
# Uses path-based deduplication against _history_media_paths (collected
# before run_conversation) instead of index slicing. This is safe even
# when context compression shrinks the message list. (Fixes #160)
# Scope the scan to THIS turn's tool results only. ``agent_history``
# was passed into run_conversation as ``conversation_history``, so the
# agent's returned ``messages`` list is ``agent_history`` followed by
# the messages produced this turn. Slicing at ``len(agent_history)``
# isolates the current turn precisely, so a stale MEDIA: path emitted
# by a tool several turns earlier (still present in the full message
# list) can never leak onto a later text-only reply. (Fixes #34608)
#
# Path-based deduplication against _history_media_paths (collected
# before run_conversation) is retained as a secondary guard. It is
# also the sole guard on the fallback branch taken when mid-run
# context compression shrinks the message list below the original
# history length, preserving the compression-safe behaviour of #160.
if "MEDIA:" not in final_response:
media_tags = []
has_voice_directive = False
for msg in result.get("messages", []):
_all_msgs = result.get("messages", [])
_history_len = len(agent_history)
# Only trust the slice boundary when the message list still
# contains the full history prefix. Mid-run compression can
# rewrite/shrink the list; in that case fall back to scanning
# everything and rely on _history_media_paths for dedup.
if _history_len and len(_all_msgs) >= _history_len:
_scan_msgs = _all_msgs[_history_len:]
else:
_scan_msgs = _all_msgs
for msg in _scan_msgs:
if msg.get("role") in {"tool", "function"}:
content = msg.get("content", "")
if "MEDIA:" in content:
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
@@ -18442,7 +18759,10 @@ def _run_planned_stop_watcher(
poll_interval: seconds between marker checks. 0.5s gives a
responsive shutdown without burning CPU.
"""
from gateway.status import _get_planned_stop_marker_path
from gateway.status import (
_get_planned_stop_marker_path,
planned_stop_marker_targets_self,
)
marker_path = _get_planned_stop_marker_path()
while not stop_event.is_set():
try:
@@ -18451,6 +18771,26 @@ def _run_planned_stop_watcher(
and not getattr(runner, "_draining", False)
and getattr(runner, "_running", False)
):
# A marker existing is NOT sufficient — it may have been
# written for a PREVIOUS gateway instance (different PID)
# and left behind because that process exited before the
# CLI's stop() could clean it up. Firing the handler on a
# stale/foreign marker drives the gateway into shutdown,
# then consume_planned_stop_marker_for_self() correctly
# reports a PID mismatch — but by then we're already
# stopping, so it's logged as an unexpected "UNKNOWN" exit
# and the watchdog crash-loops the gateway (issue #34597,
# a regression from PR #33798 which added this watcher
# without the PID check).
#
# Only fire when the marker actually targets us. The probe
# is non-destructive on a match (the handler does the
# authoritative consume on the loop thread) and self-heals
# by unlinking stale/malformed markers so they cannot wedge
# a freshly booted gateway.
if not planned_stop_marker_targets_self():
stop_event.wait(poll_interval)
continue
# Drive the same path as a real signal handler.
# Pass signal=None — the handler tolerates that and consumes
# the marker via consume_planned_stop_marker_for_self,
+80 -6
View File
@@ -816,12 +816,24 @@ def _consume_pid_marker_for_self(
our_pid = os.getpid()
our_start_time = _get_process_start_time(our_pid)
matches = (
target_pid == our_pid
and target_start_time is not None
and our_start_time is not None
and target_start_time == our_start_time
)
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform the planned-stop watcher exists for). Requiring a non-None
# match there would make every consume return False, so a legitimate
# ``hermes gateway stop`` on Windows would be misclassified as an
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
# manager. So: when both start_times are known they must match; when
# either is unknown, fall back to PID equality alone (bounded by the
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
# so the watcher's non-destructive probe and this authoritative
# consume agree on every platform (issue #34597).
if target_pid != our_pid:
matches = False
elif target_start_time is not None and our_start_time is not None:
matches = target_start_time == our_start_time
else:
matches = True
try:
path.unlink(missing_ok=True)
@@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool:
)
def planned_stop_marker_targets_self() -> bool:
"""Return True only when a live planned-stop marker names the current process.
This is a **non-destructive** probe used by the watcher thread
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
it never unlinks a marker that matches us the shutdown handler does
the authoritative consume on its own thread.
It *does* clean up markers that can never apply to this process:
malformed markers and markers older than the TTL are unlinked so a
stale file left behind by a previous gateway instance cannot wedge
the new one. Markers naming a different PID/start_time are left in
place (they may still be consumed legitimately by the process they
name) but report False here.
Returns False (without raising) on any read/parse error.
"""
path = _get_planned_stop_marker_path()
record = _read_json_file(path)
if not record:
return False
try:
target_pid = int(record["target_pid"])
target_start_time = record.get("target_start_time")
written_at = record.get("written_at") or ""
except (KeyError, TypeError, ValueError):
# Malformed marker can never match anyone — drop it.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
# A marker this old is past its useful life regardless of target —
# clean it up so it cannot crash-loop a freshly booted gateway.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
our_pid = os.getpid()
if target_pid != our_pid:
return False
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform this watcher exists for). Requiring a non-None match there
# would make the watcher never fire and re-break the #33778 Windows
# session-resume path. So: when both start_times are known they must
# match; when either is unknown, fall back to PID equality alone
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
our_start_time = _get_process_start_time(our_pid)
if target_start_time is not None and our_start_time is not None:
return target_start_time == our_start_time
return True
def clear_planned_stop_marker() -> None:
"""Remove the planned-stop marker unconditionally."""
try:
+8 -4
View File
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
from gateway.platforms.base import _custom_unit_to_cp
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
from gateway.config import (
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
@@ -645,10 +646,13 @@ class GatewayStreamConsumer:
except Exception as e:
logger.error("Stream consumer error: %s", e)
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
# Matches the simple cleanup regex used by the non-streaming path in
# gateway/platforms/base.py for post-processing.
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
# Strip MEDIA:<path> tags before display. Uses the shared anchored
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
# path ends in a deliverable extension are removed, so an unknown-extension
# path stays visible instead of being silently dropped (issue #34517).
# Streaming and non-streaming paths share the same regex, so a tag is
# treated identically whichever path delivered the text.
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
@staticmethod
def _clean_for_display(text: str) -> str:
+50 -40
View File
@@ -1580,8 +1580,10 @@ def resolve_provider(
# AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
# This runs after API-key providers so explicit keys always win.
try:
from agent.bedrock_adapter import has_aws_credentials
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials and has_aws_credentials():
return "bedrock"
except ImportError:
pass # boto3 not installed — skip Bedrock auto-detection
@@ -5730,8 +5732,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
# AWS SDK providers (Bedrock) — check via boto3 credential chain
if pconfig and pconfig.auth_type == "aws_sdk":
try:
from agent.bedrock_adapter import has_aws_credentials
return {"logged_in": has_aws_credentials(), "provider": target}
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return {"logged_in": has_aws_credentials(), "provider": target}
else:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
except ImportError:
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
return {"logged_in": False}
@@ -5770,11 +5777,13 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
if auth_mode == "entra_id":
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
installed = has_azure_identity_installed()
entra_cfg = {}
if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict):
@@ -6126,55 +6135,56 @@ def _prompt_model_selection(
_DIM = "\033[2m"
_RESET = "\033[0m"
# Try arrow-key menu first, fall back to number input
# Try arrow-key menu first, fall back to number input.
# Uses the shared curses radiolist (ESC/arrow-key handling that works
# across terminals, incl. those that emit raw escape sequences) instead
# of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow
# keys unreliable in the setup model picker.
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
choices = [f" {_label(mid)}" for mid in ordered]
choices.append(" Enter custom model name")
choices.append(" Skip (keep current)")
choices = [_label(mid) for mid in ordered]
choices.append("Enter custom model name")
choices.append("Skip (keep current)")
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
unavailable_footer = unavailable_message.strip()
if not unavailable_footer and _unavailable:
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
# Print the unavailable block BEFORE the menu via regular print().
# simple_term_menu pads title lines to terminal width (causes wrapping),
# so we keep the title minimal and use stdout for the static block.
# clear_screen=False means our printed output stays visible above.
# The pricing column header (and any unavailable-models block) is shown
# as a multi-line description above the list so it survives the curses
# screen clear. menu_title already embeds the aligned price header.
desc_lines: list[str] = []
if has_pricing:
# menu_title is "Select default model:\n<pad><header> /Mtok"
# Keep only the header portion for the description.
header_part = menu_title.split("\n", 1)
if len(header_part) > 1:
desc_lines.extend(header_part[1].splitlines())
if _unavailable:
print(menu_title)
print()
for mid in _unavailable:
print(f"{_DIM} {_label(mid)}{_RESET}")
print()
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
print()
effective_title = "Available free models:"
else:
effective_title = menu_title
desc_lines.append(f" {_label(mid)}")
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
menu = TerminalMenu(
idx = curses_radiolist(
"Select default model:",
choices,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title=effective_title,
selected=default_idx,
cancel_returns=-1,
description=description,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
if idx is None:
if idx < 0:
return None
print()
if idx < len(ordered):
return ordered[idx]
elif idx == len(ordered):
custom = input("Enter model name: ").strip()
try:
custom = input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return custom if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
+18 -11
View File
@@ -221,9 +221,12 @@ def auth_add_command(args) -> None:
return
if provider == "anthropic":
from agent import anthropic_adapter as anthropic_mod
creds = anthropic_mod.run_hermes_oauth_login_pure()
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
run_hermes_oauth_login_pure = _anthropic_ns.get("run_hermes_oauth_login_pure")
if not run_hermes_oauth_login_pure:
raise SystemExit("Anthropic plugin not loaded — cannot run OAuth login.")
creds = run_hermes_oauth_login_pure()
if not creds:
raise SystemExit("Anthropic OAuth login did not return credentials.")
label = (getattr(args, "label", None) or "").strip() or label_from_token(
@@ -545,8 +548,12 @@ def _interactive_auth() -> None:
# Show AWS Bedrock credential status (not in the pool — uses boto3 chain)
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if has_aws_credentials():
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
if has_aws_credentials and has_aws_credentials():
auth_source = resolve_aws_auth_env_var() or "unknown"
region = resolve_bedrock_region()
print(f"bedrock (AWS SDK credential chain):")
@@ -573,12 +580,12 @@ def _interactive_auth() -> None:
_cfg_provider = str(_model_cfg.get("provider") or "").strip().lower()
_cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower()
if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id":
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
_base_url = str(_model_cfg.get("base_url") or "").strip()
_entra = _model_cfg.get("entra") or {}
if not isinstance(_entra, dict):
+99
View File
@@ -670,6 +670,105 @@ def restore_quick_snapshot(
return restored > 0
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
_CRON_JOBS_REL = "cron/jobs.json"
def _count_cron_jobs(path: Path) -> Optional[int]:
"""Return the number of cron jobs stored in ``path``.
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
A legacy bare-list shape (``[...]``) is also honoured.
Returns:
The job count for any *valid, readable* JSON document, or ``None`` if
the file is missing or cannot be parsed. ``None`` means "unknown"
callers must not treat it as "zero jobs", because acting on an
unreadable file could mask a real corruption the user needs to see.
"""
if not path.is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if isinstance(data, dict):
jobs = data.get("jobs", [])
return len(jobs) if isinstance(jobs, list) else None
if isinstance(data, list):
return len(data)
return None
def restore_cron_jobs_if_emptied(
snapshot_id: str,
hermes_home: Optional[Path] = None,
) -> Optional[Dict[str, Any]]:
"""Safety net for silent cron-job loss across ``hermes update``.
Config-version migrations have been observed to leave ``cron/jobs.json``
valid-but-empty after an update, silently dropping every scheduled job
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
This compares the *current* job count against the pre-update snapshot. If
the live file now has **zero** jobs while the snapshot captured **one or
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
The check is deliberately conservative it only ever restores when there
is unambiguous evidence of loss (snapshot had jobs, live file has none),
so a user who genuinely deleted all their jobs during/after the update is
never second-guessed, and an unreadable live file (count ``None``) is left
untouched so real corruption still surfaces.
Args:
snapshot_id: The pre-update quick-snapshot id (from
:func:`create_quick_snapshot`).
hermes_home: Override for the Hermes home directory (tests).
Returns:
``None`` when no action was taken (the common, healthy path). On a
successful restore, a dict ``{"restored": True, "job_count": N,
"snapshot_id": ...}`` so the caller can warn the user.
"""
if not snapshot_id:
return None
home = hermes_home or get_hermes_home()
live_path = home / _CRON_JOBS_REL
live_count = _count_cron_jobs(live_path)
# Only act when the live file is readable AND empty. ``None`` (missing or
# unparseable) is intentionally left alone — that's a different failure
# mode the user should see rather than have papered over.
if live_count is None or live_count > 0:
return None
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
snap_count = _count_cron_jobs(snap_path)
if not snap_count: # None or 0 — nothing worth restoring
return None
try:
live_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(snap_path, live_path)
except (OSError, PermissionError) as exc:
logger.error(
"Cron jobs were emptied during update but auto-restore failed: %s", exc
)
return None
logger.warning(
"Restored %d cron job(s) from pre-update snapshot %s "
"(cron/jobs.json was emptied during migration)",
snap_count,
snapshot_id,
)
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
if not root.exists():
+38 -10
View File
@@ -12,14 +12,16 @@ import threading
import time
from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List, Optional
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
# rich and prompt_toolkit are imported lazily (inside the functions that use
# them) rather than at module level. Importing this module is on the TUI
# gateway's critical startup path purely to reach the lightweight update-check
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
# Keep the type-only reference available to checkers without the runtime cost.
if TYPE_CHECKING:
from rich.console import Console
logger = logging.getLogger(__name__)
@@ -36,6 +38,8 @@ _RST = "\033[0m"
def cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's renderer."""
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
_pt_print(_PT_ANSI(text))
@@ -221,7 +225,11 @@ def check_for_updates() -> Optional[int]:
cache_file = hermes_home / ".update_check"
embedded_rev = os.environ.get("HERMES_REVISION") or None
# Read cache — invalidate if the embedded rev has changed since last check
# Read cache — invalidate if the embedded rev OR installed version has
# changed since the last check. The version guard matters for pip installs:
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
# changes VERSION but leaves rev unchanged (both None), and without this
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
now = time.time()
try:
if cache_file.exists():
@@ -229,6 +237,7 @@ def check_for_updates() -> Optional[int]:
if (
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
and cached.get("rev") == embedded_rev
and cached.get("ver") == VERSION
):
return cached.get("behind")
except Exception:
@@ -249,7 +258,9 @@ def check_for_updates() -> Optional[int]:
behind = _check_via_local_git(repo_dir)
try:
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
cache_file.write_text(
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
)
except Exception:
pass
@@ -464,7 +475,7 @@ def _display_toolset_name(toolset_name: str) -> str:
)
def build_welcome_banner(console: Console, model: str, cwd: str,
def build_welcome_banner(console: "Console", model: str, cwd: str,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
session_id: str = None,
@@ -483,6 +494,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
context_length: Model's context window size in tokens.
"""
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
from rich.panel import Panel
from rich.table import Table
if get_toolset_for_tool is None:
from model_tools import get_toolset_for_tool
@@ -691,6 +704,21 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
except Exception:
pass # Never break the banner over an update check
# Pip-install warning — `pip install hermes-agent` is not the supported
# install path (it exists on PyPI for internal/CI reasons, not end users).
# Such installs miss the git checkout + installer-managed deps, so updates,
# self-update, and issue triage don't behave correctly. Warn, don't block.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "pip":
right_lines.append(
"[bold yellow]⚠ pip install not officially supported[/]"
"[dim yellow] — exists for reasons other than user install; "
"expect instability and an inability to support issues[/]"
)
except Exception:
pass # Never break the banner over the install-method check
right_content = "\n".join(right_lines)
layout_table.add_row(left_content, right_content)
+2 -2
View File
@@ -85,8 +85,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="<platform>", cli_only=True),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session",
args_hint="[focus topic]"),
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
args_hint="[here [N] | focus topic]"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
+77 -10
View File
@@ -285,9 +285,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
Resolution order:
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
4. .git directory presence -> 'git'
5. Fallback -> 'pip'
3. .git directory presence -> 'git'
4. Fallback -> 'pip'
Note: running inside a container is NOT treated as "docker" on its own.
The two supported install paths both self-identify via the
``.install_method`` stamp (caught by step 1), so neither relies on
container detection here:
- the curl installer (scripts/install.sh, the README/website install
command) git-clones the repo and stamps ``git``;
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
at boot via ``docker/stage2-hook.sh``.
An unsupported manual install dropped into a container (no stamp) was
wrongly classified as the published image by bare container detection,
so ``hermes update`` bailed with "doesn't apply inside the Docker
container". Without that fallback such installs fall through to the
``.git``/pip checks and behave like any off-path install. See issue #34397.
"""
stamp = get_hermes_home() / ".install_method"
try:
@@ -299,9 +312,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
from hermes_constants import is_container
if is_container():
return "docker"
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
if (project_root / ".git").is_dir():
@@ -319,6 +329,34 @@ def stamp_install_method(method: str) -> None:
pass
def is_uv_tool_install() -> bool:
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
``uv tool install hermes-agent`` places the install at
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
``uv pip install`` fails with ``No virtual environment found`` and the
update path must use ``uv tool upgrade`` instead.
Detection is intentionally restricted to properties of the running
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
NOT consult ``uv tool list``: it would also return True when
``hermes-agent`` happens to be uv-tool-installed on the machine while
the *active* Hermes is a regular pip/venv install, causing
``hermes update`` to upgrade the wrong copy. It would also block on a
subprocess call (~seconds) just to compute a recommendation string.
"""
def _has_uv_tool_marker(path: str) -> bool:
norm = os.path.normpath(path).replace(os.sep, "/").lower()
return "/uv/tools/hermes-agent/" in norm + "/"
if _has_uv_tool_marker(sys.prefix):
return True
if _has_uv_tool_marker(sys.executable or ""):
return True
return False
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if method == "nixos":
@@ -328,9 +366,10 @@ def recommended_update_command_for_method(method: str) -> str:
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
if method == "pip":
if is_uv_tool_install():
return "uv tool upgrade hermes-agent"
import shutil
uv = shutil.which("uv")
if uv:
if shutil.which("uv"):
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
return "hermes update"
@@ -1183,6 +1222,11 @@ DEFAULT_CONFIG = {
# Mirrors `hermes -c` muscle memory. Default off so existing
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
"tui_auto_resume_recent": False,
# When true (default), `hermes --tui` drops a one-time hint
# ("subagents working · /agents to watch live") the first time a turn
# starts delegating, nudging the user toward the live spawn-tree
# dashboard. Set false to suppress the hint.
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,
@@ -1202,6 +1246,13 @@ DEFAULT_CONFIG = {
# class of over-claim that otherwise forces users to run
# `git status` to verify edits landed. Set false to suppress.
"file_mutation_verifier": True,
# Turn-completion explainer. When true (default), the agent appends a
# one-line explanation to its final response whenever a turn ends
# abnormally with no usable reply — empty content after retries, a
# partial/truncated stream, a still-pending tool result, or an
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
# failure isn't silent from the UI's perspective. Set false to suppress.
"turn_completion_explainer": True,
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
# UI language for static user-facing messages (approval prompts, a
@@ -1852,7 +1903,7 @@ DEFAULT_CONFIG = {
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
# next /model or `hermes model` invocation; network failures
# silently fall back to the stale cache.
"ttl_hours": 24,
"ttl_hours": 1,
# Optional per-provider override URLs for third parties that want
# to self-host their own curation list using the same schema.
# Example:
@@ -2100,7 +2151,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 24,
"_config_version": 25,
}
# =============================================================================
@@ -4293,6 +4344,22 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
f"{', '.join(added_aux)}"
)
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
# The model picker now refreshes its curated list hourly so freshly
# published model-catalog.json deploys reach users without a day-long
# stale window. Only rewrite the OLD default (24) — never clobber a
# value the user deliberately customized.
if current_ver < 25:
config = read_raw_config()
raw_mc = config.get("model_catalog")
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
raw_mc["ttl_hours"] = 1
config["model_catalog"] = raw_mc
save_config(config)
results["config_added"].append("model_catalog.ttl_hours 24→1")
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
+41
View File
@@ -6,6 +6,7 @@ pause/resume/run/remove, status, and tick.
"""
import json
import re
import sys
from pathlib import Path
from typing import Iterable, List, Optional
@@ -15,6 +16,24 @@ sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
# Patterns that indicate a cron job targets the gateway lifecycle.
# Matches commands that restart/stop the gateway or its service manager.
# Deliberately specific — a bare "gateway ... restart" catch-all would block
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
# the API gateway logs and report restart events").
_GATEWAY_LIFECYCLE_PATTERNS = re.compile(
r"(?i)"
r"(hermes\s+gateway\s+(restart|stop|start))"
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
r"|(p?kill\s+.*hermes.*gateway)"
)
def _contains_gateway_lifecycle_command(text: str) -> bool:
"""Return True if *text* contains a gateway lifecycle command pattern."""
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
if skills is None:
@@ -166,6 +185,28 @@ def cron_status():
def cron_create(args):
# Defense: reject cron jobs that contain gateway lifecycle commands.
# Prevents agents from scheduling their own restart/stop, which creates
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
prompt = getattr(args, "prompt", None) or ""
script = getattr(args, "script", None)
combined = prompt
if script:
try:
script_text = Path(script).read_text(encoding="utf-8")
combined = f"{combined}\n{script_text}"
except (OSError, UnicodeDecodeError):
pass
if _contains_gateway_lifecycle_command(combined):
print(color(
"Blocked: cron job contains a gateway lifecycle command "
"(restart/stop/kill).\n"
"This is blocked to prevent restart loops (#30719).\n"
"Use `hermes gateway restart` from a shell outside the gateway.",
Colors.RED,
))
return 1
result = _cron_api(
action="create",
schedule=args.schedule,
+346 -268
View File
@@ -32,6 +32,196 @@ def flush_stdin() -> None:
pass
# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
# every menu's key-handling branch identical and free of raw escape-byte logic.
NAV_UP = "up"
NAV_DOWN = "down"
NAV_SELECT = "select"
NAV_TOGGLE = "toggle"
NAV_CANCEL = "cancel"
NAV_NONE = "none"
def read_menu_key(stdscr) -> str:
"""Read one keypress and normalize it to a menu action.
Decodes raw arrow-key escape sequences in addition to the translated
``curses.KEY_*`` values. Even with ``keypad(True)`` (which
``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor
keys as raw CSI/SS3 byte sequences ``getch()`` then returns ``27`` (ESC)
followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is
what made the setup wizard's provider/model pickers bail to the numbered
fallback the moment a user pressed up/down.
Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte
within a short window) is the only thing that maps to ``NAV_CANCEL`` via
the escape path; ``q`` also cancels. Unknown sequences map to
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
"""
import curses
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
if key in (curses.KEY_DOWN, ord("j")):
return NAV_DOWN
if key in (curses.KEY_ENTER, 10, 13):
return NAV_SELECT
if key == ord(" "):
return NAV_TOGGLE
if key == ord("q"):
return NAV_CANCEL
if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence.
# Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the
# bytes of an arrow key can arrive across separate reads, so a tiny
# timeout avoids misreading a split sequence as a bare ESC.
try:
stdscr.timeout(60)
nxt = stdscr.getch()
finally:
stdscr.timeout(-1) # restore blocking mode
if nxt == -1:
return NAV_CANCEL # genuine lone ESC
if nxt in (ord("["), ord("O")): # CSI / SS3 introducer
final = stdscr.getch()
if final in (ord("A"), ord("k")):
return NAV_UP
if final in (ord("B"), ord("j")):
return NAV_DOWN
# Consume the tail of any other CSI sequence (e.g. ``[3~`` Delete,
# ``[H`` Home) up to its terminator so stray bytes don't leak into
# the next input() and corrupt it.
while 0x20 <= final <= 0x3F: # CSI parameter/intermediate bytes
final = stdscr.getch()
return NAV_NONE
# ESC followed by some other byte we don't handle — swallow it.
return NAV_NONE
return NAV_NONE
# Sentinel: an on_action reducer returns this to mean "keep looping" (the
# keypress changed cursor/selection state but didn't resolve the menu).
_KEEP = object()
def _run_curses_menu(
*,
initial_cursor,
item_count,
draw_header,
draw_row,
on_action,
reserve_bottom=1,
draw_footer=None,
extra_color_pairs=False,
fallback,
cancel_value,
):
"""Shared curses single-/multi-select event loop.
Owns every piece the three public menus used to duplicate verbatim:
the non-TTY guard, ``curses.wrapper`` setup (cursor hide + color pairs),
the per-frame ``clear``/``getmaxyx``/``refresh`` cycle, scroll-offset math,
row iteration, the ``read_menu_key`` dispatch with ``NAV_UP``/``NAV_DOWN``
cursor wrap, ``flush_stdin``, and the ``KeyboardInterrupt`` / curses-
unavailable fallback. Per-menu behavior is supplied as callbacks so the
rendered output stays byte-identical to the old hand-rolled loops.
Callbacks / params:
draw_header(stdscr, max_y, max_x) -> int
Draw the title/hint/description rows. Returns the first screen row
index where the scrollable item list should start.
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
Draw one item row.
on_action(action, cursor) -> value
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
loop; return anything else to resolve the menu with that value.
(UP/DOWN cursor movement is handled by the driver itself.)
reserve_bottom: number of bottom screen rows kept clear of items
(1 = leave the final row blank, matching the old loops).
draw_footer(stdscr, max_y, max_x) -> None
Optional bottom-row painter (e.g. a status bar). Drawn after the
item rows; its row budget must be included in ``reserve_bottom``.
extra_color_pairs: also init pair 3 (dim gray) for status bars.
fallback() -> value
Called when curses errors out on a real TTY (curses unavailable).
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
"""
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
# so return the cancel value directly — matching the pre-refactor guard in
# each menu (the numbered fallback is only for curses errors on a real TTY).
if not sys.stdin.isatty():
return cancel_value
try:
import curses
result_holder = [_KEEP]
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
if extra_color_pairs:
curses.init_pair(
3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1
)
cursor = initial_cursor
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
items_start = draw_header(stdscr, max_y, max_x)
visible_rows = max_y - items_start - reserve_bottom
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
):
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
draw_row(stdscr, y, i, i == cursor, max_x)
if draw_footer is not None:
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
action = read_menu_key(stdscr)
if action == NAV_UP:
cursor = (cursor - 1) % item_count
elif action == NAV_DOWN:
cursor = (cursor + 1) % item_count
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
outcome = on_action(action, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
except KeyboardInterrupt:
return cancel_value
except Exception:
return fallback()
def curses_checklist(
title: str,
items: List[str],
@@ -54,112 +244,73 @@ def curses_checklist(
if cancel_returns is None:
cancel_returns = set(selected)
# Safety: curses and input() both hang or spin when stdin is not a
# terminal (e.g. subprocess pipe). Return defaults immediately.
if not sys.stdin.isatty():
return cancel_returns
chosen = set(selected)
try:
def _draw_header(stdscr, max_y, max_x):
import curses
chosen = set(selected)
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
cursor = 0
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
return 3
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
check = "" if i in chosen else " "
arrow = "" if is_cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Reserve bottom row for status bar when status_fn provided
footer_rows = 1 if status_fn else 0
def _draw_footer(stdscr, max_y, max_x):
import curses
try:
status_text = status_fn(chosen)
if status_text:
# Right-align on the bottom row
sx = max(0, max_x - len(status_text) - 1)
sattr = curses.A_DIM
if curses.has_colors():
sattr |= curses.color_pair(3)
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
except curses.error:
pass
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
def _on_action(action, cursor):
if action == NAV_TOGGLE:
chosen.symmetric_difference_update({cursor})
return _KEEP
if action == NAV_SELECT:
return set(chosen)
return cancel_returns # NAV_CANCEL
# Scrollable item list
visible_rows = max_y - 3 - footer_rows
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1 - footer_rows:
break
check = "" if i in chosen else " "
arrow = "" if i == cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Status bar (bottom row, right-aligned)
if status_fn:
try:
status_text = status_fn(chosen)
if status_text:
# Right-align on the bottom row
sx = max(0, max_x - len(status_text) - 1)
sattr = curses.A_DIM
if curses.has_colors():
sattr |= curses.color_pair(3)
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(items)
elif key == ord(" "):
chosen.symmetric_difference_update({cursor})
elif key in {curses.KEY_ENTER, 10, 13}:
result_holder[0] = set(chosen)
return
elif key in {27, ord("q")}:
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
return _run_curses_menu(
initial_cursor=0,
item_count=len(items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=(2 if status_fn else 1),
draw_footer=_draw_footer if status_fn else None,
extra_color_pairs=bool(status_fn),
fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn),
cancel_value=cancel_returns,
)
def curses_radiolist(
@@ -184,106 +335,68 @@ def curses_radiolist(
if cancel_returns is None:
cancel_returns = selected
if not sys.stdin.isatty():
return cancel_returns
desc_lines: list[str] = []
if description:
desc_lines = description.splitlines()
try:
def _draw_header(stdscr, max_y, max_x):
import curses
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
row = 0
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = selected
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Description lines
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
row = 0
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
# One blank row between the hint and the item list.
return row + 1
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if is_cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
# Description lines
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
def _on_action(action, cursor):
if action in (NAV_SELECT, NAV_TOGGLE):
return cursor
return cancel_returns # NAV_CANCEL
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
# Scrollable item list
items_start = row + 1
visible_rows = max_y - items_start - 1
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + items_start
if y >= max_y - 1:
break
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if i == cursor else " "
line = f" {arrow} ({radio}) {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(items)
elif key in {ord(" "), curses.KEY_ENTER, 10, 13}:
result_holder[0] = cursor
return
elif key in {27, ord("q")}:
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _radio_numbered_fallback(title, items, selected, cancel_returns)
return _run_curses_menu(
initial_cursor=selected,
item_count=len(items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
)
def _radio_numbered_fallback(
@@ -324,93 +437,58 @@ def curses_single_select(
Works inside prompt_toolkit because curses.wrapper() restores the terminal
safely, unlike simple_term_menu which conflicts with /dev/tty.
"""
if not sys.stdin.isatty():
return None
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
try:
def _draw_header(stdscr, max_y, max_x):
import curses
result_holder: list = [None]
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
def _draw(stdscr):
curses.curs_set(0)
try:
hattr = curses.A_BOLD
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = min(default_index, len(all_items) - 1)
scroll_offset = 0
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
return 3
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
def _draw_row(stdscr, y, i, is_cursor, max_x):
import curses
arrow = "" if is_cursor else " "
line = f" {arrow} {all_items[i]}"
attr = curses.A_NORMAL
if is_cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1:
break
arrow = "" if i == cursor else " "
line = f" {arrow} {all_items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in {curses.KEY_UP, ord("k")}:
cursor = (cursor - 1) % len(all_items)
elif key in {curses.KEY_DOWN, ord("j")}:
cursor = (cursor + 1) % len(all_items)
elif key in {curses.KEY_ENTER, 10, 13}:
result_holder[0] = cursor
return
elif key in {27, ord("q")}:
result_holder[0] = None
return
curses.wrapper(_draw)
flush_stdin()
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
def _on_action(action, cursor):
if action == NAV_SELECT:
# Selecting the synthetic cancel row resolves to None, mirroring
# the old post-loop ``>= cancel_idx`` guard.
return None if cursor >= cancel_idx else cursor
if action == NAV_CANCEL:
return None
return result_holder[0]
return _KEEP # NAV_TOGGLE — no-op for this menu
except KeyboardInterrupt:
return None
except Exception:
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
return _numbered_single_fallback(title, all_items, cancel_idx)
return _run_curses_menu(
initial_cursor=min(default_index, len(all_items) - 1),
item_count=len(all_items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
cancel_value=None,
)
def _numbered_single_fallback(
+81 -37
View File
@@ -27,7 +27,6 @@ from hermes_cli.models import _HERMES_USER_AGENT
from hermes_constants import OPENROUTER_MODELS_URL
from utils import base_url_host_matches
_PROVIDER_ENV_HINTS = (
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
@@ -53,14 +52,11 @@ _PROVIDER_ENV_HINTS = (
"TOKENHUB_API_KEY",
)
from hermes_constants import is_termux as _is_termux
def _python_install_cmd() -> str:
return "python -m pip install" if _is_termux() else "uv pip install"
def _system_package_install_cmd(pkg: str) -> str:
if _is_termux():
return f"pkg install {pkg}"
@@ -68,7 +64,6 @@ def _system_package_install_cmd(pkg: str) -> str:
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
@@ -76,7 +71,6 @@ def _safe_which(cmd: str) -> str | None:
except Exception:
return None
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps: list[str] = []
step = 1
@@ -87,7 +81,6 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps.append(f"{step + 1}) agent-browser install")
return steps
def _termux_install_all_fallback_notes() -> list[str]:
return [
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
@@ -96,12 +89,10 @@ def _termux_install_all_fallback_notes() -> list[str]:
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
]
def _has_provider_env_config(content: str) -> bool:
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
return any(key in content for key in _PROVIDER_ENV_HINTS)
def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
@@ -112,7 +103,6 @@ def _honcho_is_configured_for_doctor() -> bool:
except Exception:
return False
def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
@@ -123,14 +113,12 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:
tools = item.get("tools") or []
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
"""Adjust runtime-gated tool availability for doctor diagnostics."""
updated_available = list(available)
@@ -148,7 +136,6 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable:
updated_unavailable.append(item)
return updated_available, updated_unavailable
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
"""Return True when a direct API-key probe failure is non-blocking.
@@ -178,7 +165,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
return False
return False
def check_ok(text: str, detail: str = ""):
print(f" {color('', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
@@ -191,18 +177,69 @@ def check_fail(text: str, detail: str = ""):
def check_info(text: str):
print(f" {color('', Colors.CYAN)} {text}")
def _section(title: str) -> None:
"""Print a doctor section banner: blank line + bold cyan ◆ title."""
print()
print(color(f"{title}", Colors.CYAN, Colors.BOLD))
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
"""Emit a check_fail and append the corresponding fix instruction."""
check_fail(text, detail)
issues.append(fix)
def _read_pyproject_version() -> str | None:
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
Returns None when running from an installed wheel (no pyproject.toml ships
with the package) or when the file can't be parsed. Reads only the
``[project]`` version, ignoring any version strings that appear in other
tables.
"""
pyproject = PROJECT_ROOT / "pyproject.toml"
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return None
in_project = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_project = line == "[project]"
continue
if in_project and line.startswith("version") and "=" in line:
value = line.split("=", 1)[1]
value = value.split("#", 1)[0].strip().strip("\"'")
return value or None
return None
def _check_version_consistency(issues: list[str]) -> None:
"""Verify pyproject.toml version matches hermes_cli.__version__.
A git conflict resolution (reset/merge) can revert one file without the
other, leaving ``hermes --version`` reporting a stale version while
``pyproject.toml`` is current. Detect that drift so users can re-sync.
Silent no-op for installed wheels where pyproject.toml isn't present.
"""
try:
from hermes_cli import __version__ as init_version
except Exception:
return
pyproject_version = _read_pyproject_version()
if pyproject_version is None:
# Installed wheel or unreadable pyproject — nothing to cross-check.
return
if pyproject_version == init_version:
check_ok("Version files consistent", f"({init_version})")
else:
_fail_and_issue(
"Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
issues,
)
def _check_s6_supervision(issues: list[str]) -> None:
"""Inside a container under our s6 /init, surface what s6 sees.
@@ -251,7 +288,6 @@ def _check_s6_supervision(issues: list[str]) -> None:
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "")
)
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout.
@@ -295,10 +331,8 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
else:
check_warn("Could not verify systemd linger", f"({linger_detail})")
_APIKEY_PROVIDERS_CACHE: list | None = None
def _build_apikey_providers_list() -> list:
"""Build the API-key provider health-check list once and cache it.
@@ -390,7 +424,6 @@ def _build_apikey_providers_list() -> list:
pass
return _static
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
@@ -509,6 +542,10 @@ def run_doctor(args):
check_ok("Virtual environment active")
else:
check_warn("Not in virtual environment", "(recommended)")
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
# (a git conflict resolution can silently revert one but not the other).
_check_version_consistency(issues)
_section("Required Packages")
required_packages = [
@@ -1474,12 +1511,15 @@ def run_doctor(args):
return _ConnectivityResult("Anthropic API", [], [])
try:
import httpx
from agent.anthropic_adapter import (
_is_oauth_token,
_COMMON_BETAS,
_OAUTH_ONLY_BETAS,
_CONTEXT_1M_BETA,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
# _COMMON_BETAS and _CONTEXT_1M_BETA are now in core
from agent.anthropic_format import _COMMON_BETAS, _CONTEXT_1M_BETA
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
if not all([_is_oauth_token, _OAUTH_ONLY_BETAS]):
raise ImportError("anthropic provider services not fully registered")
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(key)
if is_oauth:
@@ -1623,11 +1663,13 @@ def run_doctor(args):
def _probe_bedrock() -> _ConnectivityResult:
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region]):
raise ImportError("bedrock provider services not fully registered")
except ImportError:
return _ConnectivityResult("AWS Bedrock", [], [])
if not has_aws_credentials():
@@ -1698,12 +1740,14 @@ def run_doctor(args):
return _ConnectivityResult("Azure Foundry (Entra ID)", [], [])
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
describe_active_credential = _azure_ns.get("describe_active_credential")
has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
return _ConnectivityResult(
"Azure Foundry (Entra ID)",
+70 -6
View File
@@ -2161,9 +2161,37 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
return candidates
def _stable_service_working_dir() -> str:
"""Return a WorkingDirectory that will not disappear out from under systemd.
The gateway does NOT need its cwd to be the source checkout ``ExecStart``
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
when the unit is generated from a transient checkout a ``.worktrees/``
dir, or a clone that ``hermes update`` later relocates/removes the path
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
"Changing to the requested working directory failed") *before* Python
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
runs and ``Restart=always`` crash-loops forever on a dead directory.
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
it never moves, and it is guaranteed to exist whenever the gateway is
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
cannot be resolved (it always can in practice).
"""
try:
home = get_hermes_home()
if home and Path(home).is_dir():
return str(Path(home).resolve())
except Exception:
pass
return str(PROJECT_ROOT)
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
working_dir = _stable_service_working_dir()
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
@@ -2192,7 +2220,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
# (e.g. /root/) to the target user's home so the service can
# actually access them.
python_path = _remap_path_for_user(python_path, home_dir)
working_dir = _remap_path_for_user(working_dir, home_dir)
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
# rather than a remapped source-checkout path that can rot. See
# _stable_service_working_dir() for the full rationale.
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
venv_dir = _remap_path_for_user(venv_dir, home_dir)
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
@@ -2804,7 +2835,10 @@ def _launchd_domain() -> str:
def generate_launchd_plist() -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
# Stable cwd anchor — never the volatile source checkout. See
# _stable_service_working_dir() for the rationale (same rot risk applies
# to launchd's WorkingDirectory as to systemd's).
working_dir = _stable_service_working_dir()
hermes_home = str(get_hermes_home().resolve())
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
@@ -3730,6 +3764,9 @@ def _all_platforms() -> list[dict]:
for entry in platform_registry.all_entries():
if entry.name in by_key:
continue # built-in already covers it
# Re-apply host gating for plugin entries (e.g. matrix on Windows).
if sys.platform == "win32" and entry.name == "matrix":
continue
platforms.append({
"key": entry.name,
"label": entry.label,
@@ -4352,7 +4389,9 @@ def _setup_feishu():
if method_idx == 0:
# ── QR scan-to-create ──
try:
from gateway.platforms.feishu import qr_register
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
qr_register = _feishu_entry.helper_functions.get("qr_register") if _feishu_entry else None
except Exception as exc:
print_error(f" Feishu / Lark onboard import failed: {exc}")
qr_register = None
@@ -4393,8 +4432,13 @@ def _setup_feishu():
# Try to probe the bot with manual credentials
bot_name = None
try:
from gateway.platforms.feishu import probe_bot
bot_info = probe_bot(app_id, app_secret, domain)
from agent.plugin_registries import registries
_feishu_entry = registries.get_platform("feishu")
probe_bot = _feishu_entry.helper_functions.get("probe_bot") if _feishu_entry else None
if probe_bot:
bot_info = probe_bot(app_id, app_secret, domain)
else:
bot_info = None
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
@@ -5388,6 +5432,16 @@ def _gateway_command_inner(args):
sys.exit(1)
elif subcmd == "stop":
# Defense: refuse self-targeting gateway stop from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to stop the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway stop` from a shell outside the running gateway."
)
sys.exit(1)
stop_all = getattr(args, 'all', False)
system = getattr(args, 'system', False)
@@ -5463,6 +5517,16 @@ def _gateway_command_inner(args):
print(f"✓ Stopped {get_service_name()} service")
elif subcmd == "restart":
# Defense: refuse self-targeting gateway restart from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to restart the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway restart` from a shell outside the running gateway."
)
sys.exit(1)
# Try service first, fall back to killing and restarting
service_available = False
system = getattr(args, 'system', False)
+150
View File
@@ -747,6 +747,153 @@ class GoalManager:
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
# ──────────────────────────────────────────────────────────────────────
# Kanban worker goal loop
# ──────────────────────────────────────────────────────────────────────
# Continuation prompt fed back to a kanban goal-mode worker that has not
# yet completed/blocked its task. The card's own acceptance criteria are
# the goal — the worker already has the full task body in its first turn,
# so we keep this short and point it back at the lifecycle contract.
KANBAN_GOAL_CONTINUATION_TEMPLATE = (
"[Continuing toward this kanban task — judge says it is not done yet]\n"
"Reason: {reason}\n\n"
"Take the next concrete step toward completing the task. When the work "
"is genuinely finished, call kanban_complete with a summary. If you are "
"blocked and need human input, call kanban_block with a reason. Do not "
"stop without calling one of them."
)
# Fed when the judge believes the work is done but the worker never called
# kanban_complete / kanban_block. One explicit nudge to terminate the task
# the right way before the loop gives up.
KANBAN_GOAL_FINALIZE_TEMPLATE = (
"[The work looks complete, but the task is still open]\n"
"Reason: {reason}\n\n"
"If the task is genuinely done, call kanban_complete now with a short "
"summary of what you did. If something still blocks completion, call "
"kanban_block with the reason instead."
)
def run_kanban_goal_loop(
*,
task_id: str,
goal_text: str,
run_turn,
task_status_fn,
block_fn,
max_turns: int = DEFAULT_MAX_TURNS,
first_response: str = "",
log=None,
) -> Dict[str, Any]:
"""Drive a kanban worker through a Ralph-style goal loop.
The dispatcher spawns a goal-mode worker exactly like a normal worker
(``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's
first turn has already run by the time this is called; ``first_response``
is that turn's reply. From here we:
1. Check whether the worker already terminated the task (called
``kanban_complete`` / ``kanban_block``). If so, stop nothing to do.
2. Otherwise judge the latest response against ``goal_text`` (the card's
title + body). ``continue`` feed a continuation prompt and run
another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the
task is still open one explicit "call kanban_complete" nudge.
3. When the turn budget is exhausted and the worker still hasn't
terminated the task, ``block_fn`` is invoked so the card lands in a
sticky ``blocked`` state for human review (NOT a silent exit).
This function performs NO SessionDB persistence a worker process is
ephemeral, so the turn budget lives in a local counter. It is fully
decoupled from the CLI for testability: callers inject ``run_turn``
(str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn``
(reason: str -> None).
Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where
outcome is one of ``"completed_by_worker"``, ``"blocked_budget"``,
``"blocked_by_worker"``, or ``"stopped"``.
"""
def _log(msg: str) -> None:
if log is not None:
try:
log(msg)
except Exception:
pass
max_turns = int(max_turns or DEFAULT_MAX_TURNS)
if max_turns < 1:
max_turns = DEFAULT_MAX_TURNS
last_response = first_response or ""
# The first turn already consumed one unit of budget.
turns_used = 1
nudged_to_finalize = False
while True:
# Did the worker terminate the task itself this turn?
try:
status = task_status_fn()
except Exception as exc:
_log(f"kanban goal loop: status check failed ({exc}); stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": "status check failed"}
if status == "done":
_log(f"kanban goal loop: task {task_id} completed by worker after {turns_used} turn(s)")
return {"outcome": "completed_by_worker", "turns_used": turns_used, "reason": "worker completed the task"}
if status == "blocked":
_log(f"kanban goal loop: task {task_id} blocked by worker after {turns_used} turn(s)")
return {"outcome": "blocked_by_worker", "turns_used": turns_used, "reason": "worker blocked the task"}
if status not in ("running", "ready"):
# Reclaimed / archived / unexpected — let the dispatcher own it.
_log(f"kanban goal loop: task {task_id} status={status!r}; stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"status={status}"}
# Still open — judge whether the latest response satisfies the card.
verdict, reason, _parse_failed = judge_goal(goal_text, last_response)
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
if verdict == "done":
if nudged_to_finalize:
# Already asked once to call kanban_complete and it still
# didn't — block for review rather than spin.
_log(f"kanban goal loop: task {task_id} judged done but worker won't finalize; blocking")
try:
block_fn(
f"Goal-mode worker's output looked complete but it never "
f"called kanban_complete after a finalize nudge ({reason})."
)
except Exception as exc:
_log(f"kanban goal loop: block_fn failed ({exc})")
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "judged done, never finalized"}
prompt = KANBAN_GOAL_FINALIZE_TEMPLATE.format(reason=_truncate(reason, 400))
nudged_to_finalize = True
else:
prompt = KANBAN_GOAL_CONTINUATION_TEMPLATE.format(reason=_truncate(reason, 400))
# Budget check BEFORE spending another turn.
if turns_used >= max_turns:
_log(f"kanban goal loop: task {task_id} exhausted {turns_used}/{max_turns} turns; blocking")
try:
block_fn(
f"Goal-mode worker exhausted its turn budget "
f"({turns_used}/{max_turns}) without completing the task. "
f"Last judge verdict: {_truncate(reason, 300)}"
)
except Exception as exc:
_log(f"kanban goal loop: block_fn failed ({exc})")
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "turn budget exhausted"}
# Run another turn in the same session.
try:
last_response = run_turn(prompt) or ""
except Exception as exc:
_log(f"kanban goal loop: run_turn failed ({exc}); stopping")
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"run_turn error: {type(exc).__name__}"}
turns_used += 1
__all__ = [
"GoalState",
"GoalManager",
@@ -754,9 +901,12 @@ __all__ = [
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
"JUDGE_USER_PROMPT_TEMPLATE",
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
"KANBAN_GOAL_CONTINUATION_TEMPLATE",
"KANBAN_GOAL_FINALIZE_TEMPLATE",
"DEFAULT_MAX_TURNS",
"load_goal",
"save_goal",
"clear_goal",
"judge_goal",
"run_kanban_goal_loop",
]
+15
View File
@@ -341,6 +341,19 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"two retries. Omit to use the dispatcher's "
"kanban.failure_limit config "
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
help="Run the worker in a goal loop: after each "
"turn a judge checks the response against the "
"card title/body and, if not done, the worker "
"keeps going in the same session until the "
"judge agrees it's complete (or the turn "
"budget runs out, which blocks the card for "
"review). Best for open-ended cards one shot "
"rarely finishes.")
p_create.add_argument("--goal-max-turns", type=int, default=None,
metavar="N", dest="goal_max_turns",
help="Turn budget for --goal workers (default 20). "
"Ignored without --goal.")
p_create.add_argument("--initial-status",
choices=sorted(kb.VALID_INITIAL_STATUSES),
default="running",
@@ -1343,6 +1356,8 @@ def _cmd_create(args: argparse.Namespace) -> int:
max_runtime_seconds=max_runtime,
skills=getattr(args, "skills", None) or None,
max_retries=max_retries,
goal_mode=bool(getattr(args, "goal_mode", False)),
goal_max_turns=getattr(args, "goal_max_turns", None),
initial_status=getattr(args, "initial_status", "running"),
)
task = kb.get_task(conn, task_id)
+437 -16
View File
@@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path:
return board_dir(slug) / "workspaces"
def attachments_root(board: Optional[str] = None) -> Path:
"""Return the directory under which task file attachments are stored.
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
per-board so attachments don't leak between projects. Each task gets
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
precedence) for tests and unusual deployments.
``default`` uses ``<root>/kanban/attachments/``; other boards use
``<root>/kanban/boards/<slug>/attachments/``.
Workers (which run with full file-tool access) read attached files
by the absolute path surfaced in :func:`build_worker_context`. On the
local terminal backend the default for kanban that path resolves
directly. Remote backends (Docker/Modal) need this directory mounted;
see the kanban docs.
"""
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "attachments"
return board_dir(slug) / "attachments"
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
return attachments_root(board=board) / task_id
def worker_logs_dir(board: Optional[str] = None) -> Path:
"""Return the directory under which per-task worker logs are written.
@@ -690,6 +725,19 @@ class Task:
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
max_retries: Optional[int] = None
# When True, the dispatched worker runs in a Ralph-style goal loop
# (the same engine behind the ``/goal`` slash command): after each
# turn an auxiliary judge model evaluates the worker's response
# against this card's title/body (treated as the goal). If the judge
# says "not done" and budget remains, the worker is fed a
# continuation prompt IN THE SAME SESSION and keeps working until the
# judge agrees, the goal-turn budget is exhausted (→ kanban_block),
# or the worker explicitly blocks/completes. ``False`` (default) =
# the classic single-shot worker. ``goal_max_turns`` bounds the loop.
goal_mode: bool = False
# Goal-loop turn budget for ``goal_mode`` workers. ``None`` falls
# through to the goals engine default (``goals.DEFAULT_MAX_TURNS``).
goal_max_turns: Optional[int] = None
# Originating chat/agent session id, when the task was created from
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
# tasks created from the CLI, the dashboard, or any path that doesn't
@@ -762,6 +810,12 @@ class Task:
max_retries=(
row["max_retries"] if "max_retries" in keys else None
),
goal_mode=(
bool(row["goal_mode"]) if "goal_mode" in keys and row["goal_mode"] else False
),
goal_max_turns=(
row["goal_max_turns"] if "goal_max_turns" in keys and row["goal_max_turns"] else None
),
session_id=(
row["session_id"] if "session_id" in keys else None
),
@@ -831,6 +885,20 @@ class Comment:
created_at: int
@dataclass
class Attachment:
"""In-memory view of a row from the ``task_attachments`` table."""
id: int
task_id: str
filename: str
stored_path: str
content_type: Optional[str]
size: int
uploaded_by: Optional[str]
created_at: int
@dataclass
class Event:
id: int
@@ -897,6 +965,16 @@ CREATE TABLE IF NOT EXISTS tasks (
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
-- config and then ``DEFAULT_FAILURE_LIMIT``.
max_retries INTEGER,
-- When 1, the dispatched worker runs in a Ralph-style goal loop: an
-- auxiliary judge re-evaluates the worker's response against the
-- card title/body after each turn and feeds a continuation prompt
-- back into the SAME session until the judge agrees the work is done
-- or ``goal_max_turns`` is exhausted. NULL/0 = classic single-shot
-- worker (the default).
goal_mode INTEGER NOT NULL DEFAULT 0,
-- Goal-loop turn budget for ``goal_mode`` workers. NULL = use the
-- goals-engine default.
goal_max_turns INTEGER,
-- Originating chat/agent session id when the task was created from
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
-- for tasks created from the CLI, dashboard, or any path that doesn't
@@ -957,6 +1035,23 @@ CREATE TABLE IF NOT EXISTS task_runs (
error TEXT
);
-- Files attached to a task (PDFs, images, source documents). The blob
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
-- this row carries metadata + the absolute ``stored_path`` so the
-- dashboard can list/download and ``build_worker_context`` can surface
-- the absolute path to the worker (which has full file-tool access). See
-- #35338.
CREATE TABLE IF NOT EXISTS task_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
content_type TEXT,
size INTEGER NOT NULL DEFAULT 0,
uploaded_by TEXT,
created_at INTEGER NOT NULL
);
-- Subscription from a gateway source (platform + chat + thread) to a
-- task. The gateway's kanban-notifier watcher tails task_events and
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
@@ -981,6 +1076,7 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
"""
@@ -1517,6 +1613,20 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
if "model_override" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT")
if "goal_mode" not in cols:
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
# default) = classic single-shot worker, preserving the behaviour
# existing rows had before the column existed.
_add_column_if_missing(
conn, "tasks", "goal_mode", "goal_mode INTEGER NOT NULL DEFAULT 0"
)
if "goal_max_turns" not in cols:
# Per-task goal-loop turn budget. NULL = goals-engine default.
_add_column_if_missing(
conn, "tasks", "goal_max_turns", "goal_max_turns INTEGER"
)
if "session_id" not in cols:
# Originating agent/chat session id, populated when the task is
# created from within an agent loop that propagated
@@ -1637,6 +1747,140 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
(new, old),
)
_rebuild_drifted_tables(conn)
# Legacy DBs defined these tables with a ``TEXT PRIMARY KEY`` id (or, for
# ``kanban_notify_subs``, a nullable ``TEXT last_event_id``). The current
# schema uses ``INTEGER PRIMARY KEY AUTOINCREMENT`` / ``INTEGER NOT NULL
# DEFAULT 0``. ``CREATE TABLE IF NOT EXISTS`` skips existing tables
# regardless of schema and ``_add_column_if_missing`` only adds columns, so
# neither can fix a drifted column type — the table must be rebuilt. See
# #35096.
#
# Each entry pairs the canonical CREATE TABLE with the CREATE INDEX
# statements that DROP TABLE would otherwise take down with it (including
# ``idx_events_run``, added by the additive pass above). To guard against
# this list drifting from SCHEMA_SQL, ``test_rebuilt_schema_matches_fresh``
# asserts a rebuilt legacy DB is byte-identical to a fresh one.
_REBUILD_SPECS = {
"task_events": (
"CREATE TABLE task_events ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL,"
" payload TEXT, created_at INTEGER NOT NULL)",
(
"CREATE INDEX idx_events_task ON task_events(task_id, created_at)",
"CREATE INDEX idx_events_run ON task_events(run_id, id)",
),
),
"task_comments": (
"CREATE TABLE task_comments ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL,"
" created_at INTEGER NOT NULL)",
("CREATE INDEX idx_comments_task ON task_comments(task_id, created_at)",),
),
"task_runs": (
"CREATE TABLE task_runs ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" task_id TEXT NOT NULL, profile TEXT, step_key TEXT,"
" status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER,"
" worker_pid INTEGER, max_runtime_seconds INTEGER,"
" last_heartbeat_at INTEGER, started_at INTEGER NOT NULL,"
" ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT,"
" error TEXT)",
(
"CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)",
"CREATE INDEX idx_runs_status ON task_runs(status)",
),
),
"kanban_notify_subs": (
"CREATE TABLE kanban_notify_subs ("
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
" last_event_id INTEGER NOT NULL DEFAULT 0,"
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",),
),
}
def _table_has_drifted(conn: sqlite3.Connection, table: str) -> bool:
"""True when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape."""
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
if not info:
return False # table absent — nothing to rebuild
if table == "kanban_notify_subs":
lei = next((c for c in info if c["name"] == "last_event_id"), None)
return lei is not None and (lei["type"] or "").upper() != "INTEGER"
# task_events / task_comments / task_runs: id must be INTEGER and a PK.
id_col = next((c for c in info if c["name"] == "id"), None)
if id_col is None:
return False
return not ((id_col["type"] or "").upper() == "INTEGER" and id_col["pk"])
def _rebuild_drifted_tables(conn: sqlite3.Connection) -> None:
"""Rebuild any kanban table whose column types drifted from SCHEMA_SQL.
Old boards crash the gateway notifier (``int(None)`` on a NULL id in
``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
every kanban notification is silently lost (#35096). Each affected table is
rebuilt with the standard SQLite pattern CREATE new INSERT shared
columns DROP old RENAME recreating its indexes too (DROP TABLE takes
them down). The legacy TEXT ids are dropped (they aren't valid integers);
AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
so the first post-migration tick replays a task's event history once —
the safe failure mode for a feature that was already fully broken.
The whole pass runs in one transaction so an interruption can't leave a
table half-renamed, and under ``connect()``'s init locks so nothing races
it. Idempotent: a correctly-typed DB skips every table and returns without
opening a transaction.
"""
drifted = [t for t in _REBUILD_SPECS if _table_has_drifted(conn, t)]
if not drifted:
return
conn.execute("BEGIN IMMEDIATE")
try:
for table in drifted:
create_sql, index_sqls = _REBUILD_SPECS[table]
old_cols = [c["name"] for c in conn.execute(f"PRAGMA table_info({table})")]
_log.info("kanban migration: rebuilding %s to match current schema", table)
conn.execute(f"ALTER TABLE {table} RENAME TO {table}_legacy")
conn.execute(create_sql)
new_cols = {c["name"] for c in conn.execute(f"PRAGMA table_info({table})")}
if table == "kanban_notify_subs":
# Cast the legacy TEXT cursor to INTEGER; NULL / non-numeric → 0.
shared = [c for c in old_cols if c in new_cols and c != "last_event_id"]
cols_csv = ", ".join(shared)
conn.execute(
f"INSERT INTO {table} ({cols_csv}, last_event_id) "
f"SELECT {cols_csv}, COALESCE(CAST(last_event_id AS INTEGER), 0) "
f"FROM {table}_legacy"
)
else:
# Drop the legacy TEXT id; AUTOINCREMENT reassigns it.
shared = [c for c in old_cols if c in new_cols and c != "id"]
cols_csv = ", ".join(shared)
conn.execute(
f"INSERT INTO {table} ({cols_csv}) "
f"SELECT {cols_csv} FROM {table}_legacy"
)
conn.execute(f"DROP TABLE {table}_legacy")
for index_sql in index_sqls:
conn.execute(index_sql)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
raise
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
"""Read the SQLite header page_count and compare against actual file size.
@@ -1766,6 +2010,8 @@ def create_task(
max_runtime_seconds: Optional[int] = None,
skills: Optional[Iterable[str]] = None,
max_retries: Optional[int] = None,
goal_mode: bool = False,
goal_max_turns: Optional[int] = None,
initial_status: str = "running",
session_id: Optional[str] = None,
board: Optional[str] = None,
@@ -1933,8 +2179,8 @@ def create_task(
id, title, body, assignee, status, priority,
created_by, created_at, workspace_kind, workspace_path,
branch_name, tenant, idempotency_key, max_runtime_seconds,
skills, max_retries, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
skills, max_retries, goal_mode, goal_max_turns, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
@@ -1953,6 +2199,8 @@ def create_task(
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
json.dumps(skills_list) if skills_list is not None else None,
int(max_retries) if max_retries is not None else None,
1 if goal_mode else 0,
int(goal_max_turns) if goal_max_turns is not None else None,
session_id,
),
)
@@ -1972,6 +2220,7 @@ def create_task(
"tenant": tenant,
"branch_name": branch_name,
"skills": list(skills_list) if skills_list else None,
"goal_mode": bool(goal_mode) or None,
},
)
return task_id
@@ -2252,6 +2501,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
]
# ---------------------------------------------------------------------------
# Attachments
# ---------------------------------------------------------------------------
def add_attachment(
conn: sqlite3.Connection,
task_id: str,
*,
filename: str,
stored_path: str,
content_type: Optional[str] = None,
size: int = 0,
uploaded_by: Optional[str] = None,
) -> int:
"""Record a file attachment for a task. Returns the new attachment id.
The caller is responsible for writing the blob to ``stored_path``
first (under :func:`task_attachments_dir`); this only persists the
metadata row and appends an ``attached`` event.
"""
if not filename or not filename.strip():
raise ValueError("attachment filename is required")
if not stored_path or not stored_path.strip():
raise ValueError("attachment stored_path is required")
now = int(time.time())
with write_txn(conn):
if not conn.execute(
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
).fetchone():
raise ValueError(f"unknown task {task_id}")
cur = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
task_id,
filename.strip(),
stored_path,
content_type,
int(size),
uploaded_by,
now,
),
)
_append_event(
conn,
task_id,
"attached",
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
)
return int(cur.lastrowid or 0)
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
rows = conn.execute(
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
(task_id,),
).fetchall()
return [
Attachment(
id=r["id"],
task_id=r["task_id"],
filename=r["filename"],
stored_path=r["stored_path"],
content_type=r["content_type"],
size=r["size"] or 0,
uploaded_by=r["uploaded_by"],
created_at=r["created_at"],
)
for r in rows
]
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
r = conn.execute(
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
).fetchone()
if r is None:
return None
return Attachment(
id=r["id"],
task_id=r["task_id"],
filename=r["filename"],
stored_path=r["stored_path"],
content_type=r["content_type"],
size=r["size"] or 0,
uploaded_by=r["uploaded_by"],
created_at=r["created_at"],
)
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
"""Delete an attachment row and its on-disk blob. Returns the removed row.
Returns ``None`` when no row matched. The blob is removed best-effort
(a missing file is not an error); the metadata row is the source of
truth for whether an attachment "exists".
"""
with write_txn(conn):
att = get_attachment(conn, attachment_id)
if att is None:
return None
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
_append_event(
conn, att.task_id, "attachment_removed", {"filename": att.filename}
)
try:
p = Path(att.stored_path)
if p.is_file():
p.unlink()
except OSError:
pass
return att
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
rows = conn.execute(
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
@@ -2457,7 +2821,9 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
return bool(row) and row["kind"] == "blocked"
def recompute_ready(conn: sqlite3.Connection) -> int:
def recompute_ready(
conn: sqlite3.Connection, failure_limit: int = None,
) -> int:
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
Returns the number of tasks promoted. Safe to call inside or outside
@@ -2465,17 +2831,34 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
``blocked`` tasks are also considered for promotion (so a task
blocked purely by a parent dependency unblocks itself when the
parent completes), *except* when the most recent block event was a
worker-initiated ``kanban_block`` those stay blocked until an
explicit ``kanban_unblock`` (#28712). Without that guard, a
``review-required`` handoff would auto-respawn, the fresh worker
would find nothing to do, exit cleanly, get recorded as a protocol
violation, and the cycle would repeat indefinitely.
parent completes), *except* in two cases:
1. The most recent block event was a worker-initiated
``kanban_block`` those stay blocked until an explicit
``kanban_unblock`` (#28712).
2. The task's ``consecutive_failures`` has reached the effective
failure limit. This prevents infinite retry loops when a task
repeatedly exhausts its iteration budget: without this guard the
counter would reset on every recovery cycle and the circuit
breaker could never trip (#35072).
The effective failure limit resolves in the same order as the
circuit breaker in ``_record_task_failure`` so the two never
disagree about when a task is permanently blocked:
1. per-task ``max_retries`` if set
2. caller-supplied ``failure_limit`` (the dispatcher passes the
``kanban.failure_limit`` config value through ``dispatch_once``)
3. ``DEFAULT_FAILURE_LIMIT``
"""
if failure_limit is None:
failure_limit = DEFAULT_FAILURE_LIMIT
promoted = 0
with write_txn(conn):
todo_rows = conn.execute(
"SELECT id, status FROM tasks WHERE status IN ('todo', 'blocked')"
"SELECT id, status, consecutive_failures, max_retries "
"FROM tasks WHERE status IN ('todo', 'blocked')"
).fetchall()
for row in todo_rows:
task_id = row["id"]
@@ -2493,13 +2876,25 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
(task_id,),
).fetchall()
if all(p["status"] in ("done", "archived") for p in parents):
# Blocked tasks also get their failure counters reset —
# this is effectively an auto-unblock (circuit-breaker
# recovery; worker-initiated blocks are skipped above).
if cur_status == "blocked":
# Don't auto-recover tasks that have hit the
# circuit-breaker failure limit. Without this
# guard, a task that repeatedly exhausts its
# iteration budget would cycle forever:
# block → auto-recover → respawn → budget
# exhausted → block → … The counter must also
# be preserved so the breaker can accumulate
# across recovery cycles.
failures = int(row["consecutive_failures"] or 0)
task_limit = row["max_retries"]
effective_limit = (
int(task_limit) if task_limit is not None
else int(failure_limit)
)
if failures >= effective_limit:
continue
conn.execute(
"UPDATE tasks SET status = 'ready', "
"consecutive_failures = 0, last_failure_error = NULL "
"UPDATE tasks SET status = 'ready' "
"WHERE id = ? AND status = 'blocked'",
(task_id,),
)
@@ -5424,7 +5819,7 @@ def dispatch_once(
if _crash_auto_blocked:
result.auto_blocked.extend(_crash_auto_blocked)
result.timed_out = enforce_max_runtime(conn)
result.promoted = recompute_ready(conn)
result.promoted = recompute_ready(conn, failure_limit=failure_limit)
# Count tasks already running so max_spawn enforces concurrency rather
# than a per-tick spawn budget. See the docstring above for the full
@@ -6065,6 +6460,13 @@ def _default_spawn(
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
if task.claim_lock:
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
# Goal-loop mode: the worker reads these and wraps its run in the
# Ralph-style /goal judge loop (see cli.py quiet-mode path). Only set
# when enabled so non-goal tasks keep a clean env.
if task.goal_mode:
env["HERMES_KANBAN_GOAL_MODE"] = "1"
if task.goal_max_turns is not None:
env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns))
terminal_timeout = _worker_terminal_timeout_env(
task.max_runtime_seconds,
env.get("TERMINAL_TIMEOUT"),
@@ -6300,6 +6702,25 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
lines.append("")
# Attachments — files uploaded to this task (PDFs, source docs,
# images). Surface the absolute on-disk path so the worker, which has
# full file-tool access, can read them directly (read_file, terminal
# `pdftotext`, etc.). On the local terminal backend the path resolves
# as-is; remote backends need the kanban attachments dir mounted.
attachments = list_attachments(conn, task_id)
if attachments:
lines.append("## Attachments")
lines.append(
"Files attached to this task. Read them with the file/terminal "
"tools at the absolute paths below:"
)
for att in attachments:
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
size_str = f", {size_kb} KB" if size_kb else ""
ctype = f", {att.content_type}" if att.content_type else ""
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
lines.append("")
# Prior attempts — show closed runs so a retrying worker sees the
# history. Skip the currently-active run (that's this worker).
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
+473 -211
View File
@@ -65,6 +65,46 @@ import os
import sys
def _set_process_title() -> None:
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
'htop' show the app name instead of 'python3.xx'.
Purely cosmetic non-fatal on any platform.
Strategy (try in order):
1. ``setproctitle`` (opt-in dep installed via ``hermes tools`` or
``pip install setproctitle``, or bundled in a future release).
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name
changes lldb/top but not ``ps aux``).
4. No-op on Windows (the .exe name is already ``hermes.exe``).
"""
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
try:
import setproctitle # type: ignore[import-untyped]
setproctitle.setproctitle("hermes")
return
except ImportError:
pass
# Strategy 2/3: platform-specific ctypes fallback
import ctypes
import platform
try:
system = platform.system()
if system == "Linux":
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
elif system == "Darwin":
libc = ctypes.CDLL("libc.dylib", use_errno=True)
libc.pthread_setname_np(b"hermes")
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
except Exception:
pass
# Mouse-tracking residue suppression — runs BEFORE every other import on the
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
# Python launcher is still doing imports (≈100300ms in cooked + echo mode,
@@ -622,10 +662,12 @@ def _has_any_provider_configured() -> bool:
# being installed doesn't mean the user wants Hermes to use their tokens.
if _has_hermes_config:
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if read_claude_code_credentials is None or is_claude_code_token_valid is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
if creds and (
@@ -2354,7 +2396,12 @@ def select_provider_and_model(args=None):
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
active = "custom"
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
from hermes_cli.models import (
CANONICAL_PROVIDERS,
_PROVIDER_LABELS,
group_providers,
provider_group_for_slug,
)
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
if active and active in _custom_provider_map:
@@ -2367,8 +2414,43 @@ def select_provider_and_model(args=None):
print(f" Active provider: {active_label}")
print()
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
# Step 1: Provider selection.
#
# Canonical providers are folded into top-level groups (display only — see
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
# resolves back to a concrete slug, so the dispatch chain below is
# unchanged. Custom providers and the trailing actions stay flat.
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
# The group/slug that should be pre-selected: the active provider's group
# if it's grouped, otherwise the active slug itself.
active_group = provider_group_for_slug(active) if active else ""
# ordered entries: (key, label, members)
# members == [] → leaf row, key is a provider slug / action
# members != [] → group row, key is "group:<gid>"
ordered: list[tuple[str, str, list[str]]] = []
default_idx = 0
for row in grouped_rows:
if row["kind"] == "group":
gid = row["group_id"]
label = f"{row['label']}"
key = f"group:{gid}"
is_active = bool(active_group) and gid == active_group
members = row["members"]
else:
slug = row["slug"]
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
key = slug
is_active = bool(active) and slug == active
members = []
if is_active:
ordered.append((key, f"{label} ← currently active", members))
default_idx = len(ordered) - 1
else:
ordered.append((key, label, members))
for key, provider_info in _custom_provider_map.items():
name = provider_info["name"]
@@ -2376,36 +2458,49 @@ def select_provider_and_model(args=None):
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
saved_model = provider_info.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
# Build the menu
ordered = []
default_idx = 0
for key, label in all_providers:
label = f"{name} ({short_url}){model_hint}"
if active and key == active:
ordered.append((key, f"{label} ← currently active"))
ordered.append((key, f"{label} ← currently active", []))
default_idx = len(ordered) - 1
else:
ordered.append((key, label))
ordered.append((key, label, []))
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
config.get("custom_providers")
)
if _has_saved_custom_list:
ordered.append(("remove-custom", "Remove a saved custom provider"))
ordered.append(("aux-config", "Configure auxiliary models..."))
ordered.append(("cancel", "Leave unchanged"))
ordered.append(("remove-custom", "Remove a saved custom provider", []))
ordered.append(("aux-config", "Configure auxiliary models...", []))
ordered.append(("cancel", "Leave unchanged", []))
provider_idx = _prompt_provider_choice(
[label for _, label in ordered],
[label for _, label, _ in ordered],
default=default_idx,
)
if provider_idx is None or ordered[provider_idx][0] == "cancel":
print("No change.")
return
selected_provider = ordered[provider_idx][0]
selected_key = ordered[provider_idx][0]
selected_members = ordered[provider_idx][2]
# Group row → drill into a member sub-picker. Default to the active member
# if the active provider lives in this group.
if selected_members:
member_default = 0
if active in selected_members:
member_default = selected_members.index(active)
member_labels = [
canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members
]
member_idx = _prompt_provider_choice(member_labels, default=member_default)
if member_idx is None:
print("No change.")
return
selected_provider = selected_members[member_idx]
else:
selected_provider = selected_key
if selected_provider == "aux-config":
_aux_config_menu()
@@ -4102,13 +4197,15 @@ def _model_flow_azure_foundry(config, current_model=""):
if use_entra:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
describe_active_credential,
has_azure_identity_installed,
)
from agent.plugin_registries import registries
_azure = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure.get("build_token_provider")
describe_active_credential = _azure.get("describe_active_credential")
has_azure_identity_installed = _azure.get("has_azure_identity_installed")
if any(v is None for v in [EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed]):
raise ImportError("azure plugin not registered")
except ImportError as exc:
print()
print(f"⚠ Could not import azure-identity adapter: {exc}")
@@ -4362,23 +4459,17 @@ def _remove_custom_provider(config):
choices.append("Cancel")
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
menu = TerminalMenu(
[f" {c}" for c in choices],
cursor_index=0,
menu_cursor="-> ",
menu_cursor_style=("fg_red", "bold"),
menu_highlight_style=("fg_red",),
cycle_cursor=True,
clear_screen=False,
title="Select provider to remove:",
idx = curses_radiolist(
"Select provider to remove:",
list(choices),
selected=0,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
print()
if idx < 0:
idx = None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
for i, c in enumerate(choices, 1):
print(f" {i}. {c}")
@@ -4445,27 +4536,19 @@ def _model_flow_named_custom(config, provider_info):
print(f"Found {len(models)} model(s):\n")
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
menu_items = [
f" {m} (current)" if m == saved_model else f" {m}" for m in models
] + [" Cancel"]
menu = TerminalMenu(
f"{m} (current)" if m == saved_model else m for m in models
] + ["Cancel"]
idx = curses_radiolist(
f"Select model from {name}:",
menu_items,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title=f"Select model from {name}:",
selected=default_idx,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
print()
if idx is None or idx >= len(models):
if idx < 0 or idx >= len(models):
print("Cancelled.")
return
model_name = models[idx]
@@ -4642,26 +4725,18 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
default_idx = 0
try:
from simple_term_menu import TerminalMenu
from hermes_cli.curses_ui import curses_radiolist
choices = [f" {_label(effort)}" for effort in ordered]
choices.append(f" {disable_label}")
choices.append(f" {skip_label}")
menu = TerminalMenu(
choices = [_label(effort) for effort in ordered]
choices.append(disable_label)
choices.append(skip_label)
idx = curses_radiolist(
"Select reasoning effort:",
choices,
cursor_index=default_idx,
menu_cursor="-> ",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title="Select reasoning effort:",
selected=default_idx,
cancel_returns=-1,
)
idx = menu.show()
from hermes_cli.curses_ui import flush_stdin
flush_stdin()
if idx is None:
if idx < 0:
return None
print()
if idx < len(ordered):
@@ -5422,12 +5497,14 @@ def _model_flow_bedrock(config, current_model=""):
# 1. Check for AWS credentials
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
discover_bedrock_models,
)
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
discover_bedrock_models = _bedrock.get("discover_bedrock_models")
if any(v is None for v in [has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models]):
raise ImportError("bedrock plugin not registered")
except ImportError:
print(" ✗ boto3 is not installed. Install it with:")
print(" pip install boto3")
@@ -5874,11 +5951,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
def _run_anthropic_oauth_flow(save_env_value):
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
from agent.anthropic_adapter import (
run_oauth_setup_token,
read_claude_code_credentials,
is_claude_code_token_valid,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
run_oauth_setup_token = _anthropic.get("run_oauth_setup_token")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
if run_oauth_setup_token is None:
raise ImportError("anthropic plugin not registered")
from hermes_cli.config import (
save_anthropic_oauth_token,
use_anthropic_claude_code_credentials,
@@ -5986,11 +6065,13 @@ def _model_flow_anthropic(config, current_model=""):
existing_key = get_anthropic_key()
cc_available = False
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
is_claude_code_token_valid,
_is_oauth_token,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid")
_is_oauth_token = _anthropic.get("_is_oauth_token")
if any(v is None for v in [read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token]):
raise ImportError("anthropic plugin not registered")
cc_creds = read_claude_code_credentials()
if cc_creds and is_claude_code_token_valid(cc_creds):
@@ -7840,39 +7921,6 @@ def _detect_concurrent_hermes_instances(
except Exception:
return []
# Build a set of PIDs to exclude: the Python process itself plus its
# entire parent chain. On Windows the setuptools-generated hermes.exe
# launcher is a separate native process that spawns python.exe (the
# interpreter that runs our code). os.getpid() returns the Python PID,
# but the launcher (which holds the file lock) is the parent. Without
# walking the parent chain, every ``hermes update`` reports its own
# launcher as a concurrent instance — a false positive.
if exclude_pid is not None:
exclude_pids: set[int] = {exclude_pid}
else:
exclude_pids = {os.getpid()}
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
# AccessDenied) we stop walking and use whatever we've collected so far.
# Broader Exception catch on the outer block guards against partially-
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
# NoSuchProcess) — the surrounding update flow documents this helper as
# "never raises".
try:
current = psutil.Process(next(iter(exclude_pids)))
while True:
try:
parent = current.parent()
except Exception:
break
if parent is None or parent.pid <= 0:
break
if parent.pid in exclude_pids:
break # loop detected
exclude_pids.add(parent.pid)
current = parent
except Exception:
pass
# Resolve every shim path to its canonical form once for cheap comparison.
shim_paths: set[str] = set()
for shim in _hermes_exe_shims(scripts_dir):
@@ -7883,6 +7931,56 @@ def _detect_concurrent_hermes_instances(
if not shim_paths:
return []
# Build a set of PIDs to exclude: the Python process itself plus every
# ancestor whose executable is one of our shims. On Windows the
# setuptools-generated hermes.exe launcher is a separate native process
# that spawns python.exe (the interpreter that runs our code).
# os.getpid() returns the Python PID, but the launcher (which holds the
# file lock) is the parent. Without excluding it, every ``hermes update``
# reports its own launcher as a concurrent instance — a false positive
# (issues #29341, #34795).
#
# Two robustness points learned from the field:
# 1. Use ``proc.parents()`` — it returns the WHOLE ancestor list in one
# call. The earlier per-hop ``current.parent()`` loop bailed on the
# first psutil error (AccessDenied/NoSuchProcess is common on Windows
# across session/elevation boundaries), leaving the launcher shim in
# the candidate set and re-triggering the false positive.
# 2. Only exclude ancestors whose exe is itself a shim. A genuine second
# hermes.exe sitting *under* a non-Hermes parent (e.g. a Hermes
# Desktop backend child) must still be flagged, so we don't blanket-
# exclude unrelated ancestors like the shell or terminal.
# Broad ``except Exception`` guards against partially-stubbed psutil in
# unit tests; this helper is documented as "never raises".
if exclude_pid is not None:
exclude_pids: set[int] = {int(exclude_pid)}
else:
exclude_pids = {os.getpid()}
try:
seed = next(iter(exclude_pids))
try:
ancestors = psutil.Process(seed).parents()
except Exception:
ancestors = []
for ancestor in ancestors:
try:
anc_exe = ancestor.exe()
except Exception:
continue
if not anc_exe:
continue
try:
anc_norm = str(Path(anc_exe).resolve()).lower()
except (OSError, ValueError):
anc_norm = str(anc_exe).lower()
if anc_norm in shim_paths:
try:
exclude_pids.add(int(ancestor.pid))
except Exception:
continue
except Exception:
pass
matches: list[tuple[int, str]] = []
try:
proc_iter = psutil.process_iter(["pid", "exe", "name"])
@@ -7923,6 +8021,13 @@ def _format_concurrent_instances_message(
lines.append("")
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
lines.append("")
if matches:
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
lines.append(" If you've already closed everything and these PIDs are")
lines.append(" stale, terminate them directly, then retry the update:")
lines.append(f" taskkill {pid_args} /F")
lines.append("")
lines.append(" Override with `hermes update --force` if you've already")
lines.append(" confirmed those processes will not write to the venv.")
return "\n".join(lines)
@@ -8100,71 +8205,13 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
def _refresh_active_lazy_features() -> None:
"""Refresh lazy-installed backends after a code update.
"""No-op — lazy deps removed.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
optional backends moved to ``tools/lazy_deps.py`` and only install on
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
leaves those packages untouched so if we bump a pin in
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
activated the backend keep the stale version forever.
This function asks lazy_deps which features the user has previously
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet no churn for cold backends.
Never raises. A failure here must not block the rest of the update.
Optional backends are now proper plugin packages (hermes-agent-anthropic,
hermes-agent-telegram, etc.) installed via extras. ``hermes update``
refreshes them through ``uv pip install -e .[all]`` like any other dep.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
if not active:
return
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
try:
results = lazy_deps.refresh_active_features(prompt=False)
except Exception as exc:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
return
refreshed = [f for f, s in results.items() if s == "refreshed"]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
if refreshed:
print(f"{len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f"{len(current)} already current")
if skipped:
# Most common reason: security.allow_lazy_installs=false. Show one
# line so the user knows why; not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if failed:
for feature, status in failed:
reason = status.split(": ", 1)[-1]
# Clip noisy pip stderr to keep update output legible.
if len(reason) > 200:
reason = reason[:200] + "..."
print(f"{feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
pass
def _install_python_dependencies_with_optional_fallback(
@@ -8878,18 +8925,51 @@ def cmd_update(args):
def _cmd_update_pip(args):
"""Update Hermes via pip (for PyPI installs)."""
from hermes_cli import __version__
from hermes_cli.config import is_uv_tool_install
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
uv = shutil.which("uv")
if uv:
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
pipx = shutil.which("pipx") if pipx_managed else None
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
# exported (uv refuses to install without it when the launcher shim
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
# set it for them.
export_virtualenv = False
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
cmd = [pipx, "upgrade", "hermes-agent"]
elif uv:
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
if in_venv:
# Launcher shim runs the venv interpreter but doesn't export
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
export_virtualenv = True
else:
# Outside any venv, ``--system`` lets uv target the active
# interpreter, matching pip's default behaviour.
cmd.insert(3, "--system")
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
print(f"→ Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
run_kwargs = {}
if export_virtualenv:
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
result = subprocess.run(cmd, **run_kwargs)
if result.returncode != 0:
print("✗ Update failed")
sys.exit(1)
@@ -9125,12 +9205,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
pre_update_snapshot_id = None
try:
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(label="pre-update", keep=1)
if snap_id:
print(f" ✓ Pre-update snapshot: {snap_id}")
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
@@ -9406,16 +9487,61 @@ def _cmd_update_impl(args, gateway_mode: bool):
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
needs_migration = missing_env or missing_config or current_ver < latest_ver
has_new_options = bool(missing_env or missing_config)
version_bump_only = (
not has_new_options and current_ver < latest_ver
)
needs_migration = has_new_options or current_ver < latest_ver
if needs_migration:
if version_bump_only:
# Nothing for the user to fill in — only the config format version
# changed (new defaults already merge in transparently). Asking
# "configure new options now?" here is misleading: saying yes just
# bumps the version and looks like a no-op (issue: ScottFive /
# Tt2021). Apply it silently and say what actually happened.
print()
print(
f" Updating config format (v{current_ver} → v{latest_ver})…"
)
try:
migrate_config(interactive=False, quiet=True)
print(" ✓ Config format updated (no new settings to configure)")
except Exception as _mig_err:
print(f" ⚠️ Config format update failed: {_mig_err}")
print(" Run 'hermes config migrate' to retry.")
elif needs_migration:
print()
# Show WHAT changed, not just a count, so the user can make an
# informed yes/no decision (previously the prompt named nothing).
def _print_items(items, label, key, fallback_key=None):
if not items:
return
print(f" {label}:")
shown = items[:8]
for it in shown:
if isinstance(it, dict):
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
desc = (it.get("description") or "").strip()
else:
# Defensive: some callers/mocks pass bare name strings.
name = str(it)
desc = ""
if desc:
print(f"{name}{desc}")
else:
print(f"{name}")
extra = len(items) - len(shown)
if extra > 0:
print(f" … and {extra} more")
if missing_env:
print(
f" ⚠️ {len(missing_env)} new required setting(s) need configuration"
)
_print_items(missing_env, "New settings", "name")
if missing_config:
print(f" {len(missing_config)} new config option(s) available")
_print_items(missing_config, "New options", "key")
print()
if assume_yes:
@@ -9467,6 +9593,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
else:
print(" ✓ Configuration is up to date")
# Safety net: config-version migrations have been observed to leave
# cron/jobs.json valid-but-empty, silently dropping every scheduled
# job (issue #34600). If the live file is now empty while the
# pre-update snapshot held jobs, restore it and warn loudly.
try:
from hermes_cli.backup import restore_cron_jobs_if_emptied
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
if cron_restore:
print()
print(
" ⚠️ cron/jobs.json was emptied during this update — "
f"restored {cron_restore['job_count']} job(s) from "
f"pre-update snapshot {cron_restore['snapshot_id']}."
)
except Exception as exc:
# Never let the cron safety net break an otherwise-good update.
logger.debug("Cron jobs auto-restore check failed: %s", exc)
print()
print("✓ Update complete!")
@@ -10562,11 +10707,10 @@ def cmd_profile(args):
if collision:
print(f"Error: {collision}")
sys.exit(1)
wrapper_path = create_wrapper_script(alias_name)
wrapper_path = create_wrapper_script(
alias_name, target=name if custom_name else None
)
if wrapper_path:
# If custom name, write the profile name into the wrapper
if custom_name:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
print(f"✓ Alias created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"{_get_wrapper_dir()} is not in your PATH.")
@@ -10949,6 +11093,13 @@ def cmd_completion(args, parser=None):
print(generate_bash(parser))
def cmd_prompt_size(args):
"""Show a byte/char breakdown of the system prompt + tool schemas."""
from hermes_cli.prompt_size import cmd_prompt_size as _impl
_impl(args)
def cmd_logs(args):
"""View and filter Hermes log files."""
from hermes_cli.logs import tail_log, list_logs
@@ -10985,6 +11136,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
"prompt-size",
"send", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat", "secrets", "security",
@@ -11085,6 +11237,26 @@ _AGENT_SUBCOMMANDS = {
}
def _is_tui_chat_launch(args) -> bool:
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
def _command_has_dedicated_mcp_startup(args) -> bool:
if args.command == "acp":
return True
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
return True
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
return True
return False
def _should_background_mcp_startup(args) -> bool:
if _is_tui_chat_launch(args):
return False
return args.command in {None, "chat", "rl"}
def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
@@ -11104,19 +11276,42 @@ def _prepare_agent_startup(args) -> None:
"plugin discovery failed at CLI startup",
exc_info=True,
)
try:
# MCP tool discovery — no event loop running in CLI/TUI startup,
# so inline is safe. Moved here from model_tools.py module scope
# to avoid freezing the gateway's event loop on its first message
# via the same lazy import path (#16856).
from tools.mcp_tool import discover_mcp_tools
_run_inline_mcp_discovery = True
if _is_tui_chat_launch(args):
# The TUI launcher hands off to a dedicated startup path that already
# backgrounds MCP discovery with a bounded join before the first tool
# snapshot.
_run_inline_mcp_discovery = False
elif _command_has_dedicated_mcp_startup(args):
# These entrypoints already do their own MCP startup later on the real
# runtime path (gateway executor, ACP launcher, cron job runner).
_run_inline_mcp_discovery = False
elif _should_background_mcp_startup(args):
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
start_background_mcp_discovery(
logger=logger,
thread_name="cli-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at CLI startup",
exc_info=True,
)
_run_inline_mcp_discovery = False
if _run_inline_mcp_discovery:
try:
# MCP tool discovery remains synchronous for entrypoints that do
# not own a later bounded/executor startup path.
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config
@@ -11257,6 +11452,10 @@ def _try_termux_fast_tui_launch() -> bool:
def main():
"""Main entry point for hermes CLI."""
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
# in ps/top/htop. Non-fatal — just a nicer UX.
_set_process_title()
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
try:
from hermes_cli.stdio import configure_windows_stdio
@@ -13010,9 +13209,15 @@ Examples:
),
)
memory_sub = memory_parser.add_subparsers(dest="memory_command")
memory_sub.add_parser(
_setup_parser = memory_sub.add_parser(
"setup", help="Interactive provider selection and configuration"
)
_setup_parser.add_argument(
"provider",
nargs="?",
default=None,
help="Provider to configure directly (e.g. honcho), skipping the picker",
)
memory_sub.add_parser("status", help="Show current memory provider config")
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
_reset_parser = memory_sub.add_parser(
@@ -13390,6 +13595,11 @@ Examples:
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_subparsers.add_parser(
"optimize",
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
)
sessions_subparsers.add_parser("stats", help="Show session store statistics")
sessions_rename = sessions_subparsers.add_parser(
@@ -13562,6 +13772,34 @@ Examples:
relaunch(["--resume", selected_id])
return # won't reach here after execvp
elif action == "optimize":
db_path = db.db_path
before_mb = (
os.path.getsize(db_path) / (1024 * 1024)
if db_path.exists()
else 0.0
)
print("Optimizing session store (FTS merge + VACUUM)…")
try:
# vacuum() merges FTS5 segments (optimize_fts) then VACUUMs,
# and returns the number of indexes it merged.
n = db.vacuum()
except Exception as e:
print(f"Error: optimization failed: {e}")
db.close()
return
after_mb = (
os.path.getsize(db_path) / (1024 * 1024)
if db_path.exists()
else 0.0
)
saved = before_mb - after_mb
print(f"Optimized {n} FTS index(es).")
print(
f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
f"(reclaimed {saved:.1f} MB)"
)
elif action == "stats":
total = db.session_count()
msgs = db.message_count()
@@ -14175,6 +14413,30 @@ Examples:
)
logs_parser.set_defaults(func=cmd_logs)
# =========================================================================
# prompt-size command
# =========================================================================
prompt_size_parser = subparsers.add_parser(
"prompt-size",
help="Show a byte breakdown of the system prompt + tool schemas",
description=(
"Report the fixed prompt budget for a fresh session: system "
"prompt total, skills index, memory, user profile, and tool-schema "
"JSON. Runs offline (no API call)."
),
)
prompt_size_parser.add_argument(
"--platform",
default="cli",
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
)
prompt_size_parser.add_argument(
"--json",
action="store_true",
help="Emit the breakdown as JSON",
)
prompt_size_parser.set_defaults(func=cmd_prompt_size)
# =========================================================================
# Parse and execute
# =========================================================================
+46
View File
@@ -205,6 +205,22 @@ def _probe_single_server(
return tools_found
def _oauth_tokens_present(name: str) -> bool:
"""Return True if an OAuth token file exists on disk for ``name``.
Used after ``hermes mcp login`` to distinguish a genuine authentication
from a probe that succeeded only because the server allowed
initialize/tools-list without auth (so no token was ever acquired).
"""
try:
from tools.mcp_oauth import HermesTokenStorage
return HermesTokenStorage(name).has_cached_tokens()
except Exception as exc: # pragma: no cover — defensive
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
# Be permissive on unexpected errors: don't block a real success.
return True
def _unwrap_exception_group(exc: BaseException) -> Exception:
"""Extract the root-cause exception from anyio TaskGroup wrappers.
@@ -631,6 +647,36 @@ def cmd_mcp_login(args):
# Probe triggers the OAuth flow (browser redirect + callback capture).
try:
tools = _probe_single_server(name, server_config)
# A clean probe is NOT proof of authentication. Some MCP servers
# (notably Google's official Drive server) serve initialize +
# tools/list WITHOUT auth, so the probe lists tools even when the
# OAuth flow never completed — e.g. dynamic client registration
# 400'd because the provider doesn't support RFC 7591. Reporting
# "Authenticated — N tools" in that case is a false success: every
# real tool call later hangs until timeout because there's no token.
# Verify a token actually landed on disk before claiming success.
if not _oauth_tokens_present(name):
_warning(
"Server responded, but no OAuth token was obtained — "
"authentication did not complete."
)
print()
_info(
"Some providers (e.g. Google Drive, Atlassian) do not support "
"automatic client registration. For those you must create an "
"OAuth client yourself and add its credentials to config.yaml:"
)
print()
print(color(f" mcp_servers:", Colors.DIM))
print(color(f" {name}:", Colors.DIM))
print(color(f" url: {url}", Colors.DIM))
print(color(f" auth: oauth", Colors.DIM))
print(color(f" oauth:", Colors.DIM))
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
print()
_info("Then re-run `hermes mcp login " + name + "`.")
return
if tools:
_success(f"Authenticated — {len(tools)} tool(s) available")
else:
+59
View File
@@ -0,0 +1,59 @@
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
from __future__ import annotations
import threading
from typing import Optional
_mcp_discovery_lock = threading.Lock()
_mcp_discovery_started = False
_mcp_discovery_thread: Optional[threading.Thread] = None
def _has_configured_mcp_servers() -> bool:
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
try:
from hermes_cli.config import read_raw_config
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
except Exception:
# Be conservative: if config probing fails, try discovery in the
# background so startup still can't block.
return True
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
"""Spawn one shared background MCP discovery thread for this process."""
global _mcp_discovery_started, _mcp_discovery_thread
with _mcp_discovery_lock:
if _mcp_discovery_started:
return
_mcp_discovery_started = True
if not _has_configured_mcp_servers():
return
def _discover() -> None:
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("Background MCP tool discovery failed", exc_info=True)
thread = threading.Thread(
target=_discover,
name=thread_name,
daemon=True,
)
_mcp_discovery_thread = thread
thread.start()
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
"""Briefly wait for background MCP discovery before the first tool snapshot."""
thread = _mcp_discovery_thread
if thread is None or not thread.is_alive():
return
thread.join(timeout=timeout)
+5 -1
View File
@@ -452,7 +452,11 @@ def memory_command(args) -> None:
"""Route memory subcommands."""
sub = getattr(args, "memory_command", None)
if sub == "setup":
cmd_setup(args)
provider = getattr(args, "provider", None)
if provider:
cmd_setup_provider(provider)
else:
cmd_setup(args)
elif sub == "status":
cmd_status(args)
else:
+1 -1
View File
@@ -73,7 +73,7 @@ DEFAULT_CATALOG_URL = (
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
)
DEFAULT_TTL_HOURS = 24
DEFAULT_TTL_HOURS = 1
DEFAULT_FETCH_TIMEOUT = 8.0
SUPPORTED_SCHEMA_VERSION = 1
+57 -43
View File
@@ -1145,8 +1145,12 @@ def list_authenticated_providers(
if slug_norm != current_norm:
return False
try:
from agent.bedrock_adapter import has_aws_credentials
return bool(has_aws_credentials())
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
if has_aws_credentials:
return bool(has_aws_credentials())
return False
except Exception:
return False
@@ -1328,10 +1332,12 @@ def list_authenticated_providers(
# configured.
if not has_creds and hermes_slug == "anthropic":
try:
from agent.anthropic_adapter import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic_ns.get("read_claude_code_credentials")
read_hermes_oauth_credentials = _anthropic_ns.get("read_hermes_oauth_credentials")
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
raise ImportError("anthropic credential readers not registered")
hermes_creds = read_hermes_oauth_credentials()
cc_creds = read_claude_code_credentials()
if (hermes_creds and hermes_creds.get("accessToken")) or \
@@ -1556,24 +1562,21 @@ def list_authenticated_providers(
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
# are grouped into a single picker row, so e.g. four Ollama entries
# pointing at ``http://localhost:11434/v1`` with per-model display names
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
# provider. Entries sharing the same endpoint, credential identity, and
# wire protocol are grouped into a single picker row, so e.g. four Ollama
# entries pointing at ``http://localhost:11434/v1`` with per-model display
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
# "Ollama" row with four models inside instead of four near-duplicates
# that differ only by suffix. Entries with distinct endpoints still
# produce separate rows.
#
# When the grouped endpoint matches ``current_base_url`` the group's
# slug becomes ``current_provider`` so that selecting a model from the
# picker flows back through the runtime provider that already holds
# valid credentials — no re-resolution needed.
# that differ only by suffix. Same-host entries with different ``key_env``
# or ``api_mode`` remain distinct providers.
if custom_providers and isinstance(custom_providers, list):
from collections import OrderedDict
# Key by (base_url, api_key) instead of slug: names frequently
# differ per model ("Ollama — X") while the endpoint stays the
# same. Slug-based grouping left them as separate rows.
# Key by endpoint + credential identity + wire protocol instead of
# slug: names frequently differ per model ("Ollama — X") while the
# endpoint stays the same. Keep same-host providers with distinct
# env-backed credentials or API protocols separate so picker selection
# cannot route through the wrong credential/mode pair.
groups: "OrderedDict[tuple, dict]" = OrderedDict()
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -1588,9 +1591,23 @@ def list_authenticated_providers(
).strip().rstrip("/")
if not raw_name or not api_url:
continue
api_key = (entry.get("api_key") or "").strip()
inline_api_key = (entry.get("api_key") or "").strip()
key_env = (entry.get("key_env") or "").strip()
api_key = inline_api_key or (
os.environ.get(key_env, "").strip() if key_env else ""
)
api_mode = str(
entry.get("api_mode")
or entry.get("transport")
or ""
).strip().lower()
credential_identity = (
inline_api_key
if inline_api_key
else (f"env:{key_env}" if key_env else "")
)
group_key = (api_url, api_key)
group_key = (api_url, credential_identity, api_mode)
if group_key not in groups:
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
# "Ollama" for the grouped row. Em dash is the convention
@@ -1603,29 +1620,16 @@ def list_authenticated_providers(
break
if not display_name:
display_name = raw_name
# If this endpoint matches the currently active one, use
# ``current_provider`` as the slug so picker-driven switches
# route through the live credential pipeline.
if (
current_base_url
and api_url == current_base_url.strip().rstrip("/")
):
# Guard against bare "custom" slug left by a prior
# failed switch — always resolve to the canonical
# custom:<name> form. (GH #17478)
slug = (
current_provider
if current_provider and current_provider != "custom"
else custom_provider_slug(display_name)
)
else:
slug = custom_provider_slug(display_name)
slug = custom_provider_slug(display_name)
groups[group_key] = {
"slug": slug,
"name": display_name,
"api_url": api_url,
"api_key": api_key,
"models": [],
}
elif api_key and not groups[group_key].get("api_key"):
groups[group_key]["api_key"] = api_key
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@@ -1647,8 +1651,16 @@ def list_authenticated_providers(
groups[group_key]["models"].append(m)
_section4_emitted_slugs: set = set()
for grp_key, grp in groups.items():
api_url, api_key = grp_key
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
_current_base_url_group_count = sum(
1
for _grp in groups.values()
if _current_base_url_norm
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
)
for grp in groups.values():
api_url = grp["api_url"]
api_key = grp.get("api_key", "")
slug = grp["slug"]
# If the slug is already claimed by a built-in / overlay /
# user-provider row (sections 1-3), skip this custom group
@@ -1721,8 +1733,10 @@ def list_authenticated_providers(
"slug": slug,
"name": grp["name"],
"is_current": slug == current_provider or (
bool(current_base_url)
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
current_provider == "custom"
and bool(_current_base_url_norm)
and _grp_url_norm == _current_base_url_norm
and _current_base_url_group_count == 1
),
"is_user_defined": True,
"models": grp["models"],
+169 -37
View File
@@ -32,34 +32,43 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("moonshotai/kimi-k2.6", "recommended"),
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
("qwen/qwen3.7-max", ""),
("anthropic/claude-haiku-4.5", ""),
# OpenAI
("openai/gpt-5.5", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
("openai/gpt-5.4-nano", ""),
("openai/gpt-5.3-codex", ""),
("xiaomi/mimo-v2.5-pro", ""),
("tencent/hy3-preview", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
# Google
("google/gemini-3-pro-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("qwen/qwen3.6-35b-a3b", ""),
("stepfun/step-3.7-flash", ""),
("minimax/minimax-m2.7", ""),
("z-ai/glm-5.1", ""),
("x-ai/grok-4.20", ""),
("google/gemini-3.5-flash", ""),
# xAI
("x-ai/grok-4.3", ""),
("nvidia/nemotron-3-super-120b-a12b", ""),
# DeepSeek
("deepseek/deepseek-v4-pro", ""),
("deepseek/deepseek-v4-flash", ""),
# Qwen
("qwen/qwen3.7-max", ""),
("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI
("moonshotai/kimi-k2.6", "recommended"),
# MiniMax
("minimax/minimax-m2.7", ""),
# Z-AI
("z-ai/glm-5.1", ""),
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
# Tencent
("tencent/hy3-preview", ""),
# StepFun
("stepfun/step-3.7-flash", ""),
# NVIDIA
("nvidia/nemotron-3-super-120b-a12b", ""),
# OpenRouter routers
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
@@ -141,31 +150,40 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-opus-4.8",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"moonshotai/kimi-k2.6",
"qwen/qwen3.7-max",
"anthropic/claude-haiku-4.5",
# OpenAI
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.3-codex",
"xiaomi/mimo-v2.5-pro",
"tencent/hy3-preview",
# Google
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.6-35b-a3b",
"stepfun/step-3.7-flash",
"minimax/minimax-m2.7",
"z-ai/glm-5.1",
"google/gemini-3.5-flash",
# xAI
"x-ai/grok-4.3",
"nvidia/nemotron-3-super-120b-a12b",
# DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
# Qwen
"qwen/qwen3.7-max",
"qwen/qwen3.6-35b-a3b",
# MoonshotAI
"moonshotai/kimi-k2.6",
# MiniMax
"minimax/minimax-m2.7",
# Z-AI
"z-ai/glm-5.1",
# Xiaomi
"xiaomi/mimo-v2.5-pro",
# Tencent
"tencent/hy3-preview",
# StepFun
"stepfun/step-3.7-flash",
# NVIDIA
"nvidia/nemotron-3-super-120b-a12b",
],
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
# provider_model_ids fallback when /v1/models is unavailable.
@@ -936,6 +954,105 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
# ---------------------------------------------------------------------------
# Provider groups — DISPLAY ONLY
#
# Some vendors expose several Hermes provider slugs (one per endpoint /
# auth method: global API, China API, OAuth coding plan, ...). Listing every
# slug as a top-level row in the interactive `hermes model` / setup wizard /
# Telegram `/model` pickers makes that list long and noisy.
#
# These groups fold related slugs under one top-level row in INTERACTIVE
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
# every member slug remains individually addressable. Grouping is a pure
# display affordance; ``group_providers()`` is the single fold used by all
# three picker surfaces so they stay consistent.
#
# group_id -> (display_label, [member_slug, ...])
#
# Member order is the order shown inside the group submenu.
# ---------------------------------------------------------------------------
PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = {
"kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]),
"minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]),
"xai": ("xAI Grok", ["xai", "xai-oauth"]),
"google": ("Google Gemini", ["gemini", "google-gemini-cli"]),
"openai": ("OpenAI", ["openai-codex", "openai-api"]),
"opencode": ("OpenCode", ["opencode-zen", "opencode-go"]),
"copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]),
}
# Reverse index: member slug -> group_id. Built once at import.
_SLUG_TO_GROUP: dict[str, str] = {
slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members
}
def provider_group_for_slug(slug: str) -> str:
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
def group_providers(slugs):
"""Fold a flat ordered slug iterable into picker rows by provider group.
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
across surfaces.
Each returned row is a dict::
{"kind": "single", "slug": <slug>} # ungrouped, or
# 1-member group
{"kind": "group", "group_id": <gid>, "label": <label>,
"members": [<slug>, ...]} # 2+ members
Rules:
* A group row appears at the position of its FIRST present member, in
the input order. Subsequent members fold into that row (and are not
emitted again).
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
restricted to the members actually present in ``slugs``.
* A group reduced to a single present member degrades to a ``single``
row no pointless one-item submenu.
* Slugs not in any group pass through as ``single`` rows, order
preserved.
* Duplicate slugs in the input are ignored after first sight.
"""
seen: set[str] = set()
# Which present members each group has, in declaration order.
group_members: dict[str, list[str]] = {}
for gid, (_label, members) in PROVIDER_GROUPS.items():
present = [m for m in members if m in set(slugs)]
if present:
group_members[gid] = present
rows = []
emitted_groups: set[str] = set()
for slug in slugs:
s = str(slug or "").strip().lower()
if not s or s in seen:
continue
seen.add(s)
gid = _SLUG_TO_GROUP.get(s, "")
if not gid:
rows.append({"kind": "single", "slug": s})
continue
if gid in emitted_groups:
continue # already folded at the first member's position
emitted_groups.add(gid)
members = group_members.get(gid, [s])
if len(members) <= 1:
rows.append({"kind": "single", "slug": members[0]})
else:
label, _ = PROVIDER_GROUPS[gid]
rows.append(
{"kind": "group", "group_id": gid, "label": label, "members": list(members)}
)
return rows
_PROVIDER_ALIASES = {
"glm": "zai",
"z-ai": "zai",
@@ -2019,7 +2136,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
# below — bedrock is not expected to appear in that table.
if normalized == "bedrock":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none")
if bedrock_model_ids_or_none is None:
raise ImportError("bedrock_model_ids_or_none not found in bedrock provider")
ids = bedrock_model_ids_or_none()
if ids is not None:
return ids
@@ -2266,7 +2387,14 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
Claude Code auto-discovery). Returns sorted model IDs or None.
"""
try:
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
# Beta header constants live in core agent.anthropic_format.
from agent.anthropic_format import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
if resolve_anthropic_token is None or _is_oauth_token is None:
raise ImportError("anthropic provider services not registered")
except ImportError:
return None
@@ -2278,7 +2406,6 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
is_oauth = _is_oauth_token(token)
if is_oauth:
headers["Authorization"] = f"Bearer {token}"
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = token
@@ -3610,7 +3737,12 @@ def validate_requested_model(
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
if normalized == "bedrock":
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
discover_bedrock_models = _bedrock_ns.get("discover_bedrock_models")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
if discover_bedrock_models is None or resolve_bedrock_region is None:
raise ImportError("bedrock discovery functions not registered")
region = resolve_bedrock_region()
discovered = discover_bedrock_models(region)
discovered_ids = {m["id"] for m in discovered}
+9 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import threading
import time
import urllib.request
from dataclasses import dataclass
@@ -15,6 +16,7 @@ NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "
_ACCOUNT_INFO_CACHE_TTL = 60
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
@dataclass(frozen=True)
@@ -302,10 +304,11 @@ def _fresh_account_info(
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
cache_key = _cache_key(access_token, portal_base_url)
if not force_fresh and _account_info_cache is not None:
cached_key, cached_at, cached_info = _account_info_cache
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
return cached_info
with _ACCOUNT_INFO_CACHE_LOCK:
if not force_fresh and _account_info_cache is not None:
cached_key, cached_at, cached_info = _account_info_cache
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
return cached_info
payload = _fetch_nous_account_info(access_token, portal_base_url)
if not payload:
@@ -327,7 +330,8 @@ def _fresh_account_info(
state=refreshed_state,
portal_base_url=portal_base_url,
)
_account_info_cache = (cache_key, time.monotonic(), info)
with _ACCOUNT_INFO_CACHE_LOCK:
_account_info_cache = (cache_key, time.monotonic(), info)
return info
except Exception as exc:
return _error_info(
+197 -4
View File
@@ -7,7 +7,11 @@ from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.config import get_env_value, load_config
from hermes_cli.nous_account import NousPortalAccountInfo, get_nous_portal_account_info
from hermes_cli.nous_account import (
NousPortalAccountInfo,
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from utils import is_truthy_value
from tools.tool_backend_helpers import (
@@ -71,12 +75,16 @@ class NousSubscriptionFeatures:
def browser(self) -> NousFeatureState:
return self.features["browser"]
@property
def video_gen(self) -> NousFeatureState:
return self.features["video_gen"]
@property
def modal(self) -> NousFeatureState:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "browser", "modal")
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
for key in ordered:
yield self.features[key]
@@ -255,6 +263,7 @@ def get_nous_subscription_features(
web_tool_enabled = _toolset_enabled(config, "web")
image_tool_enabled = _toolset_enabled(config, "image_gen")
video_tool_enabled = _toolset_enabled(config, "video_gen")
tts_tool_enabled = _toolset_enabled(config, "tts")
browser_tool_enabled = _toolset_enabled(config, "browser")
modal_tool_enabled = _toolset_enabled(config, "terminal")
@@ -289,6 +298,8 @@ def get_nous_subscription_features(
browser_use_gateway = _uses_gateway(browser_cfg)
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = _uses_gateway(image_gen_cfg)
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
video_use_gateway = _uses_gateway(video_gen_cfg)
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
@@ -296,6 +307,7 @@ def get_nous_subscription_features(
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
direct_searxng = bool(get_env_value("SEARXNG_URL"))
direct_fal = fal_key_is_configured()
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
direct_openai_tts = bool(resolve_openai_audio_api_key())
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
@@ -311,6 +323,8 @@ def get_nous_subscription_features(
direct_tavily = False
if image_use_gateway:
direct_fal = False
if video_use_gateway:
direct_fal_video = False
if tts_use_gateway:
direct_openai_tts = False
direct_elevenlabs = False
@@ -320,6 +334,8 @@ def get_nous_subscription_features(
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
# Video gen uses the same fal-queue gateway as image gen.
managed_video_available = managed_image_available
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
@@ -357,6 +373,10 @@ def get_nous_subscription_features(
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
image_available = bool(managed_image_available or direct_fal)
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
video_available = bool(managed_video_available or direct_fal_video)
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
@@ -451,6 +471,18 @@ def get_nous_subscription_features(
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
),
"video_gen": NousFeatureState(
key="video_gen",
label="Video generation",
included_by_default=False,
available=video_available,
active=video_active,
managed_by_nous=video_managed,
direct_override=video_active and not video_managed,
toolset_enabled=video_tool_enabled,
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
explicit_configured=direct_fal_video,
),
"tts": NousFeatureState(
key="tts",
label="OpenAI TTS",
@@ -559,8 +591,22 @@ def apply_nous_managed_defaults(
changed.add("browser")
if "image_gen" in selected_toolsets and not fal_key_is_configured():
image_cfg = config.get("image_gen")
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in selected_toolsets and not fal_key_is_configured():
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
@@ -571,6 +617,7 @@ def apply_nous_managed_defaults(
_GATEWAY_TOOL_LABELS = {
"web": "Web search & extract (Firecrawl)",
"image_gen": "Image generation (FAL)",
"video_gen": "Video generation (FAL)",
"tts": "Text-to-speech (OpenAI TTS)",
"browser": "Browser automation (Browser Use)",
}
@@ -578,6 +625,7 @@ _GATEWAY_TOOL_LABELS = {
def _get_gateway_direct_credentials() -> Dict[str, bool]:
"""Return a dict of tool_key -> has_direct_credentials."""
fal_direct = fal_key_is_configured()
return {
"web": bool(
get_env_value("FIRECRAWL_API_KEY")
@@ -586,7 +634,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
or get_env_value("TAVILY_API_KEY")
or get_env_value("EXA_API_KEY")
),
"image_gen": fal_key_is_configured(),
"image_gen": fal_direct,
"video_gen": fal_direct,
"tts": bool(
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
@@ -601,11 +650,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
_GATEWAY_DIRECT_LABELS = {
"web": "Firecrawl/Exa/Parallel/Tavily key",
"image_gen": "FAL key",
"video_gen": "FAL key",
"tts": "OpenAI/ElevenLabs key",
"browser": "Browser Use/Browserbase key",
}
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
def get_gateway_eligible_tools(
@@ -646,6 +696,7 @@ def get_gateway_eligible_tools(
opted_in = {
"web": _uses_gateway(config.get("web")),
"image_gen": _uses_gateway(config.get("image_gen")),
"video_gen": _uses_gateway(config.get("video_gen")),
"tts": _uses_gateway(config.get("tts")),
"browser": _uses_gateway(config.get("browser")),
}
@@ -714,6 +765,15 @@ def apply_gateway_defaults(
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in tool_keys:
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
@@ -826,3 +886,136 @@ def prompt_enable_tool_gateway(
if already_managed and not newly_switched:
print(" (all tools already using Tool Gateway)")
return changed
# ---------------------------------------------------------------------------
# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`)
# ---------------------------------------------------------------------------
def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> bool:
"""Make sure the user has paid Nous Portal access, logging in if needed.
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
login, this:
- does NOT change the inference provider (``model.provider`` is untouched),
- does NOT run model selection, and
- does NOT offer the bulk "enable for all tools" Tool Gateway prompt.
It only performs the Nous Portal device-code OAuth (when the user isn't
already logged in) and refreshes entitlement, so the caller can enable the
single tool the user picked.
Returns ``True`` when the account has paid service access after the flow,
``False`` otherwise (declined login, login failed, or no paid entitlement).
"""
# Fast path: already entitled.
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
if info is not None and info.paid_service_access is True:
return True
# If not logged in at all, run the device-code login (auth only).
if info is None or not info.logged_in:
if not _run_nous_portal_login_only(capability=capability):
return False
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
if info is not None and info.paid_service_access is True:
return True
# Logged in but no paid access — surface billing guidance, do not enable.
message = format_nous_portal_entitlement_message(info, capability=capability)
if message:
for line in message.splitlines():
print(f" {line}")
return False
def _run_nous_portal_login_only(*, capability: str) -> bool:
"""Run the Nous Portal device-code OAuth and persist credentials only.
No model selection, no provider switch, no Tool Gateway bulk prompt.
Returns ``True`` on a successful login, ``False`` if the user declined or
the flow failed.
"""
try:
from hermes_cli.auth import (
_auth_store_lock,
_load_auth_store,
_nous_device_code_login,
_read_shared_nous_state,
_save_auth_store,
_save_provider_state,
_sync_nous_pool_from_auth_store,
_try_import_shared_nous_state,
_write_shared_nous_state,
)
except Exception as exc: # pragma: no cover - defensive
print(f" Could not start Nous Portal login: {exc}")
return False
print()
print(f" {capability} requires a Nous Portal login.")
try:
proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
if proceed not in {"", "y", "yes"}:
print(" Skipped Nous Portal login.")
return False
try:
# Snapshot the active_provider so a tool-config login never silently
# switches the user's inference provider to Nous.
with _auth_store_lock():
prior_active_provider = _load_auth_store().get("active_provider")
auth_state = None
shared = _read_shared_nous_state()
if shared:
try:
do_import = input(
" Found existing Nous OAuth credentials. Import them? [Y/n]: "
).strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in {"", "y", "yes"}:
auth_state = _try_import_shared_nous_state(timeout_seconds=15.0)
if auth_state is None:
auth_state = _nous_device_code_login()
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", auth_state)
# Preserve the user's existing inference provider — this login is
# for tool entitlement only, not a provider switch.
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
_save_auth_store(auth_store)
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print(" Nous Portal login successful.")
return True
except KeyboardInterrupt:
print("\n Login cancelled.")
return False
except SystemExit:
# _nous_device_code_login raises SystemExit on subscription_required;
# it already printed billing guidance.
return False
except Exception as exc:
print(f" Nous Portal login failed: {exc}")
return False
+39 -12
View File
@@ -174,28 +174,55 @@ def run_oneshot(
# Redirect stderr AND stdout to devnull for the entire call tree.
# We'll print the final response to the real stdout at the end.
real_stdout = sys.stdout
real_stderr = sys.stderr
devnull = open(os.devnull, "w", encoding="utf-8")
response: Optional[str] = None
failure: BaseException | None = None
try:
with redirect_stdout(devnull), redirect_stderr(devnull):
response = _run_agent(
prompt,
model=model,
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
)
try:
response = _run_agent(
prompt,
model=model,
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
)
except BaseException as exc: # noqa: BLE001
# Capture anything that escapes the agent (including OSError
# from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,
# KeyboardInterrupt, SystemExit, etc.) so we can surface it on
# the real stderr instead of crashing past the redirect with a
# traceback that the caller never sees. A silent exit in a
# cron / SSH / subprocess context is the worst failure mode.
# See #30623.
failure = exc
finally:
try:
devnull.close()
except Exception:
pass
if response:
real_stdout.write(response)
if not response.endswith("\n"):
real_stdout.write("\n")
real_stdout.flush()
if failure is not None:
# Re-raise control-flow exceptions so the parent handles them as usual
# (Ctrl-C / explicit sys.exit() inside the agent).
if isinstance(failure, (KeyboardInterrupt, SystemExit)):
raise failure
real_stderr.write(f"hermes -z: agent failed: {failure}\n")
real_stderr.flush()
return 1
if not (response or "").strip():
real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
real_stderr.flush()
return 1
assert response is not None # narrowed by the empty-response guard above
real_stdout.write(response)
if not response.endswith("\n"):
real_stdout.write("\n")
real_stdout.flush()
return 0
+235
View File
@@ -0,0 +1,235 @@
"""Boundary-aware partial compression — "summarize up to here".
Inspired by Claude Code's Rewind menu "Summarize up to here" action
(v2.1.139v2.1.142, Week 20, May 2026):
https://code.claude.com/docs/en/whats-new/2026-w20
Hermes already has ``/compress`` (full-history compaction) and an
automatic token-budget tail-protection heuristic inside
``ContextCompressor``. What was missing is *user-chosen* boundary
control: "fold everything before this point into a summary, but keep
my most recent N exchanges exactly as they are." That is the value of
the Claude Code feature the user decides the compression boundary
instead of leaving it to the token-budget heuristic.
This module owns the pure, side-effect-free split logic so both the
CLI (``cli.py::_manual_compress``) and the gateway
(``gateway/run.py::_handle_compress_command``) share one
implementation. The slash-command surfaces handle compression of the
*head* via the existing ``_compress_context`` pipeline (preserving all
the session-rotation / lock / memory-notify machinery) and then
re-append the verbatim *tail* returned here.
Design notes / invariants honored:
* **Role alternation.** The compressed head ends with summary/handoff
content (assistant- or user-role, possibly a trailing todo snapshot).
The verbatim tail must begin with a ``user`` message so the rejoined
history keeps the userassistant alternation that providers validate.
:func:`split_history_for_partial_compress` snaps the tail boundary
backwards to the nearest ``user`` turn so the rejoin is always legal.
* **No silent context mutation.** This is a manual, user-invoked
action. It rotates the session exactly like ``/compress`` does (via
the caller), so the prompt-cache reset is explicit and expected, not
silent.
* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user
turn plus its following assistant/tool turns), defaulting to 2. The
split never compresses if doing so would leave nothing in the head.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
#: Default number of recent exchanges to preserve verbatim when the user
#: runs ``/compress here`` without an explicit count.
DEFAULT_KEEP_LAST = 2
#: Hard ceiling so a fat-fingered ``/compress here 9999`` doesn't turn
#: into a no-op surprise — clamp instead.
MAX_KEEP_LAST = 100
def parse_partial_compress_args(
raw_args: str,
) -> Tuple[bool, int, Optional[str]]:
"""Parse the argument string after ``/compress``.
Recognizes the boundary-aware forms:
* ``here`` partial compress, keep ``DEFAULT_KEEP_LAST``
* ``here 4`` partial compress, keep 4 exchanges
* ``--keep 4`` partial compress, keep 4 exchanges
* ``up to here`` alias for ``here`` (matches Claude Code's
menu label "Summarize up to here")
Anything else is treated as a focus topic for the existing full
``/compress <focus>`` behavior.
Returns ``(partial, keep_last, focus_topic)``:
* ``partial`` True when a boundary-aware form was requested.
* ``keep_last`` exchanges to preserve verbatim (only meaningful
when ``partial`` is True).
* ``focus_topic`` focus string for full compression, or None.
Always None when ``partial`` is True (the two modes are exclusive;
a focused partial compress is not a documented Claude Code
behavior and would muddy the UX).
"""
text = (raw_args or "").strip()
if not text:
return False, DEFAULT_KEEP_LAST, None
lowered = text.lower()
# Normalize the "up to here" alias to "here".
if lowered.startswith("up to here"):
lowered = lowered[len("up to ") :]
text = text[len("up to ") :]
tokens = lowered.split()
# Form: here [N]
if tokens and tokens[0] == "here":
keep = DEFAULT_KEEP_LAST
if len(tokens) >= 2:
keep = _coerce_keep(tokens[1])
return True, keep, None
# Form: --keep N (or --keep=N)
if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2:
return True, _coerce_keep(tokens[1]), None
if tokens and tokens[0].startswith("--keep="):
return True, _coerce_keep(tokens[0].split("=", 1)[1]), None
# Otherwise: full compression with this as the focus topic.
return False, DEFAULT_KEEP_LAST, text or None
def _coerce_keep(value: str) -> int:
"""Parse a keep-count token, clamping to [1, MAX_KEEP_LAST]."""
try:
n = int(value)
except (TypeError, ValueError):
return DEFAULT_KEEP_LAST
if n < 1:
return 1
if n > MAX_KEEP_LAST:
return MAX_KEEP_LAST
return n
def split_history_for_partial_compress(
history: List[Dict[str, Any]],
keep_last: int,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Split ``history`` into ``(head, tail)`` for partial compression.
``head`` is the earlier portion that will be summarized; ``tail`` is
the most recent ``keep_last`` exchanges, preserved verbatim.
An *exchange* is counted by ``user``-role messages: keeping N
exchanges means keeping everything from the Nth-most-recent ``user``
message onward. This guarantees the tail starts on a ``user`` turn,
so when the caller rejoins ``compressed_head + tail`` the
userassistant alternation stays valid (the compressed head's
trailing content is followed by a fresh user turn).
Returns ``(head, tail)``. If the split would leave the head empty
(not enough history to compress meaningfully), returns
``(history, [])`` signaling the caller to fall back to full
compression or report "nothing to do".
"""
if keep_last < 1:
keep_last = 1
n = len(history)
if n == 0:
return [], []
# Walk backwards collecting the indices of the most recent `keep_last`
# user-message starts. The tail begins at the earliest such index.
user_starts: List[int] = []
for idx in range(n - 1, -1, -1):
if history[idx].get("role") == "user":
user_starts.append(idx)
if len(user_starts) >= keep_last:
break
if not user_starts:
# No user turns at all (degenerate) — nothing sensible to keep
# as a "recent exchange"; treat as full compression.
return list(history), []
boundary = user_starts[-1] # earliest of the kept user starts
head = history[:boundary]
tail = history[boundary:]
# If everything is in the tail (nothing left to compress), signal the
# caller to fall back to full compression rather than producing a
# no-op that rotates the session for no benefit.
if not head:
return list(history), []
return head, tail
def rejoin_compressed_head_and_tail(
compressed_head: List[Dict[str, Any]],
tail: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Concatenate a compressed head with the verbatim tail, defending
the seam against an illegal useruser / assistantassistant adjacency.
In normal operation the compressed head ends with the head's own
protected verbatim tail (the ``ContextCompressor`` always preserves a
recent window), which terminates on an ``assistant``/``tool`` turn
so ``assistant user`` at the seam is already valid. But the head
compressor's exact output shape is not contractually guaranteed (a
plugin context engine could return something that ends on a ``user``
turn, or a degenerate single-summary message). Rather than trust the
seam, this helper inspects the boundary and, if the last head message
and the first tail message share a ``user``/``assistant`` role, folds
the tail's first message content onto the head's last message so the
rejoined list never violates provider role-alternation rules.
``tool`` messages are left alone consecutive ``tool`` entries are
the one legal repetition (parallel tool results).
"""
if not tail:
return list(compressed_head)
if not compressed_head:
return list(tail)
head = list(compressed_head)
rest = list(tail)
last = head[-1]
first = rest[0]
last_role = last.get("role")
first_role = first.get("role")
if last_role == first_role and last_role in ("user", "assistant"):
# Illegal adjacency. Merge the tail's first message text into the
# head's last message so alternation is preserved. Only string
# contents are merged inline; structured/multimodal contents fall
# back to dropping the redundant standalone (the content is
# preserved by concatenation when both are strings).
last_content = last.get("content")
first_content = first.get("content")
if isinstance(last_content, str) and isinstance(first_content, str):
merged = dict(last)
merged["content"] = f"{last_content}\n\n{first_content}"
head[-1] = merged
rest = rest[1:]
else:
# Can't safely string-merge multimodal content. Insert a
# minimal bridging turn so the seam alternates rather than
# losing data.
bridge_role = "assistant" if first_role == "user" else "user"
head.append({"role": bridge_role, "content": ""})
return head + rest
+285 -25
View File
@@ -817,6 +817,270 @@ class PluginContext:
name,
)
# -- auth provider registration -------------------------------------------
def register_platform_entry(
self,
name: str,
adapter_class: type,
check_requirements: Callable,
available_flag: str = "",
constants: dict | None = None,
helper_functions: dict | None = None,
) -> None:
"""Register a platform adapter entry in the capability registries.
This populates ``agent.plugin_registries.registries.platform_adapters``
so core code can look up adapter classes, constants, and helper
functions without importing from ``hermes_agent_*`` packages directly.
Call this **in addition to** :meth:`register_platform` the two
registries serve different consumers:
* ``register_platform`` ``gateway.platform_registry`` (gateway
adapter creation, setup wizard, status)
* ``register_platform_entry`` ``agent.plugin_registries`` (adapter
class access, constants, helpers for send_message_tool, etc.)
Args:
name: Platform identifier (e.g. ``"telegram"``).
adapter_class: The adapter class itself (e.g. ``TelegramAdapter``).
check_requirements: Callable returning ``bool`` are deps installed?
available_flag: Name of the module-level AVAILABLE boolean, if any.
constants: Platform-specific constants (e.g.
``{"FEISHU_DOMAIN": ..., "LARK_DOMAIN": ...}``).
helper_functions: Platform-specific helpers (e.g.
``{"_strip_mdv2": _strip_mdv2, "qr_register": qr_register}``).
"""
from agent.plugin_registries import registries, PlatformAdapterEntry
entry = PlatformAdapterEntry(
name=name,
adapter_class=adapter_class,
check_requirements=check_requirements,
available_flag=available_flag,
constants=constants or {},
helper_functions=helper_functions or {},
)
registries.register_platform(entry)
logger.debug(
"Plugin %s registered platform entry: %s",
self.manifest.name,
name,
)
def register_tool_provider_entry(
self,
name: str,
tool_functions: dict | None = None,
check_fn: Callable | None = None,
constants: dict | None = None,
config_functions: dict | None = None,
environment_classes: dict | None = None,
) -> None:
"""Register a tool provider entry in the capability registries.
This populates ``agent.plugin_registries.registries.tool_providers``
so core code can look up tool functions, constants, and config
helpers without importing from ``hermes_agent_*`` packages directly.
Args:
name: Tool identifier (e.g. ``"tts"``, ``"stt"``).
tool_functions: Dict of function name callable
(e.g. ``{"text_to_speech_tool": text_to_speech_tool}``).
check_fn: Optional callable returning ``bool`` are deps
installed and configured?
constants: Tool-specific constants
(e.g. ``{"MAX_FILE_SIZE": 25 * 1024 * 1024}``).
config_functions: Config/utility functions
(e.g. ``{"is_stt_enabled": is_stt_enabled}``).
environment_classes: Environment classes for terminal backends
(e.g. ``{"DaytonaEnvironment": DaytonaEnvironment}``).
"""
from agent.plugin_registries import registries, ToolProviderEntry
entry = ToolProviderEntry(
name=name,
tool_functions=tool_functions or {},
check_fn=check_fn,
constants=constants or {},
config_functions=config_functions or {},
environment_classes=environment_classes or {},
)
registries.register_tool_provider(entry)
logger.debug(
"Plugin %s registered tool provider entry: %s",
self.manifest.name,
name,
)
def register_provider_services(
self,
name: str,
services: dict,
) -> None:
"""Register a namespace dict of provider-specific services.
This is the escape hatch for model-provider plugins that expose many
symbols (anthropic has 50+). Each plugin registers its public surface
as a flat dict of ``{symbol_name: callable_or_value}``. Core code
looks up specific symbols instead of importing from the plugin
package directly.
Args:
name: Provider identifier (e.g. ``"anthropic"``, ``"bedrock"``).
services: Dict of symbol name callable or value.
"""
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
logger.debug(
"Plugin %s registered provider services: %s (%d symbols)",
self.manifest.name,
name,
len(services),
)
def register_auth_provider(
self,
name: str,
provider: Any,
*,
cli_group: str = "",
setup_subcommands: bool = False,
) -> None:
"""Register an authentication provider.
``provider`` must implement the :class:`agent.plugin_registries.AuthProvider`
protocol (``name``, ``has_credentials``, ``check_env_vars``,
``resolve_token``, ``refresh_token``). It may also expose
provider-specific attributes (``_is_oauth_token``,
``_HERMES_OAUTH_FILE``, ``read_claude_code_credentials``, etc.)
that core code accesses via the registry.
Registered providers are queried by core code via
``registries.get_auth_provider(name)`` instead of importing
directly from ``hermes_agent_*`` packages.
"""
from agent.plugin_registries import registries
registries.register_auth_provider(
name, provider,
cli_group=cli_group,
setup_subcommands=setup_subcommands,
)
logger.debug(
"Plugin %s registered auth provider: %s",
self.manifest.name, name,
)
def register_provider_resolver(
self,
name: str,
resolver: Any,
) -> None:
"""Register a provider resolver callable.
The resolver handles ALL provider-specific client construction
logic for auxiliary tasks. Core's ``resolve_provider_client()``
dispatches to it instead of using per-provider if/elif branches.
Signature::
def resolver(
*,
model: str | None,
explicit_api_key: str | None,
explicit_base_url: str | None,
async_mode: bool,
is_vision: bool,
main_runtime: dict | None,
api_mode: str | None,
) -> tuple[Any, str] | tuple[None, None]:
...
Returns ``(client, default_model)`` or ``(None, None)``.
"""
from agent.plugin_registries import registries
registries.register_provider_resolver(name, resolver)
logger.debug(
"Plugin %s registered provider resolver: %s",
self.manifest.name, name,
)
def register_transport(
self,
api_mode: str,
transport_cls: type,
) -> None:
"""Register a ProviderTransport class for an api_mode string.
This lets the transport registry discover provider transports
from plugins without core needing to import the plugin package.
"""
from agent.plugin_registries import registries
registries._transports[api_mode] = transport_cls
logger.debug(
"Plugin %s registered transport: %s%s",
self.manifest.name, api_mode, transport_cls.__name__,
)
def register_credential_pool_hook(
self,
name: str,
hook: Any,
) -> None:
"""Register a credential pool hook for provider-specific pool operations.
The hook should be a :class:`agent.plugin_registries.CredentialPoolHook`
instance with optional ``sync_from_credentials_file``,
``refresh_oauth``, and ``should_include_in_pool`` callables.
"""
from agent.plugin_registries import registries
registries.register_credential_pool_hook(name, hook)
logger.debug(
"Plugin %s registered credential pool hook: %s",
self.manifest.name, name,
)
def register_pricing_provider(
self,
name: str,
entries: list,
) -> None:
"""Register pricing entries for a provider.
``entries`` should be a list of
:class:`agent.plugin_registries.PricingEntry` instances.
"""
from agent.plugin_registries import registries
registries.register_pricing_provider(name, entries)
logger.debug(
"Plugin %s registered pricing provider: %s (%d entries)",
self.manifest.name, name, len(entries),
)
def register_provider_overlay(
self,
entry: Any,
) -> None:
"""Register a provider overlay entry.
``entry`` should be a :class:`agent.plugin_registries.ProviderOverlayEntry`
instance.
"""
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
logger.debug(
"Plugin %s registered provider overlay: %s",
self.manifest.name, entry.provider_name,
)
# -- hook registration --------------------------------------------------
# -- auxiliary task registration ---------------------------------------
@@ -1073,6 +1337,11 @@ class PluginManager:
)
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
bundled_providers = self._scan_directory(
repo_plugins / "model-providers", source="bundled"
)
logger.debug(" bundled/model-providers: %d manifest(s)", len(bundled_providers))
manifests.extend(bundled_providers)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
@@ -1109,7 +1378,16 @@ class PluginManager:
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
winners[manifest.key or manifest.name] = manifest
key = manifest.key or manifest.name
existing = winners.get(key)
# Bundled/workspace plugins take precedence over entry-points
# for the same key — the local source is the one we're
# actively developing; the entry-point is the published
# version. Only let entry-points fill gaps where no bundled
# version exists.
if existing is not None and existing.source == "bundled" and manifest.source != "bundled":
continue
winners[key] = manifest
for manifest in winners.values():
lookup_key = manifest.key or manifest.name
@@ -1137,30 +1415,12 @@ class PluginManager:
)
continue
# Model provider plugins are loaded by providers/__init__.py
# (its own lazy discovery keyed off first get_provider_profile()
# call). We record the manifest here for introspection but do
# not import the module — a second import would create two
# ProviderProfile instances and break the "last writer wins"
# override semantics between bundled and user plugins.
if manifest.kind == "model-provider":
loaded = LoadedPlugin(manifest=manifest, enabled=True)
self._plugins[lookup_key] = loaded
logger.debug(
"Skipping '%s' (model-provider, handled by providers/ discovery)",
lookup_key,
)
continue
# Built-in backends auto-load — they ship with hermes and must
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``<category>.provider`` config,
# enforced by the tool wrapper.
#
# Bundled platform plugins (gateway adapters like IRC) auto-load
# for the same reason: every platform Hermes ships must be
# available out of the box without the user having to opt in.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}:
# Model provider plugins auto-load just like backends and
# platforms. They register their provider services (auth,
# transport, metadata) via ctx.register_provider_services()
# in their register() function, which populates the
# capability registries that core code queries.
if manifest.source == "bundled" and manifest.kind in {"backend", "platform", "model-provider"}:
self._load_plugin(manifest)
continue
+65 -30
View File
@@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
# Check existing commands in PATH
wrapper_dir = _get_wrapper_dir()
is_windows = sys.platform == "win32"
try:
result = subprocess.run(
["which", canon], capture_output=True, text=True, timeout=5,
["where" if is_windows else "which", canon],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
existing_path = result.stdout.strip()
existing_path = result.stdout.strip().splitlines()[0]
# Allow overwriting our own wrappers
if existing_path == str(wrapper_dir / canon):
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
if existing_path == str(expected):
try:
content = (wrapper_dir / canon).read_text()
content = expected.read_text()
if "hermes -p" in content:
return None # it's our wrapper, safe to overwrite
except Exception:
@@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
def create_wrapper_script(name: str) -> Optional[Path]:
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
"""Create a shell wrapper script at ~/.local/bin/<name>.
The wrapper file is named after ``name`` (the alias). The profile it
activates is ``target`` if given, otherwise ``name`` this lets a custom
alias name point at a differently-named profile without a post-hoc rewrite.
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
Returns the path to the created wrapper, or None if creation failed.
"""
canon = normalize_profile_name(name)
profile = normalize_profile_name(target) if target else canon
wrapper_dir = _get_wrapper_dir()
try:
wrapper_dir.mkdir(parents=True, exist_ok=True)
@@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:
print(f"⚠ Could not create {wrapper_dir}: {e}")
return None
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
is_windows = sys.platform == "win32"
if is_windows:
wrapper_path = wrapper_dir / f"{canon}.bat"
try:
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
else:
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
return None
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
wrapper_dir = _get_wrapper_dir()
canon = normalize_profile_name(name)
is_windows = sys.platform == "win32"
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
return False
@@ -1443,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
"""Rename Honcho host blocks for a renamed profile without changing peers."""
old_host = f"hermes.{old_name}"
new_host = f"hermes.{new_name}"
old_host = f"hermes_{old_name}"
legacy_old_host = f"hermes.{old_name}"
new_host = f"hermes_{new_name}"
candidates = [
new_dir / "honcho.json",
@@ -1468,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
continue
hosts = raw.get("hosts")
if not isinstance(hosts, dict) or old_host not in hosts:
if not isinstance(hosts, dict):
continue
source_host = old_host if old_host in hosts else legacy_old_host
if source_host not in hosts:
continue
if new_host in hosts:
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
continue
block = hosts[old_host]
block = hosts[source_host]
if isinstance(block, dict) and "aiPeer" not in block:
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
if source_host.startswith("hermes_"):
bare = source_host.split("_", 1)[1]
else:
bare = source_host.split(".", 1)[1] if "." in source_host else source_host
block["aiPeer"] = bare
hosts[new_host] = hosts.pop(old_host)
hosts[new_host] = hosts.pop(source_host)
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -1491,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
pass
continue
print(f"✓ Honcho host updated: {old_host}{new_host}")
print(f"✓ Honcho host updated: {source_host}{new_host}")
def rename_profile(old_name: str, new_name: str) -> Path:
+153
View File
@@ -0,0 +1,153 @@
"""Prompt-size diagnostic: ``hermes prompt-size``.
Reports a byte/char breakdown of the system prompt the agent would build for
a fresh session system prompt total, the ``<available_skills>`` index,
memory + user profile, and tool-schema JSON. Lets users see where their fixed
prompt budget goes (issue #34667) without parsing a saved session JSON by hand.
The diagnostic builds a real inspection agent (so the numbers match what
actually ships on the wire) but never makes a network call: it passes dummy
credentials so ``AIAgent.__init__`` takes the direct-construction path, then
calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Tuple
# The skills index is wrapped in this tag pair inside the stable tier.
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
def _bytes(s: str) -> int:
return len(s.encode("utf-8"))
def _build_inspection_agent(platform: str) -> Any:
"""Construct an offline AIAgent for prompt inspection.
Dummy ``api_key`` + ``base_url`` force the direct-construction path in
``run_agent.py`` (no provider auto-detection, no network). Toolsets and
platform come from the caller so the breakdown matches a real session.
"""
from run_agent import AIAgent
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
model = model_cfg.get("default") or model_cfg.get("model") or ""
return AIAgent(
model=model,
api_key="inspect-only",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
save_trajectories=False,
platform=platform,
)
def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]:
"""Return a dict of prompt-size measurements for a fresh session.
Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``,
``user_profile``, ``tools`` (count + json bytes), and ``sections`` (a list
of (label, chars, bytes) for the three prompt tiers).
"""
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
agent = _build_inspection_agent(platform)
parts = build_system_prompt_parts(agent)
full = build_system_prompt(agent)
stable = parts.get("stable", "")
context = parts.get("context", "")
volatile = parts.get("volatile", "")
# Skills index — the <available_skills> block (the largest single block
# when many skills are installed). Measured inside the stable tier.
skills_match = _SKILLS_BLOCK_RE.search(stable)
skills_index = skills_match.group(0) if skills_match else ""
# Memory + user profile live in the volatile tier. We re-derive their
# blocks directly from the memory store so the numbers are attributable
# even though they're joined into ``volatile``.
memory_block = ""
user_block = ""
store = getattr(agent, "_memory_store", None)
if store is not None:
try:
if getattr(agent, "_memory_enabled", True):
memory_block = store.format_for_system_prompt("memory") or ""
if getattr(agent, "_user_profile_enabled", True):
user_block = store.format_for_system_prompt("user") or ""
except Exception:
pass
# Tool-schema JSON — the other half of the fixed per-call payload.
tools = getattr(agent, "tools", None) or []
tools_json = json.dumps(tools, ensure_ascii=False)
sections: List[Tuple[str, int, int]] = [
("stable (identity/guidance/skills)", len(stable), _bytes(stable)),
("context (AGENTS.md/cwd files)", len(context), _bytes(context)),
("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)),
]
return {
"platform": platform,
"model": getattr(agent, "model", "") or "",
"system_prompt": {"chars": len(full), "bytes": _bytes(full)},
"skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)},
"memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)},
"user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)},
"tools": {"count": len(tools), "json_bytes": _bytes(tools_json)},
"sections": sections,
}
def _fmt_kb(n: int) -> str:
return f"{n / 1024:.1f} KB"
def render_breakdown(data: Dict[str, Any]) -> str:
"""Render the breakdown as plain text suitable for a terminal."""
lines: List[str] = []
sp = data["system_prompt"]
lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})")
lines.append("")
lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)")
lines.append("")
lines.append(" Major blocks:")
si = data["skills_index"]
mem = data["memory"]
up = data["user_profile"]
lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})")
lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})")
lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})")
lines.append("")
lines.append(" Prompt tiers:")
for label, chars, byts in data["sections"]:
lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})")
lines.append("")
tools = data["tools"]
lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)")
return "\n".join(lines)
def cmd_prompt_size(args: Any) -> None:
"""Entry point for ``hermes prompt-size``."""
platform = getattr(args, "platform", "cli") or "cli"
as_json = getattr(args, "json", False)
try:
data = compute_prompt_breakdown(platform)
except Exception as e:
print(f"Could not compute prompt-size breakdown: {e}")
return
if as_json:
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(render_breakdown(data))
+40 -17
View File
@@ -99,10 +99,8 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
),
"anthropic": HermesOverlay(
transport="anthropic_messages",
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
# "anthropic" overlay moved to plugin: hermes_agent_anthropic register()
# Plugin registers via ctx.register_provider_overlay() and core merges lazily.
"zai": HermesOverlay(
transport="openai_chat",
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
@@ -204,17 +202,45 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
),
# Azure Foundry: supports both OpenAI-style and Anthropic-style endpoints.
# The transport is determined at runtime from config.yaml model.api_mode.
"azure-foundry": HermesOverlay(
transport="openai_chat", # default; overridden by api_mode in config
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
),
"bedrock": HermesOverlay(
transport="bedrock_converse",
auth_type="aws_sdk",
),
# "azure-foundry" overlay moved to plugin: hermes_agent_azure register()
# "bedrock" overlay moved to plugin: hermes_agent_bedrock register()
# Plugins register via ctx.register_provider_overlay() and core merges lazily.
}
def _merge_plugin_overlays() -> None:
"""Merge plugin-registered provider overlays into HERMES_OVERLAYS.
Called lazily from ``resolve_provider`` so that plugins have had a
chance to register by the time we need the overlay data.
"""
global _plugin_overlays_merged
if _plugin_overlays_merged:
return
_plugin_overlays_merged = True
try:
from agent.plugin_registries import registries
for _name, _entry in registries.all_provider_overlays().items():
if _name not in HERMES_OVERLAYS:
HERMES_OVERLAYS[_name] = HermesOverlay(
transport=_entry.transport,
is_aggregator=_entry.is_aggregator,
auth_type=_entry.auth_type,
extra_env_vars=_entry.extra_env_vars,
base_url_override=_entry.base_url_override,
base_url_env_var=_entry.base_url_env_var,
)
# Also merge aliases from the plugin overlay entry
for _alias in _entry.aliases:
if _alias not in ALIASES:
ALIASES[_alias] = _name
except Exception:
pass
_plugin_overlays_merged = False
# -- Resolved provider -------------------------------------------------------
# The merged result of models.dev + overlay + user config.
@@ -335,11 +361,7 @@ ALIASES: Dict[str, str] = {
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
# bedrock
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",
"amazon": "bedrock",
# bedrock aliases moved to plugin: hermes_agent_bedrock register()
# arcee
"arcee-ai": "arcee",
@@ -426,6 +448,7 @@ def get_provider(name: str) -> Optional[ProviderDef]:
except Exception:
mdev_info = None
_merge_plugin_overlays()
overlay = HERMES_OVERLAYS.get(canonical)
if mdev_info is not None:
+25 -13
View File
@@ -976,11 +976,13 @@ def _resolve_azure_foundry_runtime(
auth_mode = "api_key"
else:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
build_token_provider,
)
from agent.plugin_registries import registries
_azure_ns = registries.get_provider_namespace("azure")
EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig")
SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT")
build_token_provider = _azure_ns.get("build_token_provider")
if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider]):
raise ImportError("azure provider services not fully registered")
except Exception as exc:
raise AuthError(
"Azure Foundry Entra ID auth requires the 'azure-identity' "
@@ -1072,7 +1074,11 @@ def _resolve_explicit_runtime(
base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com"
api_key = explicit_api_key
if not api_key:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
api_key = resolve_anthropic_token()
if not api_key:
@@ -1516,7 +1522,11 @@ def resolve_runtime_provider(
"config.yaml model section at a custom env var."
)
else:
from agent.anthropic_adapter import resolve_anthropic_token
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
if resolve_anthropic_token is None:
raise ImportError("anthropic provider services not registered")
token = resolve_anthropic_token()
if not token:
raise AuthError(
@@ -1534,12 +1544,14 @@ def resolve_runtime_provider(
# AWS Bedrock (native Converse API via boto3)
if provider == "bedrock":
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
is_anthropic_bedrock_model,
)
from agent.plugin_registries import registries
_bedrock_ns = registries.get_provider_namespace("bedrock")
has_aws_credentials = _bedrock_ns.get("has_aws_credentials")
resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var")
resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region")
is_anthropic_bedrock_model = _bedrock_ns.get("is_anthropic_bedrock_model")
if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, is_anthropic_bedrock_model]):
raise ImportError("bedrock provider services not fully registered")
# When the user explicitly selected bedrock (not auto-detected),
# trust boto3's credential chain — it handles IMDS, ECS task roles,
# Lambda execution roles, SSO, and other implicit sources that our
+3 -1
View File
@@ -104,7 +104,9 @@ ADVISORIES: tuple[Advisory, ...] = (
"them to a hardcoded webhook. If you ran any Python process that "
"imported mistralai 2.4.6 — including hermes when configured "
"with provider=mistral for TTS or STT — assume those credentials "
"are exposed."
"are exposed. PyPI has since removed 2.4.6 and the project ships "
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
"the compromised 2.4.6 is still installed."
),
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
compromised=(
+113 -295
View File
@@ -305,7 +305,7 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
appended at the end the user toggles items with Space and confirms
with Enter on "Continue →".
Falls back to a numbered toggle interface when simple_term_menu is
Falls back to a numbered toggle interface when curses is
unavailable.
Returns:
@@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home):
# Video generation — opt-in via `hermes tools` → Video Generation.
# Only show the row when a plugin reports available so we don't badger
# users who don't care about video gen with a "missing" status line.
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
if subscription_features.video_gen.managed_by_nous:
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
else:
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
# TTS — show configured provider
tts_provider = cfg_get(config, "tts", "provider", default="edge")
@@ -734,175 +737,15 @@ def setup_model_provider(config: dict, *, quick: bool = False):
if isinstance(_m, dict):
selected_provider = _m.get("provider")
# ── Same-provider fallback & rotation setup (full setup only) ──
if not quick and _supports_same_provider_pool_setup(selected_provider):
try:
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_add_command
pool = load_pool(selected_provider)
entries = pool.entries()
entry_count = len(entries)
manual_count = sum(1 for entry in entries if str(getattr(entry, "source", "")).startswith("manual"))
auto_count = entry_count - manual_count
print()
print_header("Same-Provider Fallback & Rotation")
print_info(
"Hermes can keep multiple credentials for one provider and rotate between"
)
print_info(
"them when a credential is exhausted or rate-limited. This preserves"
)
print_info(
"your primary provider while reducing interruptions from quota issues."
)
print()
if auto_count > 0:
print_info(
f"Current pooled credentials for {selected_provider}: {entry_count} "
f"({manual_count} manual, {auto_count} auto-detected from env/shared auth)"
)
else:
print_info(f"Current pooled credentials for {selected_provider}: {entry_count}")
while prompt_yes_no("Add another credential for same-provider fallback?", False):
auth_add_command(
SimpleNamespace(
provider=selected_provider,
auth_type="",
label=None,
api_key=None,
portal_url=None,
inference_url=None,
client_id=None,
scope=None,
no_browser=False,
timeout=15.0,
insecure=False,
ca_bundle=None,
)
)
pool = load_pool(selected_provider)
entry_count = len(pool.entries())
print_info(f"Provider pool now has {entry_count} credential(s).")
if entry_count > 1:
strategy_labels = [
"Fill-first / sticky — keep using the first healthy credential until it is exhausted",
"Round robin — rotate to the next healthy credential after each selection",
"Random — pick a random healthy credential each time",
]
current_strategy = _get_credential_pool_strategies(config).get(selected_provider, "fill_first")
default_strategy_idx = {
"fill_first": 0,
"round_robin": 1,
"random": 2,
}.get(current_strategy, 0)
strategy_idx = prompt_choice(
"Select same-provider rotation strategy:",
strategy_labels,
default_strategy_idx,
)
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
_set_credential_pool_strategy(config, selected_provider, strategy_value)
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
except Exception as exc:
logger.debug("Could not configure same-provider fallback in setup: %s", exc)
# ── Vision & Image Analysis Setup (full setup only) ──
if quick:
_vision_needs_setup = False
else:
try:
from agent.auxiliary_client import get_available_vision_backends
_vision_backends = set(get_available_vision_backends())
except Exception:
_vision_backends = set()
_vision_needs_setup = not bool(_vision_backends)
if selected_provider in _vision_backends:
_vision_needs_setup = False
if _vision_needs_setup:
_prov_names = {
"nous-api": "Nous Portal API key",
"copilot": "GitHub Copilot",
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"stepfun": "StepFun Step Plan",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
print()
print_header("Vision & Image Analysis (optional)")
print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
print_info("doesn't currently provide one Hermes can auto-use for vision,")
print_info("so choose a backend now or skip and configure later.")
print()
_vision_choices = [
"OpenRouter — uses Gemini (free tier at openrouter.ai/keys)",
"OpenAI-compatible endpoint — base URL, API key, and vision model",
"Skip for now",
]
_vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)
if _vision_idx == 0: # OpenRouter
_or_key = prompt(" OpenRouter API key", password=True).strip()
if _or_key:
save_env_value("OPENROUTER_API_KEY", _or_key)
print_success("OpenRouter key saved — vision will use Gemini")
else:
print_info("Skipped — vision won't be available")
elif _vision_idx == 1: # OpenAI-compatible endpoint
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
_api_key_label = " API key"
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
if _is_native_openai:
_api_key_label = " OpenAI API key"
_oai_key = prompt(_api_key_label, password=True).strip()
if _oai_key:
save_env_value("OPENAI_API_KEY", _oai_key)
# Save vision base URL to config (not .env — only secrets go there)
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
_vaux["base_url"] = _base_url
if _is_native_openai:
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
_selected_vision_model = (
_oai_vision_models[_vm_idx]
if _vm_idx < len(_oai_vision_models)
else "gpt-4o-mini"
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
)
else:
print_info("Skipped — vision won't be available")
else:
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
# Credential rotation, vision-backend selection, and TTS provider are no
# longer prompted here. They have safe defaults (rotation off, vision
# auto-detected from the main provider, TTS = Edge) and are configurable
# on demand via `hermes auth add`, `hermes setup` vision, and
# `hermes setup tts`. This keeps both quick and full setup thin.
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
if not quick and selected_provider != "nous":
_setup_tts_provider(config)
# =============================================================================
# Section 1b: TTS Provider Configuration
@@ -1338,29 +1181,9 @@ def setup_terminal_backend(config: dict):
if selected_backend == "local":
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# Gateway/cron working directory
print()
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
# Sudo support
print()
existing_sudo = get_env_value("SUDO_PASSWORD")
if existing_sudo:
print_info("Sudo password: configured")
elif prompt_yes_no(
"Enable sudo support? (stores password for apt install, etc.)", False
):
sudo_pass = prompt(" Sudo password", password=True)
if sudo_pass:
save_env_value("SUDO_PASSWORD", sudo_pass)
print_success("Sudo password saved")
# Gateway working directory defaults to home; sudo stays off. Both are
# configurable later via `hermes setup terminal` / config.yaml.
config["terminal"].setdefault("cwd", str(Path.home()))
elif selected_backend == "docker":
print_success("Terminal backend: Docker")
@@ -1373,13 +1196,10 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Docker found: {docker_bin}")
# Docker image
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Docker image", current_image)
config["terminal"]["docker_image"] = image
save_env_value("TERMINAL_DOCKER_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
@@ -1394,12 +1214,11 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Found: {sing_bin}")
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Container image", current_image)
config["terminal"]["singularity_image"] = image
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"singularity_image",
"docker://nikolaik/python-nodejs:python3.11-nodejs20",
)
elif selected_backend == "modal":
print_success("Terminal backend: Modal")
@@ -1498,8 +1317,6 @@ def setup_terminal_backend(config: dict):
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
_prompt_container_resources(config)
elif selected_backend == "daytona":
print_success("Terminal backend: Daytona")
print_info("Persistent cloud development environments.")
@@ -1549,13 +1366,10 @@ def setup_terminal_backend(config: dict):
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Configured")
# Daytona image
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Sandbox image", current_image)
config["terminal"]["daytona_image"] = image
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
@@ -1622,7 +1436,7 @@ def setup_terminal_backend(config: dict):
def _apply_default_agent_settings(config: dict):
"""Apply recommended defaults for all agent settings without prompting."""
config.setdefault("agent", {})["max_turns"] = 90
config.setdefault("agent", {})["max_turns"] = 150
# config.yaml is the authoritative source for max_turns; the gateway
# bridges it into HERMES_MAX_ITERATIONS at startup. We no longer write
# to .env to avoid the dual-source inconsistency that caused the
@@ -1634,18 +1448,17 @@ def _apply_default_agent_settings(config: dict):
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
config.setdefault("session_reset", {}).update({
"mode": "both",
"idle_minutes": 1440,
"at_hour": 4,
})
# Default to never auto-resetting sessions. The gateway treats absent
# session_reset as "both", so we must write "none" explicitly to make
# the no-auto-reset default actually take effect.
config.setdefault("session_reset", {})["mode"] = "none"
save_config(config)
print_success("Applied recommended defaults:")
print_info(" Max iterations: 90")
print_info(" Max iterations: 150")
print_info(" Tool progress: all")
print_info(" Compression threshold: 0.50")
print_info(" Session reset: inactivity (1440 min) + daily (4:00)")
print_info(" Session reset: never (use /reset or compression)")
print_info(" Run `hermes setup agent` later to customize.")
@@ -2050,59 +1863,32 @@ def _setup_matrix():
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
# Use the central lazy-deps feature group so we install ALL of
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
# aiosqlite uninstalled and broke E2EE connect with
# ``No module named 'asyncpg'`` on every fresh install (#31116).
matrix_pkg = "hermes-agent[matrix]"
# Matrix deps are now a proper plugin package. Install it the normal way.
try:
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
_missing_before = feature_missing("platform.matrix")
if _missing_before:
print_info(
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
)
try:
_lazy_ensure("platform.matrix", prompt=False)
print_success(f"{matrix_pkg} installed")
except Exception as exc:
print_warning(
f"Install failed — run manually: pip install "
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
f"aiohttp-socks"
)
print_info(f" Error: {exc}")
__import__("hermes_agent_matrix")
except ImportError:
# tools.lazy_deps unavailable (extreme edge case — partial
# install). Fall back to the legacy single-package install
# path so the wizard still does *something*.
try:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install '{matrix_pkg}'"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print()
print_info("🔒 Security: Restrict who can use your bot")
@@ -3194,7 +2980,7 @@ def run_setup_wizard(args):
config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)",
"Quick Setup (Nous Portal) — OAuth login, model & messaging (recommended)",
"Full setup — configure everything",
], 0)
@@ -3225,9 +3011,11 @@ def run_setup_wizard(args):
if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
setup_terminal_backend(config)
# Section 3: Agent Settings
if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
setup_agent_settings(config)
# Section 3: Agent Settings — no longer prompted. First installs get the
# recommended defaults silently; existing installs keep whatever they have.
# Tune later with `hermes setup agent`.
if not is_existing:
_apply_default_agent_settings(config)
# Section 4: Messaging Platforms
if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
@@ -3247,13 +3035,43 @@ def run_setup_wizard(args):
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
"""Streamlined first-time setup: provider, model, terminal & messaging.
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.
Applies sensible defaults for TTS (Edge), agent settings, and tools
the user can customize later via ``hermes setup <section>``.
Routes straight to the Nous Portal provider runs the device-code OAuth
login, picks a Nous model, then configures the terminal backend and (optionally)
a messaging platform. Applies sensible defaults for everything else (agent
settings, tools); the user can customize later via ``hermes setup <section>``
or switch providers with ``hermes model``.
"""
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True)
from hermes_cli.config import load_config
# Step 1: Nous Portal — OAuth login + model selection.
# _model_flow_nous() handles both the logged-out path (device-code OAuth,
# which selects a model internally) and the already-logged-in path (curated
# Nous model picker). Provider is set to "nous" by the login/model save.
print()
print_header("Nous Portal")
print_info("One subscription, 300+ models, plus the Tool Gateway:")
print_info(" web search, image generation, TTS, browser automation.")
print_info("Sign up: https://portal.nousresearch.com/manage-subscription")
print()
try:
from hermes_cli.main import _model_flow_nous
_model_flow_nous(config)
except (KeyboardInterrupt, EOFError):
print()
print_info("Nous Portal setup cancelled.")
except Exception as exc:
logger.debug("_model_flow_nous error during quick setup: %s", exc)
print_warning(f"Nous Portal setup encountered an error: {exc}")
print_info("You can try again later with: hermes model")
# Re-sync the wizard's config dict from disk — _model_flow_nous (and the
# underlying login/model save) write via their own load/save cycle, and the
# wizard's later save_config(config) must not clobber those values (#4172).
_refreshed = load_config()
config.clear()
config.update(_refreshed)
# Step 2: Terminal Backend — where commands run is a core decision
setup_terminal_backend(config)
+3 -4
View File
@@ -215,7 +215,7 @@ TIPS = [
# --- Context & Compression ---
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
"The status bar turns yellow, then orange, then red as context fills up.",
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
"SOUL.md is the agent's primary identity file — customize it to shape behavior.",
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
"Context files are capped at 20,000 characters with smart head/tail truncation.",
@@ -273,7 +273,7 @@ TIPS = [
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
"SOUL.md is auto-seeded with a default personality on first run. Edit it to customize.",
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
@@ -430,7 +430,7 @@ TIPS = [
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
'hermes chat --ignore-user-config skips the active user config — reproducible bug reports and CI runs.',
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
@@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
"""
return random.choice(TIPS)
+120 -50
View File
@@ -244,9 +244,16 @@ TOOL_CATEGORIES = {
],
"tts_provider": "elevenlabs",
},
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
# package is currently quarantined (malicious 2.4.6 release on
# 2026-05-12). Restore this entry once PyPI un-quarantines.
# Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use.
{
"name": "Mistral (Voxtral TTS)",
"badge": "paid",
"tag": "Multilingual, native Opus",
"env_vars": [
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
],
"tts_provider": "mistral",
},
{
"name": "Google Gemini TTS",
"badge": "preview",
@@ -339,11 +346,26 @@ TOOL_CATEGORIES = {
"video_gen": {
"name": "Video Generation",
"icon": "🎬",
# Providers list is intentionally empty — every video gen backend
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
# injected by ``_visible_providers``. Mirrors the design we'll
# converge image_gen toward.
"providers": [],
# "Nous Subscription" row mirrors the image_gen pattern — managed
# FAL video generation billed via the Nous Portal. Plugin-backed
# provider rows (FAL BYOK, xAI, …) are injected at runtime by
# ``_plugin_video_gen_providers()`` in ``_visible_providers``.
"providers": [
{
"name": "Nous Subscription",
"badge": "subscription",
"tag": "Managed FAL video generation billed to your subscription",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "video_gen",
"override_env_vars": ["FAL_KEY"],
# The underlying plugin backend — when the user picks
# "Nous Subscription" we set video_gen.provider = "fal"
# and video_gen.use_gateway = True so the FAL plugin
# routes through the managed queue gateway.
"video_gen_plugin_name": "fal",
},
],
},
"x_search": {
"name": "X (Twitter) Search",
@@ -1438,7 +1460,7 @@ def _toolset_has_keys(
except Exception:
return False
if ts_key in {"web", "image_gen", "tts", "browser"}:
if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
feature = features.features.get(ts_key)
if feature and (feature.available or feature.managed_by_nous):
@@ -1854,18 +1876,26 @@ def _visible_providers(
*,
force_fresh: bool = False,
) -> list[dict]:
"""Return provider entries visible for the current auth/config state."""
"""Return provider entries visible for the current auth/config state.
Nous-managed Tool Gateway rows (``managed_nous_feature``) are always
shown even to logged-out / unentitled users so the picker advertises
that the capability exists. Selecting one drives an inline Nous Portal
login + entitlement check (see ``_configure_provider``); the row only
*activates* the gateway once paid access is confirmed.
"""
features = get_nous_subscription_features(config, force_fresh=force_fresh)
managed_available = bool(
features.account_info
and features.account_info.logged_in
and features.account_info.paid_service_access is True
)
visible = []
for provider in cat.get("providers", []):
if provider.get("managed_nous_feature") and not managed_available:
continue
if provider.get("requires_nous_auth") and not features.nous_auth_present:
# Nous-managed Tool Gateway rows stay visible regardless of auth —
# selecting one drives an inline Portal login. A `requires_nous_auth`
# row that is NOT a managed gateway feature (pure pre-auth UX) is
# still hidden until the user is logged in.
if (
provider.get("requires_nous_auth")
and not provider.get("managed_nous_feature")
and not features.nous_auth_present
):
continue
visible.append(provider)
@@ -1911,22 +1941,16 @@ def _hidden_nous_gateway_message(
*,
force_fresh: bool = False,
) -> str:
"""Return a reason when a category's Nous provider is hidden."""
features = get_nous_subscription_features(config, force_fresh=force_fresh)
managed_available = bool(
features.account_info
and features.account_info.logged_in
and features.account_info.paid_service_access is True
)
if managed_available:
return ""
if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])):
return ""
message = format_nous_portal_entitlement_message(
features.account_info,
capability=capability,
)
return message or ""
"""Deprecated: Nous Tool Gateway rows are no longer hidden.
Previously this returned a "log in / upgrade" banner shown above a
category when its Nous-managed rows were filtered out for unentitled
users. Those rows are now always listed (see ``_visible_providers``), and
the login + entitlement guidance happens inline when the user selects one
(``ensure_nous_portal_access``). Kept as a no-op so call sites stay simple;
always returns an empty string.
"""
return ""
_POST_SETUP_INSTALLED: dict = {
@@ -2110,14 +2134,17 @@ def _configure_tool_category(
configured = ""
else:
configured = " [configured]"
# Highlight Nous-managed entries when the user has Portal auth.
# curses_radiolist can't render ANSI inside item strings, so we
# use a plain unicode star + parenthetical phrase. Suppressed
# when no Portal auth is present so non-subscribers see the
# picker unchanged.
# Mark Nous-managed entries. Logged-in paid subscribers get the
# "included" star; everyone else gets a "via Nous Portal" hint so
# it's clear selecting the row triggers a Portal login. The rows
# are always shown now (see _visible_providers) — selecting one
# drives an inline login + entitlement check.
sub_marker = ""
if _nous_logged_in and p.get("managed_nous_feature"):
sub_marker = " ★ Included with your Nous subscription"
if p.get("managed_nous_feature"):
if _nous_logged_in:
sub_marker = " ★ Included with your Nous subscription"
else:
sub_marker = " ★ via Nous Portal (login on select)"
provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}")
# Add skip option
@@ -2153,7 +2180,7 @@ def _is_provider_active(
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
video_plugin_name = provider.get("video_gen_plugin_name")
if video_plugin_name:
if video_plugin_name and not provider.get("managed_nous_feature"):
video_cfg = config.get("video_gen", {})
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
@@ -2172,6 +2199,15 @@ def _is_provider_active(
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
return False
return feature.managed_by_nous
if managed_feature == "video_gen":
video_cfg = config.get("video_gen", {})
if isinstance(video_cfg, dict):
configured_provider = video_cfg.get("provider")
if configured_provider not in {None, "", "fal"}:
return False
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
return False
return feature.managed_by_nous
if provider.get("tts_provider"):
return (
feature.managed_by_nous
@@ -2505,14 +2541,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None
_print_success(f" Model set to: {chosen}")
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed video generation provider selection."""
vid_cfg = config.setdefault("video_gen", {})
if not isinstance(vid_cfg, dict):
vid_cfg = {}
config["video_gen"] = vid_cfg
vid_cfg["provider"] = plugin_name
vid_cfg["use_gateway"] = False
vid_cfg["use_gateway"] = use_gateway
_print_success(f" video_gen.provider set to: {plugin_name}")
_configure_videogen_model_for_plugin(plugin_name, config)
@@ -2527,7 +2563,26 @@ def _configure_provider(
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
# Nous-managed Tool Gateway backends are always listed (see
# _visible_providers), but only *activate* once the user has paid Nous
# Portal access. Selecting one runs an inline Portal login when needed —
# auth + entitlement only, no inference-provider switch and no bulk
# "enable all tools" prompt (that lives in `hermes model`).
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
)
return
# Pure pre-auth UX rows (requires_nous_auth without a managed gateway
# feature) keep the old gate. Managed rows are handled by the inline
# login above, so don't double-check them here.
if provider.get("requires_nous_auth") and not managed_feature:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
entitled = bool(
features.account_info and features.account_info.paid_service_access is True
@@ -2597,7 +2652,7 @@ def _configure_provider(
# registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection after backend pick.
backend = provider.get("imagegen_backend")
@@ -2676,7 +2731,7 @@ def _configure_provider(
return
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection after env vars are in.
backend = provider.get("imagegen_backend")
@@ -2891,7 +2946,22 @@ def _reconfigure_provider(
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
# Same inline Nous Portal login + entitlement gate as _configure_provider:
# managed Tool Gateway backends only activate with paid Portal access.
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
)
return
# Pure pre-auth UX rows keep the old gate; managed rows already handled
# by the inline login above.
if provider.get("requires_nous_auth") and not managed_feature:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
entitled = bool(
features.account_info and features.account_info.paid_service_access is True
@@ -2957,7 +3027,7 @@ def _reconfigure_provider(
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
# Imagegen backends prompt for model selection on reconfig too.
backend = provider.get("imagegen_backend")
@@ -2997,7 +3067,7 @@ def _reconfigure_provider(
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
return
backend = provider.get("imagegen_backend")
+54
View File
@@ -117,6 +117,49 @@ def remove_wrapper_script():
return removed
def remove_node_symlinks(hermes_home: Path) -> list:
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
creates::
~/.local/bin/node -> $HERMES_HOME/node/bin/node
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
manager such as nvm. Symmetrically remove them on uninstall, but *only*
when the link still resolves into this Hermes home's ``node`` directory.
A link the user has since repointed at nvm (or anything else outside
Hermes) is left untouched so we never break unrelated tooling.
"""
node_dir = (hermes_home / "node").resolve()
removed = []
for name in ("node", "npm", "npx"):
link = Path.home() / ".local" / "bin" / name
try:
# Only act on symlinks — never delete a real binary the user put here.
if not link.is_symlink():
continue
# Resolve the link target and confirm it points into our node dir.
# os.readlink + manual join handles broken (dangling) links too;
# Path.resolve() on a dangling link still returns the target path.
target = Path(os.readlink(link))
if not target.is_absolute():
target = (link.parent / target)
target = target.resolve()
if target == node_dir or node_dir in target.parents:
link.unlink()
removed.append(link)
except Exception as e:
log_warn(f"Could not remove {link}: {e}")
return removed
def uninstall_gateway_service():
"""Stop and uninstall the gateway service (systemd, launchd, Windows
Scheduled Task / Startup folder) and kill any standalone gateway processes.
@@ -594,6 +637,17 @@ def run_uninstall(args):
log_success(f"Removed {wrapper}")
else:
log_info("No wrapper script found")
# 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin
# (only when they still point into this Hermes home's node dir, so we
# never clobber an existing nvm / user-managed Node).
log_info("Removing Hermes-managed node/npm/npx symlinks...")
removed_node_links = remove_node_symlinks(hermes_home)
if removed_node_links:
for link in removed_node_links:
log_success(f"Removed {link}")
else:
log_info("No Hermes-managed node/npm/npx symlinks found")
# 4. Remove installation directory (code)
log_info("Removing installation directory...")
+5 -1
View File
@@ -779,7 +779,9 @@ def speak_text(text: str) -> None:
_debug(f"speak_text: TTS begin (paused_recording={paused_recording})")
try:
from tools.tts_tool import text_to_speech_tool
from agent.plugin_registries import registries
_tts = registries.get_tool_provider("tts")
text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
@@ -806,6 +808,8 @@ def speak_text(text: str) -> None:
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
)
if text_to_speech_tool is None:
raise ImportError("TTS plugin not registered")
_debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}")
text_to_speech_tool(text=tts_text, output_path=mp3_path)
+53 -36
View File
@@ -58,22 +58,10 @@ try:
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
# First try lazy-installing the dashboard extras. Only the user actually
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
# them out of every other install path. After install, re-import.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.dashboard", prompt=False)
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except Exception:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
)
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
"Install with: pip install 'hermes-agent[dashboard]'"
)
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
@@ -320,9 +308,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"stt.provider": {
"type": "select",
"description": "Speech-to-text provider",
# "mistral" temporarily removed — mistralai PyPI package quarantined
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
"options": ["local", "openai"],
"options": ["local", "openai", "mistral"],
},
"display.skin": {
"type": "select",
@@ -1374,11 +1360,13 @@ def _anthropic_oauth_status() -> Dict[str, Any]:
The dashboard reports the highest-priority source that's actually present.
"""
try:
from agent.anthropic_adapter import (
read_hermes_oauth_credentials,
read_claude_code_credentials,
_HERMES_OAUTH_FILE,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_hermes_oauth_credentials = _anthropic.get("read_hermes_oauth_credentials")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if read_hermes_oauth_credentials is None:
raise ImportError("anthropic plugin not registered")
except ImportError:
read_claude_code_credentials = None # type: ignore
read_hermes_oauth_credentials = None # type: ignore
@@ -1437,7 +1425,11 @@ def _claude_code_only_status() -> Dict[str, Any]:
when they also have a separate Hermes-managed PKCE login.
"""
try:
from agent.anthropic_adapter import read_claude_code_credentials
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
read_claude_code_credentials = _anthropic.get("read_claude_code_credentials")
if read_claude_code_credentials is None:
raise ImportError("anthropic plugin not registered")
creds = read_claude_code_credentials()
except Exception:
creds = None
@@ -1623,8 +1615,10 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
# want to undo a disconnect.
if provider_id in {"anthropic", "claude-code"}:
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is not None and _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
except Exception:
pass
@@ -1691,13 +1685,15 @@ _oauth_sessions_lock = threading.Lock()
# Guarded so hermes web still starts if anthropic_adapter is unavailable;
# Phase 2 endpoints will return 501 in that case.
try:
from agent.anthropic_adapter import (
_OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
_OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
_OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
_OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
_generate_pkce as _generate_pkce_pair,
)
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_ANTHROPIC_OAUTH_CLIENT_ID = _anthropic.get("_OAUTH_CLIENT_ID")
_ANTHROPIC_OAUTH_TOKEN_URL = _anthropic.get("_OAUTH_TOKEN_URL")
_ANTHROPIC_OAUTH_REDIRECT_URI = _anthropic.get("_OAUTH_REDIRECT_URI")
_ANTHROPIC_OAUTH_SCOPES = _anthropic.get("_OAUTH_SCOPES")
_generate_pkce_pair = _anthropic.get("_generate_pkce")
if any(v is None for v in [_ANTHROPIC_OAUTH_CLIENT_ID, _ANTHROPIC_OAUTH_TOKEN_URL, _ANTHROPIC_OAUTH_REDIRECT_URI, _ANTHROPIC_OAUTH_SCOPES, _generate_pkce_pair]):
raise ImportError("anthropic plugin not registered")
_ANTHROPIC_OAUTH_AVAILABLE = True
except ImportError:
_ANTHROPIC_OAUTH_AVAILABLE = False
@@ -1735,7 +1731,11 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
Mirrors what auth_commands.add_command does so the dashboard flow leaves
the system in the same state as ``hermes auth add anthropic``.
"""
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
_HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE")
if _HERMES_OAUTH_FILE is None:
raise ImportError("anthropic plugin not registered")
payload = {
"accessToken": access_token,
"refreshToken": refresh_token,
@@ -3373,10 +3373,19 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""Check if the WebSocket client IP is acceptable.
Loopback mode: only loopback clients allowed the legacy
Loopback bind: only loopback clients allowed the legacy
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
don't want LAN hosts guessing tokens.
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
specific address such as a Tailscale/LAN IP, always with
``--insecure``): allow any peer. The operator explicitly opted into
non-loopback exposure, so the loopback-only peer restriction does not
apply. DNS-rebinding is still blocked by the Host/Origin guard in
:func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and
requires the Host header to match the bound interface the same
defence ``_is_accepted_host`` applies to non-loopback HTTP requests.
Gated mode: any peer is allowed uvicorn's ``proxy_headers=True``
(enabled when the OAuth gate is active so cookies can pick up
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
@@ -3387,6 +3396,14 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""
if getattr(app.state, "auth_required", False):
return True
# Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /
# Tailscale address) means the operator opted into non-loopback
# access via --insecure. The loopback-only peer gate only applies to
# an actual loopback bind; otherwise the WS handshake is rejected even
# though same-bind HTTP requests pass _is_accepted_host.
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
if bound_host and bound_host not in _LOOPBACK_HOSTS:
return True
client_host = ws.client.host if ws.client else ""
if not client_host:
return True
+287 -62
View File
@@ -72,6 +72,15 @@ _last_init_error_lock = threading.Lock()
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
_FTS_TRIGGERS = (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
)
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
@@ -380,6 +389,8 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
try:
self._conn = sqlite3.connect(
str(self.db_path),
@@ -388,7 +399,6 @@ class SessionDB:
# handles contention instead of sitting in SQLite's internal
# busy handler for up to 30s.
timeout=1.0,
# Autocommit mode: Python's default isolation_level=""
# auto-starts transactions on DML, which conflicts with our
# explicit BEGIN IMMEDIATE. None = we manage transactions
# ourselves.
@@ -417,6 +427,111 @@ class SessionDB:
# ── Core write helper ──
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
return "no such module" in err and "fts5" in err
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
if self._fts_unavailable_warned:
return
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
exc,
)
def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool:
try:
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass
@staticmethod
def _fts_trigger_count(cursor: sqlite3.Cursor) -> int:
placeholders = ",".join("?" for _ in _FTS_TRIGGERS)
row = cursor.execute(
f"SELECT COUNT(*) FROM sqlite_master "
f"WHERE type = 'trigger' AND name IN ({placeholders})",
_FTS_TRIGGERS,
).fetchone()
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
@staticmethod
def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None:
for table_name in ("messages_fts", "messages_fts_trigram"):
cursor.execute(f"DELETE FROM {table_name}")
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]:
try:
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
return True
except sqlite3.OperationalError as exc:
if self._is_fts5_unavailable_error(exc):
self._warn_fts5_unavailable(exc)
return None
if "no such table" in str(exc).lower():
return False
raise
def _ensure_fts_schema(
self,
cursor: sqlite3.Cursor,
table_name: str,
ddl: str,
) -> bool:
status = self._fts_table_probe(cursor, table_name)
if status is None:
return False
try:
# Run even when the virtual table exists so any dropped or missing
# triggers are recreated after a previous no-FTS5 runtime disabled
# them to keep message writes working.
cursor.executescript(ddl)
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
@@ -629,6 +744,16 @@ class SessionDB:
except sqlite3.OperationalError as exc:
logger.debug("idx_messages_platform_msg_id create skipped: %s", exc)
fts5_available = self._sqlite_supports_fts5(cursor)
fts_migrations_complete = True
if not fts5_available:
# Existing FTS triggers can still fire on messages INSERT/UPDATE
# even though the current sqlite runtime cannot read the virtual
# tables they target. Drop only the triggers so core persistence
# continues; if a future runtime has FTS5, _ensure_fts_schema()
# recreates them.
self._drop_fts_triggers(cursor)
# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
@@ -650,17 +775,24 @@ class SessionDB:
# virtual table + triggers are created unconditionally via
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
# backfill into the FTS index.
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
_fts_trigram_exists = True
except sqlite3.OperationalError:
_fts_trigram_exists = False
if not _fts_trigram_exists:
cursor.executescript(FTS_TRIGRAM_SQL)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
if fts5_available:
_fts_trigram_exists = self._fts_table_probe(
cursor, "messages_fts_trigram"
)
if _fts_trigram_exists is False:
if self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
):
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
)
else:
fts_migrations_complete = False
elif _fts_trigram_exists is None:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < 11:
# v11: re-index FTS5 tables to cover tool_name + tool_calls and
# switch from external-content to inline mode. Existing DBs have
@@ -668,45 +800,50 @@ class SessionDB:
# overwrite, so we drop them explicitly and let the post-migration
# existence checks (below) recreate them from FTS_SQL /
# FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
for _trig in (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
except sqlite3.OperationalError:
pass
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError:
pass
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
cursor.executescript(FTS_SQL)
cursor.executescript(FTS_TRIGRAM_SQL)
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
if current_version < SCHEMA_VERSION:
if fts5_available:
self._drop_fts_triggers(cursor)
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
fts5_available = False
fts_migrations_complete = False
break
if fts5_available:
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
if (
self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
and self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
):
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
else:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < SCHEMA_VERSION and fts_migrations_complete:
cursor.execute(
"UPDATE schema_version SET version = ?",
(SCHEMA_VERSION,),
@@ -721,17 +858,22 @@ class SessionDB:
except sqlite3.OperationalError:
pass # Index already exists
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
try:
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_SQL)
if fts5_available:
# FTS5 setup. Run the DDL even when the virtual table exists so
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
# an earlier no-FTS5 runtime.
triggers_need_repair = self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
self._fts_enabled = self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
# Trigram FTS5 for CJK/substring search
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
except sqlite3.OperationalError:
cursor.executescript(FTS_TRIGRAM_SQL)
# Trigram FTS5 for CJK/substring search. This is optional relative
# to the main FTS table; if it cannot be created, CJK search falls
# back to LIKE.
if self._fts_enabled:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
if trigram_enabled and triggers_need_repair:
self._rebuild_fts_indexes(cursor)
self._conn.commit()
@@ -935,6 +1077,20 @@ class SessionDB:
)
self._execute_write(_do)
def update_session_model(self, session_id: str, model: str) -> None:
"""Update the model for a session after a mid-session switch.
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
(only filling in NULL), this unconditionally sets the model column
so that the dashboard reflects the user's latest /model choice.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model = ? WHERE id = ?",
(model, session_id),
)
self._execute_write(_do)
def update_token_counts(
self,
session_id: str,
@@ -2317,6 +2473,9 @@ class SessionDB:
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
FTS5 path.
"""
if not self._fts_enabled:
return []
if not query or not query.strip():
return []
@@ -3251,7 +3410,59 @@ class SessionDB:
# ── Space reclamation ──
def vacuum(self) -> None:
# FTS5 virtual tables whose b-tree segments we merge on optimize. The
# trigram table is created lazily / may be disabled, so we probe before
# touching it (see optimize_fts).
_FTS_TABLES = ("messages_fts", "messages_fts_trigram")
def _fts_table_exists(self, name: str) -> bool:
"""True if an FTS5 virtual table is queryable in this DB."""
try:
self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
return True
except sqlite3.OperationalError:
return False
def optimize_fts(self) -> int:
"""Merge fragmented FTS5 b-tree segments into one per index.
FTS5 indexes grow as a series of incremental segments one per
``INSERT`` batch driven by the message triggers. Over tens of
thousands of messages these segments accumulate, which both bloats
the ``*_data`` shadow tables and slows ``MATCH`` queries that must
scan every segment. The special ``'optimize'`` command rewrites each
index as a single merged segment.
This is purely a maintenance operation it changes neither search
results nor ``snippet()`` output, only on-disk layout and query
speed. It is complementary to VACUUM: ``optimize`` compacts the FTS
index internally, then VACUUM returns the freed pages to the OS.
Skips any FTS table that does not exist (e.g. the trigram index when
disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so
it is safe to call unconditionally.
Returns the number of FTS indexes that were optimized.
"""
optimized = 0
with self._lock:
for tbl in self._FTS_TABLES:
if not self._fts_table_exists(tbl):
continue
try:
# The column name in the INSERT must match the table name
# for FTS5 special commands.
self._conn.execute(
f"INSERT INTO {tbl}({tbl}) VALUES('optimize')"
)
optimized += 1
except sqlite3.OperationalError as exc:
logger.warning(
"FTS optimize failed for %s: %s", tbl, exc
)
return optimized
def vacuum(self) -> int:
"""Run VACUUM to reclaim disk space after large deletes.
SQLite does not shrink the database file when rows are deleted
@@ -3264,7 +3475,21 @@ class SessionDB:
exclusive lock, so callers must ensure no other writers are
active. Safe to call at startup before the gateway/CLI starts
serving traffic.
FTS5 segments are merged first via :meth:`optimize_fts` so the
subsequent VACUUM reclaims the pages freed by the merge. This is a
layout-only optimization search results are unchanged.
Returns the number of FTS indexes that were optimized (0 if the
merge step failed or no FTS tables exist).
"""
# Merge FTS5 segments before VACUUM so the freed pages are returned
# to the OS in the same pass. optimize_fts() manages its own lock.
optimized = 0
try:
optimized = self.optimize_fts()
except Exception as exc:
logger.warning("FTS optimize before VACUUM failed: %s", exc)
# VACUUM cannot be executed inside a transaction.
with self._lock:
# Best-effort WAL checkpoint first, then VACUUM.
@@ -3273,6 +3498,7 @@ class SessionDB:
except Exception:
pass
self._conn.execute("VACUUM")
return optimized
def maybe_auto_prune_and_vacuum(
self,
@@ -3446,4 +3672,3 @@ class SessionDB:
(error[:500], session_id),
)
self._execute_write(_do)
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Titel:** {title}"
created: "**Geskep:** {timestamp}"
last_activity: "**Laaste aktiwiteit:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
agent_running: "**Agent loop:** {state}"
state_yes: "Ja ⚡"
state_no: "Nee"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Titel:** {title}"
created: "**Erstellt:** {timestamp}"
last_activity: "**Letzte Aktivität:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
agent_running: "**Agent läuft:** {state}"
state_yes: "Ja ⚡"
state_no: "Nein"
+1 -1
View File
@@ -270,7 +270,7 @@ gateway:
title: "**Title:** {title}"
created: "**Created:** {timestamp}"
last_activity: "**Last Activity:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
agent_running: "**Agent Running:** {state}"
state_yes: "Yes ⚡"
state_no: "No"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Título:** {title}"
created: "**Creado:** {timestamp}"
last_activity: "**Última actividad:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
agent_running: "**Agente activo:** {state}"
state_yes: "Sí ⚡"
state_no: "No"
+1 -1
View File
@@ -255,7 +255,7 @@ gateway:
title: "**Título:** {title}"
created: "**Criada:** {timestamp}"
last_activity: "**Última atividade:** {timestamp}"
tokens: "**Tokens:** {tokens}"
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
agent_running: "**Agente em execução:** {state}"
state_yes: "Sim ⚡"
state_no: "Não"
+9 -2
View File
@@ -143,8 +143,15 @@ def create_environment(
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
elif env_type == "modal":
from tools.environments.modal import ModalEnvironment
return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
from agent.plugin_registries import registries
_modal = registries.get_tool_provider("modal")
_ModalEnvironment = _modal.environment_classes.get("ModalEnvironment") if _modal else None
if _ModalEnvironment is None:
raise ValueError(
"Modal backend selected but the hermes_agent_modal plugin is not loaded. "
"Ensure the modal plugin is installed and enabled."
)
return _ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
else:
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")
+233
View File
@@ -260,6 +260,239 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
# ── Plugin architecture (hermetic core boundary) ───────────────────
#
# These checks prove that under NixOS (sealed venv, no pip install),
# the plugin system works correctly:
# 1. Core never imports from hermes_agent_* packages directly
# 2. Plugin registries are populated after discovery
# 3. Provider service namespaces are queryable
# 4. Optional plugins degrade gracefully (None returns, no crash)
# 5. No ensure() / lazy_deps / pip-install at runtime
# Check 1: Zero direct hermes_agent_* imports in core code
plugin-hermetic-boundary = pkgs.runCommand "hermes-plugin-hermetic-boundary" { } ''
set -e
echo "=== Checking core never imports from plugin packages ==="
# Search for direct imports from hermes_agent_* in core code
# (excluding plugins/, tests/, website/, and comments)
VIOLATIONS=$(${hermesVenv}/bin/python3 -c '
import subprocess, re, sys
result = subprocess.run(
["grep", "-rn",
"from hermes_agent_\\|import hermes_agent_",
"${hermes-agent}/share/hermes-agent"],
capture_output=True, text=True
)
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
# Filter: only .py files, not in plugins/ or tests/, not comments
violations = []
for line in lines:
if not line.endswith(".py"):
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
filepath, lineno, content = parts
# Skip plugin directories
if "/plugins/" in filepath:
continue
# Skip test directories
if "/tests/" in filepath or "/test_" in filepath:
continue
# Skip comments
stripped = content.lstrip()
if stripped.startswith("#"):
continue
violations.append(line)
for v in violations:
print(v)
sys.exit(1 if violations else 0)
' 2>&1 || true)
if [ -n "$VIOLATIONS" ]; then
echo "FAIL: Core code imports directly from plugin packages:"
echo "$VIOLATIONS"
exit 1
fi
echo "PASS: Zero direct hermes_agent_* imports in core"
echo "=== Checking no ensure() / LAZY_DEPS in core ==="
ENSURE_VIOLATIONS=$(grep -rn 'ensure(' ${hermes-agent}/share/hermes-agent/agent/ ${hermes-agent}/share/hermes-agent/hermes_cli/ --include='*.py' 2>/dev/null | grep -v '__pycache__' | grep -v '# ' || true)
if [ -n "$ENSURE_VIOLATIONS" ]; then
echo "FAIL: ensure() still used in core:"
echo "$ENSURE_VIOLATIONS"
exit 1
fi
echo "PASS: No ensure() calls in core code"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 2: Plugin registries populate after discovery
plugin-registries-populate = pkgs.runCommand "hermes-plugin-registries-populate" { } ''
set -e
echo "=== Checking plugin registries populate after discovery ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
out = {
"provider_services": list(registries._provider_services.keys()),
"platform_adapters": list(registries.platform_adapters.keys()),
"tool_providers": list(registries.tool_providers.keys()),
}
json.dump(out, sys.stdout)
' 2>/dev/null)
echo "Registry state: $RESULT"
# Verify provider services populated
PROV_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.provider_services | length')
if [ "$PROV_COUNT" -lt 1 ]; then
echo "FAIL: No provider services registered (expected >= 1)"
exit 1
fi
echo "PASS: $PROV_COUNT provider service(s) registered"
# Verify platform adapters populated
PLAT_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.platform_adapters | length')
if [ "$PLAT_COUNT" -lt 1 ]; then
echo "FAIL: No platform adapters registered (expected >= 1)"
exit 1
fi
echo "PASS: $PLAT_COUNT platform adapter(s) registered"
# Verify tool providers populated
TOOL_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.tool_providers | length')
if [ "$TOOL_COUNT" -lt 1 ]; then
echo "FAIL: No tool providers registered (expected >= 1)"
exit 1
fi
echo "PASS: $TOOL_COUNT tool provider(s) registered"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 3: Specific provider service lookups work
plugin-provider-lookups = pkgs.runCommand "hermes-plugin-provider-lookups" { } ''
set -e
echo "=== Checking provider service lookups ==="
export HOME=$(mktemp -d)
RESULT=$(${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.plugins import PluginManager
from agent.plugin_registries import registries
pm = PluginManager()
pm.discover_and_load(force=True)
checks = {
"anthropic.resolve_anthropic_token": registries.get_provider_service("anthropic", "resolve_anthropic_token") is not None,
"bedrock.has_aws_credentials": registries.get_provider_service("bedrock", "has_aws_credentials") is not None,
"azure.is_token_provider": registries.get_provider_service("azure", "is_token_provider") is not None,
}
json.dump(checks, sys.stdout)
' 2>/dev/null)
echo "Lookup results: $RESULT"
for key in anthropic.resolve_anthropic_token bedrock.has_aws_credentials azure.is_token_provider; do
VALUE=$(echo "$RESULT" | ${pkgs.jq}/bin/jq --arg k "$key" '.[$k]')
if [ "$VALUE" != "true" ]; then
echo "FAIL: $key lookup returned $VALUE (expected true)"
exit 1
fi
echo "PASS: $key lookup works"
done
mkdir -p $out
echo "ok" > $out/result
'';
# Check 4: Missing plugins degrade gracefully (no crash)
plugin-missing-graceful = pkgs.runCommand "hermes-plugin-missing-graceful" { } ''
set -e
echo "=== Checking missing plugins degrade gracefully ==="
export HOME=$(mktemp -d)
${hermesVenv}/bin/python3 -c '
from agent.plugin_registries import registries
# Lookup from non-existent provider — should return None, not crash
result = registries.get_provider_service("nonexistent-provider", "some_function")
assert result is None, f"Expected None for missing provider, got {result}"
# Lookup from empty registry — should return None
result2 = registries.get_provider_namespace("no-such-provider")
assert result2 == {}, f"Expected empty dict for missing namespace, got {result2}"
# Lookup specific tool provider that does not exist
result3 = registries.get_tool_provider("nonexistent-tool")
assert result3 is None, f"Expected None for missing tool provider, got {result3}"
print("PASS: All missing-plugin lookups return None gracefully")
' 2>&1
echo "PASS: Missing plugins degrade gracefully (no crash)"
mkdir -p $out
echo "ok" > $out/result
'';
# Check 5: No runtime pip install / ensure in gateway/run.py
plugin-no-runtime-install = pkgs.runCommand "hermes-plugin-no-runtime-install" { } ''
set -e
echo "=== Checking no runtime pip install / ensure in core ==="
# Check gateway/run.py has no ensure() or pip install
GATEWAY=${hermes-agent}/share/hermes-agent/gateway/run.py
if [ -f "$GATEWAY" ]; then
if grep -q 'ensure(' "$GATEWAY" || grep -q 'pip install' "$GATEWAY"; then
echo "FAIL: gateway/run.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$GATEWAY"
exit 1
fi
echo "PASS: gateway/run.py has no ensure()/pip install"
else
echo "SKIP: gateway/run.py not found in package"
fi
# Check run_agent.py has no ensure() or pip install
RUN_AGENT=${hermes-agent}/share/hermes-agent/run_agent.py
if [ -f "$RUN_AGENT" ]; then
if grep -q 'ensure(' "$RUN_AGENT" || grep -q 'pip install' "$RUN_AGENT"; then
echo "FAIL: run_agent.py contains ensure() or pip install"
grep -n 'ensure(\|pip install' "$RUN_AGENT"
exit 1
fi
echo "PASS: run_agent.py has no ensure()/pip install"
else
echo "SKIP: run_agent.py not found in package"
fi
# Check tools/lazy_deps.py is gone
LAZY_DEPS=${hermes-agent}/share/hermes-agent/tools/lazy_deps.py
if [ -f "$LAZY_DEPS" ]; then
echo "FAIL: tools/lazy_deps.py still exists should be removed"
exit 1
fi
echo "PASS: tools/lazy_deps.py removed"
mkdir -p $out
echo "ok" > $out/result
'';
# Regression guard: messaging deps live outside [all], so the
# #messaging variant must actually ship discord.py — otherwise
# `nix profile install .#messaging` regresses to the broken default.
+1 -1
View File
@@ -242,7 +242,7 @@
type = types.str;
default = "${cfg.stateDir}/workspace";
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
description = "Working directory for the agent (MESSAGING_CWD).";
description = "Working directory for the agent.";
};
# ── Declarative config ───────────────────────────────────────────────
+6 -3
View File
@@ -19,8 +19,10 @@
# Ships discord.py + python-telegram-bot + slack-sdk so a plain
# `nix profile install .#messaging` connects to Discord/Telegram/Slack
# on first run — lazy-install can't write to the read-only /nix/store.
# (Pluginify: the old `messaging` extra is now per-platform workspace
# members, so this aggregates discord/telegram/slack explicitly.)
messaging = hermesAgent.override {
extraDependencyGroups = [ "messaging" ];
extraDependencyGroups = [ "discord" "telegram" "slack" ];
};
# All platform-portable optional integrations pre-built.
@@ -32,6 +34,7 @@
"bedrock"
"daytona"
"dingtalk"
"discord"
"edge-tts"
"exa"
"fal"
@@ -39,11 +42,11 @@
"firecrawl"
"hindsight"
"honcho"
"messaging"
"modal"
"parallel-web"
"slack"
"telegram"
"tts-premium"
"vercel"
"voice"
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
@@ -0,0 +1,177 @@
---
name: antigravity-cli
description: "Operate the Antigravity CLI (agy): plugins, auth, sandbox."
version: 0.1.0
author: Tony Simons (asimons81), Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Antigravity, CLI, Auth, Plugins, Sandbox]
related_skills: [grok, codex, claude-code, hermes-agent]
---
# Antigravity CLI (`agy`)
Operator guide for the Antigravity CLI, invoked as `agy`. Run all `agy`
commands through the Hermes `terminal` tool; inspect its config and logs with
`read_file`. This skill is reference + procedure — it does not wrap a network
API, so there is nothing to authenticate from Hermes itself.
## When to Use
- Installing, updating, or smoke-testing the `agy` binary
- Driving non-interactive `agy --print` / `agy -p` one-shots
- Debugging Antigravity auth, sandbox, permissions, or plugin state
- Reading Antigravity settings, keybindings, conversations, or logs
## Mental model
Antigravity has two layers — keep them distinct or the guidance will be wrong:
1. **Shell wrapper commands**`agy help`, `agy install`, `agy plugin`,
`agy update`, `agy changelog`. Run these through the `terminal` tool.
2. **Interactive in-session slash commands**`/config`, `/permissions`,
`/skills`, `/agents`, etc. These only exist inside a running `agy` TUI
session, not on the shell wrapper.
`agy help` shows the shell wrapper surface, NOT the in-session slash commands.
## Prerequisites
- The `agy` binary on PATH. Verify through the `terminal` tool:
`command -v agy && agy --version`.
- No env vars or API keys required by this skill — Antigravity manages its own
auth via the OS keyring / browser sign-in (see Authentication below).
## How to Run
Invoke every `agy` command through the `terminal` tool. Examples:
```
terminal(command="agy --version")
terminal(command="agy help")
terminal(command="agy plugin list")
terminal(command="agy --print 'Summarize the repo in 3 bullets'", workdir="/path/to/project")
```
For an interactive multi-turn TUI session, launch `agy` with `pty=true` (and
tmux for capture/monitoring), the same pattern the `codex` / `claude-code`
skills use. For one-shot smoke tests and scripted prompts, prefer
`agy --print` (non-interactive).
To inspect Antigravity's own files, use `read_file` on the paths under Core
paths below — do not `cat` them through the terminal.
## Core paths
- Binary / entrypoint: `agy`
- App data dir: `~/.gemini/antigravity-cli/`
- Settings file: `~/.gemini/antigravity-cli/settings.json`
- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json`
- Logs: `~/.gemini/antigravity-cli/log/cli-*.log`
- Conversations: `~/.gemini/antigravity-cli/conversations/`
- Brain artifacts: `~/.gemini/antigravity-cli/brain/`
- History: `~/.gemini/antigravity-cli/history.jsonl`
- Plugin staging: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
## Quick Reference
### Wrapper commands
- `agy changelog`
- `agy help`
- `agy install`
- `agy plugin` / `agy plugins`
- `agy update`
### Useful flags
- `--add-dir`
- `--continue` / `-c`
- `--conversation`
- `--dangerously-skip-permissions`
- `--print` / `-p`
- `--print-timeout`
- `--prompt`
- `--prompt-interactive` / `-i`
- `--sandbox`
- `--log-file`
- `--version`
### Plugin subcommands (`agy plugin --help`)
- `list`, `import [source]`, `install <target>`, `uninstall <name>`,
`enable <name>`, `disable <name>`, `validate [path]`, `link <mp> <target>`,
`help`
### Install flags (`agy install --help`)
- `--dir`, `--skip-aliases`, `--skip-path`
### In-session slash commands
- **Conversation control:** `/resume` (`/switch`), `/rewind` (`/undo`),
`/rename <name>`, `/clear`, `/fork`, `/reset`, `/new`
- **Settings & tools:** `/config`, `/settings`, `/permissions`, `/model`,
`/keybindings`, `/statusline`, `/tasks`, `/skills`, `/mcp`, `/open <path>`,
`/usage`, `/logout`, `/agents`
- **Prompt helpers:** `@` path autocomplete, `esc esc` clears the prompt (when
not streaming), `!` runs a terminal command directly, `?` opens help
## Settings and permissions
### Common settings keys (`settings.json`)
- `allowNonWorkspaceAccess`
- `colorScheme`
- `permissions.allow`
- `trustedWorkspaces`
### Permission modes
`request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`.
### Sandbox behavior
- `enableTerminalSandbox` is a boolean in `settings.json`; default `false`.
- Launch-time overrides (`--sandbox`, `--dangerously-skip-permissions`) can
supersede persistent settings for the current session.
## Authentication behavior
- The CLI tries the OS secure keyring first.
- With no saved session, it falls back to browser-based Google sign-in.
- Locally it opens the default browser; over SSH it prints an authorization URL
and expects the auth code pasted back.
- `/logout` removes saved credentials.
## Plugins
- Plugins stage under `~/.gemini/antigravity-cli/plugins/<plugin_name>/`.
- They can bundle skills, agents, rules, MCP servers, and hooks.
- `agy plugin list` returning no imported plugins is a valid empty state.
## Pitfalls
- `agy help` shows wrapper commands, not interactive slash commands.
- `agy --version` is the safe non-interactive version check; `agy version` is
interactive and can fail without a real TTY.
- First place to look for failures: `~/.gemini/antigravity-cli/log/cli-*.log`
(read with `read_file`).
- Don't confuse persistent JSON settings with launch-time overrides.
- `~/.gemini/antigravity-cli/bin/agentapi` is a thin wrapper to `agy agentapi`.
- On WSL, token storage is file-based, so auth issues are usually local-file /
session-state problems, not browser-only problems.
- Workspace identity can depend on launch directory and the `.antigravitycli`
project marker.
## Verification
Confirm the install is real and usable, all through the `terminal` tool (read
files with `read_file`):
1. `terminal(command="command -v agy")`
2. `terminal(command="agy --version")`
3. `terminal(command="agy help")`
4. `terminal(command="agy plugin list")`
5. `read_file` on `~/.gemini/antigravity-cli/settings.json`
6. `read_file` on the latest `~/.gemini/antigravity-cli/log/cli-*.log`
7. If needed, `read_file` on `~/.gemini/antigravity-cli/keybindings.json`
## Support files
- `references/cli-docs.md` — condensed notes from the getting-started, usage,
and features docs.
@@ -0,0 +1,64 @@
# Antigravity CLI docs, condensed
Source pages reviewed:
- `/docs/cli-getting-started`
- `/docs/cli-using`
- `/docs/cli-features`
## Install
- macOS/Linux: `curl -fsSL https://antigravity.google/cli/install.sh | bash`
- Windows PowerShell: `irm https://antigravity.google/cli/install.ps1 | iex`
- Windows CMD: `curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd`
## Authentication
- Tries secure keyring first.
- If no saved session exists, falls back to browser-based Google sign-in.
- Local machine: opens the default browser.
- SSH/remote: prints a secure authorization URL, then expects the auth code to be pasted back.
- `/logout` removes saved credentials.
## Config and files
- Settings: `~/.gemini/antigravity-cli/settings.json`
- Keybindings: `~/.gemini/antigravity-cli/keybindings.json`
- Plugins: `~/.gemini/antigravity-cli/plugins/<plugin_name>/`
## Useful slash commands
- `/config`, `/settings`
- `/permissions`
- `/resume` / `/switch`
- `/rewind` / `/undo`
- `/rename <name>`
- `/model`
- `/keybindings`
- `/statusline`
- `/tasks`
- `/skills`
- `/mcp`
- `/open <path>`
- `/usage`
- `/logout`
- `/agents`
## Prompt helpers
- `@` path autocomplete
- `esc esc` clears prompt when not streaming
- `!` runs a terminal command
- `?` opens help / slash command list
## Permissions and sandbox
- Permission modes: `request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`
- Launch overrides: `--sandbox`, `--dangerously-skip-permissions`
- Sandbox setting: `enableTerminalSandbox` in `settings.json` (default `false`)
## Plugins
- Plugins can bundle skills, agents, rules, MCP servers, and hooks.
- They are staged locally and auto-discovered once installed.
## Subagents
- `/agents` opens the panel for active/completed subagents.
- Subagents can run in parallel and request approvals.
## Keybindings
- `~/.gemini/antigravity-cli/keybindings.json`
- Malformed JSON falls back to defaults for broken actions.
- Docs list default bindings for clear, submit, cancel, exit, suspend, editor, approval yes/no, navigation, clipboard, undo/redo, and newline insertion.
@@ -0,0 +1,301 @@
---
name: grok
description: "Delegate coding to xAI Grok Build CLI (features, PRs)."
version: 0.1.0
author: Matt Maximo (MattMaximo), Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [Coding-Agent, Grok, xAI, Code-Review, Refactoring, Automation]
related_skills: [codex, claude-code, hermes-agent]
---
# Grok Build CLI — Hermes Orchestration Guide
Delegate coding tasks to [Grok Build](https://docs.x.ai/build/overview) (xAI's
autonomous coding agent CLI, the `grok` command) via the Hermes terminal. Grok
can read files, write code, run shell commands, spawn subagents, and manage git
workflows. It runs three ways: an interactive TUI, **headless** (`-p`), and as
an **ACP agent** over JSON-RPC.
This is the third sibling to `codex` and `claude-code`. The orchestration
pattern is nearly identical — **prefer headless `-p` for one-shots**, use a PTY
for interactive sessions.
## When to use
- Building features
- Refactoring
- PR reviews
- Batch issue fixing
- Any task where you'd otherwise reach for Codex / Claude Code but want Grok
## Prerequisites
- **Install (preferred):** `npm install -g @xai-official/grok`
- The official installer `curl -fsSL https://x.ai/cli/install.sh | bash` also
works, but the `x.ai` host is Cloudflare-walled in some environments. The
npm path avoids that dependency entirely.
- **Auth — SuperGrok / X Premium+ subscription (primary path):**
- Run `grok login` once → opens a browser for OAuth → token cached in
`~/.grok/auth.json`. This uses your **SuperGrok or X Premium+** subscription
(no per-token API billing).
- Check sign-in state by looking for `~/.grok/auth.json`, or run a cheap
headless smoke test: `grok --no-auto-update -p "Say ok."`
- In the TUI, `/logout` signs out and `/login` (or relaunching) signs back in.
- **No git repo required** — unlike Codex, Grok runs fine outside a git
directory (good for scratch/throwaway tasks).
- **Claude Code / AGENTS.md compatible with zero config** — Grok auto-reads
`CLAUDE.md`, `.claude/` (skills, agents, MCPs, hooks, rules), and the
`AGENTS.md` family. Existing project context just works.
> **API-key fallback (not the default for this user):** Grok also supports
> setting the `XAI_API_KEY` environment variable for pay-as-you-go billing
> via `api.x.ai`. Only use
> this if `grok login` / SuperGrok auth is unavailable. The subscription path
> (`grok login`) is the intended setup here.
## Two Orchestration Modes
### Mode 1: Headless (`-p`) — Non-Interactive (PREFERRED)
Runs a one-shot task, prints the result, and exits. No PTY, no interactive
dialogs to navigate. This is the cleanest integration path — the analog of
`claude -p` and `codex exec`.
```
terminal(command="grok --no-auto-update -p 'Add a dark mode toggle to settings'", workdir="/path/to/project", timeout=180)
```
Always pass `--no-auto-update` in automation to skip background update checks.
**When to use headless:**
- One-shot coding tasks (fix a bug, add a feature, refactor)
- CI/CD automation and scripting
- Structured output parsing with `--output-format json`
- Any task that doesn't need multi-turn conversation
### Mode 2: Interactive PTY — Multi-Turn TUI Sessions
The TUI is a fullscreen, mouse-interactive app. Drive it with `pty=true`. For
robust monitoring/input use tmux (same pattern as the `claude-code` skill).
```
# Launch in a tmux session for capture-pane monitoring
terminal(command="tmux new-session -d -s grok-work -x 140 -y 40")
terminal(command="tmux send-keys -t grok-work 'cd /path/to/project && grok' Enter")
# Wait for startup, then send a task
terminal(command="sleep 5 && tmux send-keys -t grok-work 'Refactor the auth module to use JWT' Enter")
# Monitor progress
terminal(command="sleep 15 && tmux capture-pane -t grok-work -p -S -50")
# Exit when done
terminal(command="tmux send-keys -t grok-work '/quit' Enter && sleep 1 && tmux kill-session -t grok-work")
```
**Tip for headless-but-inline output:** if you want TUI-style output without the
fullscreen alt-screen takeover (e.g. for cleaner logs), add `--no-alt-screen`.
For pure automation, headless `-p` is still cleaner than the TUI.
## Headless Deep Dive
### Common Flags
| Flag | Effect |
|------|--------|
| `-p, --single <PROMPT>` | Send one prompt, run headless, exit |
| `-m, --model <MODEL>` | Choose a model |
| `-s, --session-id <ID>` | Create or resume a named headless session |
| `-r, --resume <ID>` | Resume an existing session |
| `-c, --continue` | Continue the most recent session in the current directory |
| `--cwd <PATH>` | Set the working directory |
| `--output-format <FMT>` | `plain` (default), `json`, or `streaming-json` |
| `--always-approve` | Auto-approve all tool executions (the `--full-auto` / `--yolo` equivalent) |
| `--no-alt-screen` | Run inline, no fullscreen TUI takeover |
| `--no-auto-update` | Skip background update checks (use in all automation) |
### Output Formats
- `plain` — human-readable text (default)
- `json` — one JSON object at the end of the run (parse the result cleanly)
- `streaming-json` — newline-delimited JSON events as they arrive
```
# Structured result for parsing
terminal(command="grok --no-auto-update -p 'List all TODO comments in src/' --output-format json", workdir="/project", timeout=120)
# Auto-approve for autonomous building
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the database layer and run the tests'", workdir="/project", timeout=300)
```
### Background Mode (Long Tasks)
```
# Start headless in background
terminal(command="grok --no-auto-update --always-approve -p 'Refactor the auth module'", workdir="/project", background=true, notify_on_complete=true)
# Returns session_id
# Monitor
process(action="poll", session_id="<id>")
process(action="log", session_id="<id>")
# Kill if needed
process(action="kill", session_id="<id>")
```
For an interactive (TUI) background session, use `pty=true` + tmux and monitor
with `tmux capture-pane`, exactly like the `claude-code` / `codex` skills.
### Session Continuation
```
# Start a named session
terminal(command="grok --no-auto-update -s refactor-db -p 'Start refactoring the database layer' --always-approve", workdir="/project", timeout=240)
# Resume it later
terminal(command="grok --no-auto-update -r refactor-db -p 'Now add connection pooling' --always-approve", workdir="/project", timeout=180)
# Or continue the most recent session in this directory
terminal(command="grok --no-auto-update -c -p 'What did you change last time?'", workdir="/project", timeout=60)
```
## Read-Only Audit → Markdown Note Pattern
To have Grok review local artifacts and return a clean markdown note (for
Obsidian or a repo) without mutating anything:
1. Prepare stable input files first with Hermes tools (`read_file`,
`write_file`). Snapshot only the relevant context into a temp file rather
than dumping raw paths.
2. Run Grok headless **without** `--always-approve` so it cannot auto-write, and
demand `markdown only, no preamble`.
3. Save Grok's stdout straight into the destination note with `write_file()`.
```
grok --no-auto-update -p "Read /tmp/current.md and /tmp/inventory.md. Produce markdown only, no preamble. Output a clean note titled 'Cleanup Review'." --output-format plain
```
**Pitfall (same as Claude Code):** for document rewrites, a loose "rewrite this"
prompt may return a change summary instead of the full file. Instead: pipe the
file in, and demand `Return ONLY the full revised markdown document. No intro,
no explanation, no code fences. Start immediately with '# Title'.` Verify the
first lines with `read_file()` before overwriting the destination.
## PR Review Patterns
### Quick Review (Headless)
```
terminal(command="cd /path/to/repo && git diff main...feature-branch | grok --no-auto-update -p 'Review this diff for bugs, security issues, and style problems. Be thorough.'", timeout=120)
```
### Clone-to-temp Review (safe, no repo mutation)
```
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && grok --no-auto-update -p 'Review the changes vs origin/main. Check bugs, security, race conditions, missing tests.'", pty=true, timeout=300)
```
### Post the review
```
terminal(command="gh pr comment 42 --body '<review text>'", workdir="/path/to/repo")
```
## Parallel Issue Fixing with Worktrees
```
# Create worktrees
terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project")
terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project")
# Launch Grok headless in each (background)
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #78: <description>. Commit when done.'", workdir="/tmp/issue-78", background=true, notify_on_complete=true)
terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #99: <description>. Commit when done.'", workdir="/tmp/issue-99", background=true, notify_on_complete=true)
# Monitor
process(action="list")
# After completion: push and open PRs
terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78")
terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'")
# Cleanup
terminal(command="git worktree remove /tmp/issue-78", workdir="~/project")
```
## Useful Subcommands & TUI Commands
| Command | Purpose |
|---------|---------|
| `grok` | Start the interactive TUI |
| `grok -p "query"` | Headless one-shot |
| `grok login` / `grok logout` | Sign in / out (SuperGrok / X Premium+ OAuth) |
| `grok inspect` | Show what Grok discovered in cwd: config sources, instructions, skills, plugins, hooks, MCP servers |
| `grok agent stdio` | Run as an ACP agent over JSON-RPC (for IDE/tool integration) |
| `grok update` | Update the CLI (needs the `x.ai` host; skip in automation) |
TUI slash commands (interactive only): `/model <name>`, `/always-approve`,
`/plan`, `/context`, `/compact`, `/resume`, `/sessions`, `/fork`, `/usage`,
`/quit`. `Shift+Tab` cycles session modes (including Plan mode, which blocks
write tools except the session plan file).
## Config (`~/.grok/config.toml`)
```toml
[cli]
auto_update = false # skip background update checks persistently
[ui]
permission_mode = "ask" # or "always-approve" to skip tool prompts by default
[models]
default = "grok-build-0.1"
```
Put global preferences in `~/.grok/config.toml` (not project-scoped
`.grok/config.toml`). `permission_mode` supersedes the legacy `approval_mode` /
`yolo = true` keys.
## Pitfalls & Gotchas
1. **Auth is subscription-gated.** `grok login` requires a SuperGrok or X
Premium+ subscription. If login fails or there's no `~/.grok/auth.json`,
confirm the subscription is active before falling back to `XAI_API_KEY`.
2. **Don't conflate Hermes' xAI auth with the `grok` CLI's auth.** Hermes'
`x_search` runs on its own xAI OAuth; the standalone `grok` CLI has a
separate token in `~/.grok/auth.json`. A working `x_search` does NOT mean
`grok` is logged in.
3. **Always pass `--no-auto-update` in automation** — otherwise Grok phones home
for update checks (and `x.ai`/`storage.googleapis.com` may be unreachable).
4. **Prefer npm install over the curl installer** — `npm install -g
@xai-official/grok` avoids the Cloudflare-walled `x.ai` host.
5. **`--always-approve` is the autonomous-build switch.** Without it, headless
runs may stall waiting on tool-approval prompts. Omit it deliberately for
read-only review/audit work so Grok can't mutate files.
6. **Headless `-p` skips TUI dialogs**; the TUI needs `pty=true` (+ tmux for
monitoring), just like Claude Code.
7. **Use `--no-alt-screen`** if you run the TUI inline and the fullscreen
alt-screen takeover garbles captured output.
8. **No git repo needed**, but for PR/commit workflows you still want one — use
`mktemp -d && git init` for scratch commit tasks.
9. **Clean up tmux sessions** with `tmux kill-session -t <name>` when done.
## Rules for Hermes Agents
1. **Prefer headless `-p`** for single tasks — cleanest integration, structured
output via `--output-format json`.
2. **Always set `workdir`** (or `--cwd`) so Grok targets the right project.
3. **Pass `--no-auto-update`** in every automated invocation.
4. **Use `--always-approve` only when Grok should write autonomously**; omit it
for read-only reviews and audits.
5. **Background long tasks** with `background=true, notify_on_complete=true` and
monitor via the `process` tool.
6. **Use tmux for multi-turn interactive work** and monitor with
`tmux capture-pane -t <session> -p -S -50`.
7. **Verify auth before relying on it** — check `~/.grok/auth.json` or run a
cheap `grok -p "Say ok."` smoke test; don't assume Hermes' xAI auth carries
over.
8. **Report results to the user** — summarize what Grok changed and what's left.
@@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is
### Cloud (app.honcho.dev)
```bash
hermes honcho setup
hermes memory setup honcho
# select "cloud", paste API key from https://app.honcho.dev
```
### Self-hosted
```bash
hermes honcho setup
hermes memory setup honcho
# select "local", enter base URL (e.g. http://localhost:8000)
```
@@ -38,7 +38,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
+11 -4
View File
@@ -119,17 +119,20 @@ class BrowserUseBrowserProvider(BrowserProvider):
return "Browser Use"
def is_available(self) -> bool:
return self._get_config_or_none() is not None
return self._get_config_or_none(refresh_token=False) is not None
# ------------------------------------------------------------------
# Config resolution (direct API key OR managed Nous gateway)
# ------------------------------------------------------------------
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
# Import here to avoid a hard dependency at module-import time —
# managed_tool_gateway pulls in the Nous auth stack which can be
# heavy and is not needed for direct-API-key users.
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.managed_tool_gateway import (
peek_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import prefers_gateway
# Direct API key wins unless the user has explicitly opted into the
@@ -142,7 +145,11 @@ class BrowserUseBrowserProvider(BrowserProvider):
"managed_mode": False,
}
managed = resolve_managed_tool_gateway("browser-use")
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
)
if managed is None:
return None
+1 -1
View File
@@ -26,7 +26,7 @@ Optional feature knobs::
BROWSERBASE_PROXIES=true # default true
BROWSERBASE_ADVANCED_STEALTH=false
BROWSERBASE_KEEP_ALIVE=true # default true
BROWSERBASE_SESSION_TIMEOUT=... (ms, integer)
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
"""
from __future__ import annotations
+7
View File
@@ -0,0 +1,7 @@
"""Bridge module — delegates plugin registration to hermes_agent_dashboard."""
def register(ctx):
"""Plugin entry point — delegates to the inner hermes_agent_dashboard package."""
from hermes_agent_dashboard import register as _inner_register
_inner_register(ctx)
@@ -0,0 +1,6 @@
"""Hermes Agent web dashboard."""
def register(ctx):
"""Plugin entry point — dashboard registers via ctx.register_tool_provider_entry()."""
pass

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