Compare commits

..
Author SHA1 Message Date
ethernet 06f3676598 fix: address copilot review comments on PR #37660
- Fix update_managed_uv() docstring: returns path even on self-update
  failure, None only when no managed uv exists
- Fail fast on rebuild_venv() failure in _update_via_zip and
  _cmd_update_impl — avoids continuing with a nuked venv
- Save/restore $env:UV_INSTALL_DIR in install.ps1 Install-Uv
  to prevent env var leak into subsequent stages
- Patch platform.system() in test_posix_sets_uv_unmanaged_install
  for determinism on Windows
- Soften FTS5 warning: drop 'managed uv guarantees FTS5' claim,
  keep actionable 'hermes update' advice
2026-06-02 18:45:06 -04:00
ethernet be97aeb7ba fix(tests): add _patch_managed_uv autouse fixture to uv-dependent test files
Production code now uses ensure_uv()/update_managed_uv() from
managed_uv.py instead of shutil.which("uv") directly. Tests that
patched shutil.which to control uv availability no longer controlled
the actual code path, causing CI failures.

Add an autouse _patch_managed_uv fixture to test_update_autostash.py
and test_uv_tool_update.py (matching the existing fixture in
test_cmd_update.py). The fixture makes managed_uv functions delegate
to shutil.which so existing test patches flow through naturally.
2026-06-02 18:18:38 -04:00
ethernet 1a9da1ae82 refactor(uv): single managed-uv path, delete fts5 installer escalation
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.

Key changes:

- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
  ensure_uv() (returns (path, freshly_bootstrapped) tuple),
  update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
  add venv rebuild on first-time managed uv bootstrap, update_managed_uv
  before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
  $HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
  _reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
  Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
  $HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
  as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
  fixture in test_cmd_update.py.

Closes #37605, Closes #37622
2026-06-02 17:42:44 -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
245 changed files with 18727 additions and 2621 deletions
+40 -11
View File
@@ -26,6 +26,10 @@ on:
permissions:
contents: read
# Needed so the arm64 job can push/pull its registry-backed build cache
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
# job for why registry cache replaced the gha cache on that arch.
packages: write
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
@@ -196,11 +200,34 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for smoke testing. PR arm64
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
# outlive GitHub's short-lived Azure cache SAS token, then fail while
# reading or writing cache blobs before the smoke test can run.
- name: Build image (arm64, smoke test, uncached PR)
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
# crashed the build before the smoke test (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for smoke testing.
#
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
# they pull warm layers pushed by the most recent main build but never
# write, so rapid PR pushes don't race on cache writes or pollute the
# cache ref. This restores warm-cache speed to arm64 PR builds (which
# were running fully uncached and were ~45% slower than amd64, making
# them the job most often cancelled on supersede).
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cache read-only PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -211,9 +238,11 @@ jobs:
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
# Main/release builds still use the per-arch gha cache so the digest
# push below can reuse layers from this smoke-test build.
# Main/release builds read AND write the registry cache so the digest
# push below reuses layers from this smoke-test build, and so the next
# PR/main build starts warm.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
@@ -225,8 +254,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Smoke test image
uses: ./.github/actions/hermes-smoke-test
@@ -253,8 +282,8 @@ jobs:
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
+2 -2
View File
@@ -49,8 +49,8 @@ hermes-agent/
│ ├── 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>/ # disk-cleanup, google_meet, platforms, 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`
+1 -1
View File
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
+8 -5
View File
@@ -308,11 +308,14 @@ def compress_context(
# The check itself sets ``agent._compression_warning`` so the
# status-callback replay machinery still emits the warning to the user
# the first time it would matter.
if not getattr(agent, "_compression_feasibility_checked", True):
try:
check_compression_model_feasibility(agent)
finally:
agent._compression_feasibility_checked = True
if not getattr(agent, "_compression_feasibility_checked", False):
# Mark as checked only after the probe completes. If the check
# raises (e.g. a fatal aux-context ValueError that aborts the
# session), leaving the flag unset is harmless; a non-fatal
# transient failure is swallowed inside the function so the flag
# is set normally on the next successful pass.
check_compression_model_feasibility(agent)
agent._compression_feasibility_checked = True
_pre_msg_count = len(messages)
logger.info(
+187
View File
@@ -451,3 +451,190 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
f"``cross_profile=True``. (Defense-in-depth — not a security "
f"boundary; the terminal tool can still bypass.)"
)
# ---------------------------------------------------------------------------
# Sandbox-mirror write guard (#32049)
#
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
# directory to the container's ``$HOME``. The on-disk layout looks like
#
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
#
# When the agent (running host-side) speculates that authoritative profile
# state lives at one of those sandbox-mirror paths, the write lands on the
# mirror — never read by the host process — while the host file is left
# untouched. The agent reports success, the user sees no change, and on
# disk two divergent copies accumulate. See #32049 for evidence.
#
# This guard is path-shape-only: it detects the
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
# regardless of which Hermes profile is active. It does NOT cover the
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
# (the agent's view inside the container is plain ``/root/.hermes/...``);
# that case needs a separate dispatch-layer or host-side ``profile_state``
# tool.
# ---------------------------------------------------------------------------
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
index where the inner Hermes-state portion starts. Returns ``None`` for
paths that do not contain the sandbox-mirror shape.
"""
for i, part in enumerate(parts):
if part != "sandboxes":
continue
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
if i + 5 >= len(parts):
continue
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
return i + 4
return None
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
Returns ``None`` when the path does not match the sandbox-mirror shape.
Otherwise returns a dict with:
* ``target_path``: the resolved path string
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
prefix (so callers can show users which sandbox owns the mirror)
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
agent likely meant to address on the host)
Detection is path-shape-only — does not require any Hermes resolver to
succeed, so it works correctly even when called from contexts where
HERMES_HOME resolution would be ambiguous.
"""
try:
target = Path(os.path.expanduser(str(path))).resolve()
except (OSError, RuntimeError):
return None
parts = target.parts
inner_idx = _find_sandbox_mirror_segments(parts)
if inner_idx is None:
return None
mirror_root = str(Path(*parts[: inner_idx + 1]))
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
return {
"target_path": str(target),
"mirror_root": mirror_root,
"inner_path": inner_path,
}
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
Returns ``None`` when the path is not a sandbox-mirror target. Caller
is expected to surface the warning to the agent as a tool-result
error. The bypass kwarg (``cross_profile=True``) is shared with the
cross-profile guard: both are soft "I know what I'm doing" overrides
a user can authorise.
Defense-in-depth, NOT a security boundary: the terminal tool runs as
the same OS user and can write the mirror path directly. The guard
exists to surface the misclassification before the silent-success +
divergent-copy footgun in #32049 fires.
"""
info = classify_sandbox_mirror_target(path)
if info is None:
return None
return (
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
f"created by a non-local terminal backend (docker/daytona/etc.). "
f"Writes here land on a copy that the host Hermes process never "
f"reads — the authoritative file is likely {info['inner_path']!r} "
f"under the real HERMES_HOME. Use the host-side tool for "
f"authoritative state (e.g. ``memory`` for memories), or address "
f"the host path directly. To bypass this guard after explicit "
f"user direction, retry the call with ``cross_profile=True``. "
f"(Defense-in-depth — not a security boundary; the terminal tool "
f"can still bypass.)"
)
# ---------------------------------------------------------------------------
# Container-context mirror guard (inner-container case — #32049 follow-up)
#
# Brian's shape-based detector (#32213) catches paths that still carry the
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
# But when file tools execute *inside* the container the bind-mount strips
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
# failure mode.
#
# Fix: file_tools passes the active Docker mirror prefix when the terminal
# backend is docker + persistent. This catches the very first file-tool call,
# before a DockerEnvironment object necessarily exists.
# ---------------------------------------------------------------------------
def classify_container_mirror_target(
path: str,
mirror_prefix: str | None = None,
) -> Optional[dict]:
"""Classify a write target as a container-side sandbox mirror.
``mirror_prefix`` must be supplied by the caller after it has established
that file tools are executing in a container whose home is a sandbox
mirror. Returns ``None`` when no such context is active or the path is not
under the mirror prefix. Otherwise returns:
* ``target_path``: resolved path string
* ``mirror_root``: the declared container mirror prefix
* ``inner_path``: portion under the mirror root (what the agent
likely meant to address in the host HERMES_HOME)
"""
if not mirror_prefix:
return None
try:
target = Path(os.path.expanduser(str(path))).resolve()
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
inner = target.relative_to(mirror)
except (OSError, RuntimeError, ValueError):
return None
return {
"target_path": str(target),
"mirror_root": str(mirror),
"inner_path": inner.as_posix(),
}
def get_container_mirror_warning(
path: str,
mirror_prefix: str | None = None,
) -> Optional[str]:
"""Return a model-facing warning when *path* lands in the container's
sandbox mirror of authoritative Hermes state.
The caller supplies ``mirror_prefix`` only when the current file-tool
backend is known to execute inside a Docker sandbox. Same contract as
``get_cross_profile_warning``: soft guard, returns ``None`` for
non-mirror paths, caller surfaces as a tool-result error. Bypass via
``cross_profile=True`` after explicit user direction.
"""
info = classify_container_mirror_target(path, mirror_prefix)
if info is None:
return None
return (
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
f"sits under {info['mirror_root']!r}, which is the container's "
f"bind-mounted home — a per-task mirror that the host Hermes "
f"process never reads. The authoritative file is "
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
f"host-side tool for authoritative state (e.g. ``memory`` for "
f"memories), or address the host path directly. To bypass after "
f"explicit user direction, retry with ``cross_profile=True``. "
f"(Defense-in-depth — not a security boundary; the terminal tool "
f"can still bypass.)"
)
+25
View File
@@ -1128,6 +1128,18 @@ def _model_name_suggests_kimi(model: str) -> bool:
return lower.startswith("kimi") or "moonshot" in lower
def _model_name_suggests_minimax_m3(model: str) -> bool:
"""Return True if the model name looks like MiniMax M3.
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
Used as a guard against stale cache entries seeded by pre-catalog builds
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
"""
return "minimax-m3" in model.lower()
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -1539,6 +1551,19 @@ def get_model_context_length(
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
# stale value would otherwise stick forever here at step 1. M3 is 1M,
# so any sub-256K cached value for an M3 slug is a leftover — drop it
# and fall through to the hardcoded default.
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
logger.info(
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Nous Portal: the portal /v1/models endpoint is authoritative.
# Bypass the persistent cache so step 5b can always reconcile
# against it — this corrects pre-fix entries seeded from the
+2 -1
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
extract_skill_conditions,
extract_skill_description,
@@ -802,7 +803,7 @@ def build_environment_hints() -> str:
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
try:
host_lines.append(f"Current working directory: {os.getcwd()}")
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
except OSError:
pass
+33
View File
@@ -0,0 +1,33 @@
"""Single source of truth for the agent working directory.
`TERMINAL_CWD` is the runtime carrier for the configured working directory
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
relies on the launch dir. Reading it in one place keeps the system prompt, the
tool surfaces, and context-file discovery agreeing on where the agent lives.
The #29531 per-session extension point is this function: a future PR adds a
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
`set_session_vars` seam — by design, not a reopening hazard.
"""
import os
from pathlib import Path
def resolve_agent_cwd() -> Path:
raw = os.environ.get("TERMINAL_CWD", "").strip()
if raw:
p = Path(raw).expanduser()
if p.is_dir():
return p
return Path(os.getcwd())
def resolve_context_cwd() -> Path | None:
# None means "no configured cwd": build_context_files_prompt then falls back
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
raw = os.environ.get("TERMINAL_CWD", "").strip()
return Path(raw).expanduser() if raw else None
+6 -7
View File
@@ -24,7 +24,6 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@@ -41,6 +40,7 @@ from agent.prompt_builder import (
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
)
from agent.runtime_cwd import resolve_context_cwd
def _ra():
@@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
context_parts.append(system_message)
if not agent.skip_context_files:
# Use TERMINAL_CWD for context file discovery when set (gateway
# mode). The gateway process runs from the hermes-agent install
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
# other dev files — inflating token usage by ~10k for no benefit.
_context_cwd = os.getenv("TERMINAL_CWD") or None
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
# CLI), None lets build_context_files_prompt fall back to the launch
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt(
cwd=_context_cwd, skip_soul=_soul_loaded)
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
if context_files_prompt:
context_parts.append(context_files_prompt)
+71 -31
View File
@@ -8,18 +8,24 @@ fn main() {
// `option_env!()` macro to default the install-script reference.
// Precedence (matches install.ps1's own arg precedence): commit > branch.
//
// Resolution order:
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
// Useful for CI builds that want to pin to a tagged release SHA
// rather than whatever the checkout's HEAD happens to be.
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
// the repo this build.rs lives in. Default for `cargo tauri build`
// from a dev machine — pins the produced .exe to your current
// checkout state.
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
// installer will fetch HEAD-of-main at runtime. Used when the
// build is happening outside a git checkout (e.g. cargo install
// from a packaged crate, unlikely for this binary but defensive).
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
// so the produced installer follows that branch's HEAD at install time
// (tolerant of fast-forwards/new commits, and never references a SHA the
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
// immutable commit pin for reproducible/release installers.
//
// Commit pin resolution:
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
// or branch name; resolved to an immutable SHA via `git rev-parse`
// when possible, else used verbatim if it already looks like a SHA.
// - Otherwise: NO commit pin (branch-follow is the default).
//
// Branch pin resolution:
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
// lives in — the current branch. (None on a detached HEAD.)
// 3. Last-resort fallback handled below: if neither commit nor branch
// resolves, warn — the binary needs a runtime arg or dev-repo env.
//
// Build script reruns on git HEAD change so a new commit triggers
// a rebuild without `cargo clean`.
@@ -30,11 +36,20 @@ fn main() {
if let Some(c) = &commit {
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
println!(
"cargo:warning=hermes-bootstrap: pinning to commit {}",
short(c)
);
}
if let Some(b) = &branch {
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
match &commit {
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
None => println!(
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
),
}
}
if commit.is_none() && branch.is_none() {
// Fail loudly rather than silently produce a binary that errors
@@ -46,8 +61,11 @@ fn main() {
);
}
// Rerun build.rs when HEAD moves so successive builds pick up new
// commits without needing `cargo clean`. .git/HEAD changes on every
// Rerun build.rs when HEAD moves. With branch-follow as the default the
// baked commit no longer changes per-commit, but a branch *switch* changes
// the detected branch name, so we still re-trigger. When an explicit
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
// HEAD move can also change that resolution. .git/HEAD changes on every
// commit / branch switch / rebase.
let git_dir = locate_git_dir();
if let Some(gd) = &git_dir {
@@ -83,24 +101,46 @@ fn main() {
}
fn resolve_commit_pin() -> Option<String> {
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
// None and the installer follows the branch HEAD at install time.
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
let requested = requested.trim();
if requested.is_empty() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
// Resolve the request (which may be a SHA, tag, or branch name) to an
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
// dereferences tags to the commit they point at.
if let Ok(out) = Command::new("git")
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
.output()
{
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let s = s.trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
}
}
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
// literal value only if it already looks like a SHA; otherwise fail loud
// rather than bake an unresolvable ref into the binary.
if is_sha(requested) {
return Some(requested.to_string());
}
panic!(
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
(git rev-parse failed and it is not a valid SHA)"
);
}
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
fn is_sha(s: &str) -> bool {
let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn resolve_branch_pin() -> Option<String> {
@@ -482,6 +482,18 @@ async function runBootstrap(opts) {
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
// already-aborted signal would still fetch the manifest (a spawn) before the
// in-loop abort check fires.
if (abortSignal && abortSignal.aborted) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {}
}
return { ok: false, cancelled: true }
}
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
// Tee every event to the runLog AND the caller's onEvent. This gives us a
@@ -0,0 +1,27 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { runBootstrap } = require('./bootstrap-runner.cjs')
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
sourceRepoRoot: null,
hermesHome: '/tmp/hermes-runner-test',
logRoot: '/tmp/hermes-runner-test',
onEvent: ev => events.push(ev),
abortSignal: controller.signal
})
// Cancelled before any install script is spawned.
assert.deepEqual(result, { ok: false, cancelled: true })
assert.ok(
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
'should emit a cancelled failure event'
)
})
+98
View File
@@ -8,6 +8,8 @@ const {
ipcMain,
nativeImage,
nativeTheme,
net: electronNet,
protocol,
safeStorage,
session,
shell,
@@ -364,6 +366,66 @@ app.setAboutPanelOptions({
copyright: 'Copyright © 2026 Nous Research'
})
// Custom scheme for streaming local media (video/audio) into the renderer.
// Reading large media through `readFileDataUrl` failed: it base64-loads the
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
// so any non-trivial video silently refused to load. Streaming via a protocol
// handler removes the size cap and gives the <video> element seekable,
// range-aware playback. Must be registered before the app is ready.
const MEDIA_PROTOCOL = 'hermes-media'
// Only audio/video may be streamed. Without this the handler would read any
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
const STREAMABLE_MEDIA_EXTS = new Set([
'.avi',
'.flac',
'.m4a',
'.mkv',
'.mov',
'.mp3',
'.mp4',
'.ogg',
'.opus',
'.wav',
'.webm'
])
protocol.registerSchemesAsPrivileged([
{
scheme: MEDIA_PROTOCOL,
privileges: {
secure: true,
standard: true,
stream: true,
supportFetchAPI: true
}
}
])
function registerMediaProtocol() {
protocol.handle(MEDIA_PROTOCOL, async request => {
let resolvedPath
try {
const url = new URL(request.url)
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
} catch {
return new Response('Media not found', { status: 404 })
}
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
return new Response('Unsupported media type', { status: 415 })
}
// Delegate to Electron's net stack on a file:// URL — it resolves the
// content-type and honors Range requests so seeking works. Forward the
// renderer's headers (notably Range) and skip custom-protocol re-entry.
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
bypassCustomProtocolHandlers: true,
headers: request.headers
})
})
}
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
@@ -373,6 +435,9 @@ let connectionPromise = null
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@@ -1678,12 +1743,15 @@ async function ensureRuntime(backend) {
})
} catch {}
bootstrapAbortController = new AbortController()
const bootstrapResult = await runBootstrap({
installStamp: backend.installStamp,
activeRoot: backend.activeRoot,
sourceRepoRoot: SOURCE_REPO_ROOT,
hermesHome: HERMES_HOME,
logRoot: path.join(HERMES_HOME, 'logs'),
abortSignal: bootstrapAbortController.signal,
onEvent: ev => {
// Tee every bootstrap event to (a) the desktop log for forensics
// and (b) the renderer for live progress UI. Either may be absent;
@@ -1699,6 +1767,16 @@ async function ensureRuntime(backend) {
writeMarker: writeBootstrapMarker
})
bootstrapAbortController = null
if (bootstrapResult.cancelled) {
const cancelledError = new Error('Hermes install was cancelled.')
cancelledError.isBootstrapFailure = true
cancelledError.bootstrapCancelled = true
bootstrapFailure = cancelledError
throw cancelledError
}
if (!bootstrapResult.ok) {
const bootstrapError = new Error(
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
@@ -3194,6 +3272,18 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
// resolves with { cancelled: true }, which surfaces the recovery overlay.
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
return { ok: true, cancelled: true }
}
return { ok: false, cancelled: false }
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
@@ -3654,6 +3744,7 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(null)
}
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
createWindow()
@@ -3663,6 +3754,13 @@ app.whenReady().then(() => {
})
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
}
if (desktopLogFlushTimer) {
clearTimeout(desktopLogFlushTimer)
desktopLogFlushTimer = null
+1
View File
@@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
+1 -1
View File
@@ -32,7 +32,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+1 -1
View File
@@ -97,7 +97,7 @@ function ChatHeader({
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
+255 -40
View File
@@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -33,7 +33,7 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@@ -54,7 +54,8 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds
$workingSessionIds,
sessionPinId
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
@@ -67,8 +68,18 @@ import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
const SIDEBAR_NAV: SidebarNavItem[] = [
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{
id: 'new-session',
label: 'New session',
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{
id: 'skills',
label: 'Skills & Tools',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE
},
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
]
@@ -115,6 +126,31 @@ const baseName = (path: string) =>
.filter(Boolean)
.pop()
// FTS results cover sessions that aren't in the loaded page; synthesize a
// minimal SessionInfo so they render in the same row component (resume works
// by id; the snippet stands in for the preview).
function searchResultToSession(result: SessionSearchResult): SessionInfo {
const ts = result.session_started ?? Date.now() / 1000
return {
archived: false,
cwd: null,
ended_at: null,
id: result.session_id,
input_tokens: 0,
is_active: false,
last_active: ts,
message_count: 0,
model: result.model ?? null,
output_tokens: 0,
preview: result.snippet?.trim() || null,
source: result.source ?? null,
started_at: ts,
title: null,
tool_call_count: 0
}
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
@@ -128,6 +164,14 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
groups.set(id, group)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
return [...groups.values()]
}
@@ -149,6 +193,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onLoadMoreSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
}
export function ChatSidebar({
@@ -156,7 +202,9 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onResumeSession,
onDeleteSession
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const agentsGrouped = useStore($sidebarAgentsGrouped)
@@ -170,6 +218,9 @@ export function ChatSidebar({
const workingSessionIds = useStore($workingSessionIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const trimmedQuery = searchQuery.trim()
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
@@ -180,24 +231,99 @@ export function ChatSidebar({
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = useMemo(
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
[pinnedSessionIds, sessionsById]
)
// Index sessions by both their live id and their lineage-root id so a pin
// stored as the pre-compression root resolves to the live continuation tip.
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
for (const s of sessions) {
map.set(s.id, s)
const pinnedSessions = useMemo(
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
[visiblePinnedIds, sessionsById]
)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
map.set(s._lineage_root_id, s)
}
}
return map
}, [sessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
const out: SessionInfo[] = []
for (const pinId of pinnedSessionIds) {
const session = sessionByAnyId.get(pinId)
if (session && !seen.has(session.id)) {
seen.add(session.id)
out.push(session)
}
}
return out
}, [pinnedSessionIds, sessionByAnyId])
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
// Full-text search across *all* sessions (not just the loaded page) so 699
// sessions stay findable. Debounced; loaded sessions are matched instantly
// client-side and merged ahead of the server hits.
useEffect(() => {
if (!trimmedQuery) {
setServerMatches([])
return
}
let cancelled = false
const id = window.setTimeout(() => {
void searchSessions(trimmedQuery)
.then(res => {
if (!cancelled) {
setServerMatches(res.results)
}
})
.catch(() => undefined)
}, 200)
return () => {
cancelled = true
window.clearTimeout(id)
}
}, [trimmedQuery])
const searchResults = useMemo(() => {
if (!trimmedQuery) {
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
out.set(s.id, s)
}
}
for (const match of serverMatches) {
if (out.has(match.session_id)) {
continue
}
const loaded = sessionByAnyId.get(match.session_id)
out.set(match.session_id, loaded ?? searchResultToSession(match))
}
return [...out.values()]
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
[sortedSessions, visiblePinnedIdSet]
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
)
const agentSessions = useMemo(
@@ -227,7 +353,10 @@ export function ChatSidebar({
return
}
reorderPinnedSession(String(active.id), newIndex)
// Sortable ids are live session ids; the pinned store is keyed by durable
// (lineage-root) ids, so translate before reordering.
const dragged = sessionByAnyId.get(String(active.id))
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
}
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
@@ -322,12 +451,63 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
</div>
)}
{sidebarOpen && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
No sessions match {trimmedQuery}.
</div>
}
label="Results"
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
@@ -342,7 +522,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && (
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -361,9 +541,9 @@ export function ChatSidebar({
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
@@ -372,15 +552,17 @@ export function ChatSidebar({
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Agents"
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
@@ -472,7 +654,9 @@ interface SidebarSessionsSectionProps {
workingSessionIdSet: Set<string>
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
onNewSessionInWorkspace?: (path: null | string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
@@ -496,7 +680,9 @@ function SidebarSessionsSection({
workingSessionIdSet,
onResumeSession,
onDeleteSession,
onArchiveSession,
onTogglePin,
onNewSessionInWorkspace,
pinned,
rootClassName,
contentClassName,
@@ -518,8 +704,9 @@ function SidebarSessionsSection({
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
session
}
@@ -551,9 +738,19 @@ function SidebarSessionsSection({
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
<SortableSidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
) : (
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
<SidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
)
)
@@ -568,6 +765,7 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
@@ -610,6 +808,7 @@ function SidebarSessionsSection({
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
@@ -618,6 +817,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
function SidebarWorkspaceGroup({
group,
renderRows,
onNewSession,
reorderable = false,
dragging = false,
dragHandleProps,
@@ -634,18 +834,31 @@ function SidebarWorkspaceGroup({
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<button
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{onNewSession && (
<button
aria-label={`New session in ${group.label}`}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
)}
{reorderable && (
<span
{...dragHandleProps}
@@ -663,7 +876,7 @@ function SidebarWorkspaceGroup({
/>
</span>
)}
</button>
</div>
{open && (
<>
{renderRows(visibleSessions)}
@@ -687,6 +900,7 @@ function SidebarWorkspaceGroup({
interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
@@ -702,6 +916,7 @@ interface SortableSessionRowProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -26,6 +26,7 @@ interface SessionActions {
title: string
pinned?: boolean
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
}
@@ -40,7 +41,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
setRenameOpen(true)
}
},
{
disabled: !onArchive,
icon: 'archive',
label: 'Archive',
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
}
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -45,6 +46,7 @@ export function SidebarSessionRow({
isPinned,
isSelected,
isWorking,
onArchive,
onDelete,
onPin,
onResume,
@@ -61,7 +63,14 @@ export function SidebarSessionRow({
const handleLabel = `Reorder ${title}`
return (
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<SessionContextMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<div
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
@@ -88,6 +97,15 @@ export function SidebarSessionRow({
return
}
if (event.metaKey || event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onArchive()
return
}
onResume()
}}
type="button"
@@ -127,7 +145,14 @@ export function SidebarSessionRow({
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
@@ -5,6 +5,7 @@ import { type FC, useCallback, useMemo, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
@@ -12,6 +13,7 @@ interface SessionRowCommonProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -20,6 +22,7 @@ interface SessionRowCommonProps {
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
onArchiveSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
@@ -35,6 +38,7 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
onArchiveSession,
onDeleteSession,
onResumeSession,
onTogglePin,
@@ -72,8 +76,9 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id)
}
+7 -371
View File
@@ -3,37 +3,29 @@ import {
IconBookmark,
IconBookmarkFilled,
IconDownload,
IconLoader2,
IconRefresh,
IconSparkles,
IconTrash
} from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getActionStatus,
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
searchSessions,
setModelAssignment,
updateHermes
} from '@/hermes'
import type {
ActionStatusResponse,
AnalyticsResponse,
AuxiliaryModelsResponse,
ModelOptionProvider,
SessionInfo,
SessionSearchResult as SessionSearchApiResult,
StatusResponse
} from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@@ -47,30 +39,9 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
// are opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
@@ -79,7 +50,6 @@ interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
onDeleteSession: (sessionId: string) => Promise<void>
onMainModelChanged?: (provider: string, model: string) => void
onNavigateRoute: (path: string) => void
onOpenSession: (sessionId: string) => void
}
@@ -87,14 +57,12 @@ interface CommandCenterViewProps {
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models',
usage: 'Usage'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
models: 'Global and auxiliary model controls',
usage: 'Token, cost, and skill activity over time'
}
@@ -113,9 +81,9 @@ interface SectionSearchEntry {
}
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
{
id: 'nav-messaging',
route: MESSAGING_ROUTE,
@@ -128,7 +96,6 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
]
@@ -216,7 +183,6 @@ export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onMainModelChanged,
onNavigateRoute,
onOpenSession
}: CommandCenterViewProps) {
@@ -233,16 +199,6 @@ export function CommandCenterView({
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applyingModel, setApplyingModel] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
const [usageLoading, setUsageLoading] = useState(false)
@@ -265,11 +221,6 @@ export function CommandCenterView({
[sessions]
)
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
() => [
{
@@ -342,29 +293,6 @@ export function CommandCenterView({
}
}, [])
const refreshModels = useCallback(async () => {
setModelsLoading(true)
setModelsError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setModelsLoading(false)
}
}, [])
const refreshUsage = useCallback(async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
usageRequestRef.current = requestId
@@ -430,28 +358,12 @@ export function CommandCenterView({
}
}, [refreshSystem, section, status, systemLoading])
useEffect(() => {
if (section === 'models' && !mainModel && !modelsLoading) {
void refreshModels()
}
}, [mainModel, modelsLoading, refreshModels, section])
useEffect(() => {
if (section === 'usage') {
void refreshUsage(usagePeriod)
}
}, [refreshUsage, section, usagePeriod])
useEffect(() => {
if (!selectedProviderModels.length) {
return
}
if (!selectedProviderModels.includes(selectedModel)) {
setSelectedModel(selectedProviderModels[0])
}
}, [selectedModel, selectedProviderModels])
const showGlobalSearchResults = debouncedQuery.length > 0
const hasGlobalSearchResults = searchGroups.length > 0
const sessionListHasResults = filteredSessions.length > 0
@@ -497,128 +409,6 @@ export function CommandCenterView({
[refreshSystem]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
const result = await setModelAssignment({
model: selectedModel,
provider: selectedProvider,
scope: 'main'
})
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[mainModel, refreshModels]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: auxDraft.model,
provider: auxDraft.provider,
scope: 'auxiliary',
task
})
setEditingAuxTask(null)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[auxDraft, refreshModels]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [mainModel, refreshModels])
const handleSearchSelect = useCallback(
(result: CommandCenterSearchResult) => {
if (result.kind === 'route') {
@@ -658,7 +448,7 @@ export function CommandCenterView({
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
@@ -684,12 +474,6 @@ export function CommandCenterView({
{usageLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
{modelsLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
</header>
{showGlobalSearchResults ? (
@@ -844,7 +628,7 @@ export function CommandCenterView({
period={usagePeriod}
usage={usage}
/>
) : section === 'system' ? (
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
{status ? (
@@ -902,154 +686,6 @@ export function CommandCenterView({
</pre>
</OverlayCard>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
{mainModel ? (
<>
<div className="text-sm font-medium text-foreground">Main model</div>
<div className="text-xs text-muted-foreground">
{mainModel.provider} / {mainModel.model}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">Loading model state...</div>
)}
</OverlayCard>
<OverlayCard className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedProvider(event.target.value)}
value={selectedProvider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedModel(event.target.value)}
value={selectedModel}
>
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!selectedProvider || !selectedModel || applyingModel}
onClick={() => void applyMainModel()}
>
{applyingModel ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<IconSparkles className="mr-1.5 size-3.5" />
)}
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
</div>
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void resetAuxiliaryModels()}
tone="subtle"
>
Reset all
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<OverlayCard className="px-2 py-1.5" key={meta.key}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground">{meta.label}</span>
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
</div>
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</div>
</div>
{!isEditing && (
<>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(meta.key)}
tone="subtle"
>
Set to main
</OverlayActionButton>
<OverlayActionButton
disabled={!providers.length || applyingModel}
onClick={() => beginAuxiliaryEdit(meta.key)}
>
Change
</OverlayActionButton>
</>
)}
</div>
{isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<select
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event =>
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
}
value={auxDraft.provider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
value={auxDraft.model}
>
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
onClick={() => void applyAuxiliaryDraft(meta.key)}
>
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
Cancel
</OverlayActionButton>
</div>
)}
</OverlayCard>
)
})}
</div>
</OverlayCard>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
+52 -12
View File
@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
@@ -31,8 +32,12 @@ import {
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
@@ -122,6 +127,7 @@ export function DesktopController() {
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
@@ -192,7 +198,10 @@ export function DesktopController() {
try {
const limit = $sessionsLimit.get()
const result = await listSessions(limit)
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
const result = await listSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
@@ -217,10 +226,14 @@ export function DesktopController() {
return
}
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
// Pin on the durable lineage-root id so the pin survives auto-compression.
const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId)
const pinId = session ? sessionPinId(session) : sessionId
if ($pinnedSessionIds.get().includes(pinId)) {
unpinSession(pinId)
} else {
pinSession(sessionId)
pinSession(pinId)
}
}, [])
@@ -324,6 +337,7 @@ export function DesktopController() {
})
const {
archiveSession,
branchCurrentSession,
createBackendSessionForSend,
openSettings,
@@ -392,6 +406,29 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions]
)
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
const target = path?.trim()
if (!target) {
return
}
// The next message creates the backend session in $currentCwd, so seed
// it (and the branch) from the workspace the user clicked the + on.
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
setCurrentCwd(info.cwd || target)
setCurrentBranch(info.branch || '')
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
const handleSkinCommand = useSkinCommand()
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
@@ -461,9 +498,11 @@ export function DesktopController() {
const sidebar = (
<ChatSidebar
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
@@ -485,6 +524,7 @@ export function DesktopController() {
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
{settingsOpen && (
@@ -497,6 +537,13 @@ export function DesktopController() {
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
@@ -507,13 +554,6 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
@@ -575,10 +615,10 @@ export function DesktopController() {
titlebarTools={titlebarToolGroups.flat.right}
>
<Pane
disabled={terminalTakeoverActive}
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
disabled={terminalTakeoverActive}
resizable
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
@@ -751,7 +751,39 @@ export function useSessionActions({
]
)
const archiveSession = useCallback(
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
if (wasSelected) {
startFreshSessionDraft(true)
}
try {
await setSessionArchived(storedSessionId, true)
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
}
$pinnedSessionIds.set(previousPinned)
notifyError(err, 'Archive failed')
}
},
[selectedStoredSessionId, startFreshSessionDraft]
)
return {
archiveSession,
branchCurrentSession,
closeSettings,
createBackendSessionForSend,
@@ -18,6 +18,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
@@ -167,10 +168,12 @@ export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: SearchProps & {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject<HTMLInputElement | null>
}) {
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
@@ -322,6 +325,11 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && !query.trim() && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
+5 -17
View File
@@ -141,13 +141,7 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'auxiliary.vision.provider': 'Vision Provider',
'auxiliary.vision.model': 'Vision Model',
'auxiliary.compression.provider': 'Compression Provider',
'auxiliary.compression.model': 'Compression Model',
'auxiliary.title_generation.provider': 'Title Provider',
'auxiliary.title_generation.model': 'Title Model'
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -183,7 +177,7 @@ export const SECTIONS: DesktopConfigSection[] = [
id: 'model',
label: 'Model',
icon: Sparkles,
keys: ['model', 'model_context_length', 'fallback_providers']
keys: ['model_context_length', 'fallback_providers']
},
{
id: 'chat',
@@ -287,13 +281,7 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort',
'auxiliary.vision.provider',
'auxiliary.vision.model',
'auxiliary.compression.provider',
'auxiliary.compression.model',
'auxiliary.title_generation.provider',
'auxiliary.title_generation.model'
'delegation.reasoning_effort'
]
}
]
@@ -311,11 +299,11 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
tools: 'Search skills and tools...'
sessions: 'Search archived sessions...'
}
+13 -12
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -19,7 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { ToolsSettings } from './tools-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
@@ -27,11 +27,11 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'tools',
'sessions',
'about'
]
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
@@ -40,7 +40,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
gateway: '',
keys: '',
mcp: '',
tools: ''
sessions: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -137,18 +137,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
label="API Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
active={activeView === 'tools'}
icon={Package}
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label="MCP"
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label="Archived Chats"
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
@@ -194,6 +194,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
@@ -201,7 +202,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : (
<ToolsSettings query={queries.tools} />
<SessionsSettings query={queries.sessions} />
)}
</OverlayMain>
</OverlaySplitLayout>
@@ -0,0 +1,70 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body)
}))
beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModelSettings() {
const { ModelSettings } = await import('./model-settings')
return render(<ModelSettings />)
}
describe('ModelSettings', () => {
it('loads and shows the current main model', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
expect(screen.getByText('nous / hermes-4')).toBeTruthy()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()
expect(await screen.findByText('Vision')).toBeTruthy()
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
})
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
await renderModelSettings()
// One "Set to main" button per task slot; the first is Vision.
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'hermes-4',
provider: 'nous',
scope: 'auxiliary',
task: 'vision'
})
)
})
})
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments readable; raw task keys (vision, mcp, …) are
// opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
interface ModelSettingsProps {
/** Notified after the main model is applied, so live UI stores can sync. */
onMainModelChanged?: (provider: string, model: string) => void
}
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const providerOptions = providers.length ? providers : NO_PROVIDERS
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplying(true)
setError('')
try {
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [onMainModelChanged, refresh, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task })
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[mainModel, refresh]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task })
setEditingAuxTask(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[auxDraft, refresh]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [mainModel, refresh])
if (loading && !mainModel) {
return <LoadingState label="Loading model configuration..." />
}
return (
<div className="grid gap-6">
<section>
<SectionHeading
icon={Sparkles}
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
title="Main model"
/>
<p className="mb-3 text-xs text-muted-foreground">
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
</p>
<div className="flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
</div>
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
</section>
<section>
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title="Auxiliary models" />
<Button
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="outline"
>
Reset all to main
</Button>
</div>
<p className="mb-2 text-xs text-muted-foreground">
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
</p>
<div className="divide-y divide-border/40">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<ListRow
action={
!isEditing && (
<div className="flex shrink-0 items-center gap-1.5">
<Button
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="ghost"
>
Set to main
</Button>
<Button
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="outline"
>
Change
</Button>
</div>
)
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
value={auxDraft.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!auxDraft.provider || !auxDraft.model || applying}
onClick={() => void applyAuxiliaryDraft(meta.key)}
size="sm"
>
{applying ? 'Applying...' : 'Apply'}
</Button>
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
Cancel
</Button>
</div>
)
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}
title={
<span className="flex items-baseline gap-2">
{meta.label}
<Pill>{meta.hint}</Pill>
</span>
}
/>
)
})}
</div>
</section>
</div>
)
}
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
const ARCHIVED_FETCH_LIMIT = 200
function workspaceLabel(cwd: null | string | undefined): string {
const path = cwd?.trim()
if (!path) {
return ''
}
return (
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
.pop() ?? path
)
}
export function SessionsSettings({ query }: SearchProps) {
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, 'Could not load archived sessions')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const unarchive = useCallback(async (session: SessionInfo) => {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
} catch (err) {
notifyError(err, 'Unarchive failed')
} finally {
setBusyId(null)
}
}, [])
const remove = useCallback(async (session: SessionInfo) => {
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
return
}
setBusyId(session.id)
try {
await deleteSession(session.id)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
notifyError(err, 'Delete failed')
} finally {
setBusyId(null)
}
}, [])
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) {
return sessions
}
return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
)
}, [query, sessions])
if (loading) {
return <LoadingState label="Loading archived sessions…" />
}
return (
<SettingsContent>
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
title="Archived sessions"
/>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/-click a chat in the sidebar to
archive it.
</p>
{filtered.length === 0 ? (
<EmptyState
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
title="Nothing archived"
/>
) : (
<div className="divide-y divide-border/30">
{filtered.map(session => {
const label = workspaceLabel(session.cwd)
const busy = busyId === session.id
return (
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="outline"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
key={session.id}
title={sessionTitle(session)}
/>
)
})}
</div>
)}
</SettingsContent>
)
}
@@ -1,229 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { Brain, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import { ToolsetConfigPanel } from './toolset-config-panel'
import type { SearchProps } from './types'
export function ToolsSettings({ query }: SearchProps) {
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
Promise.all([getSkills(), getToolsets()])
.then(([s, t]) => {
if (cancelled) {
return
}
setSkills(s)
setToolsets(t)
})
.catch(err => notifyError(err, 'Capabilities failed to load'))
return () => void (cancelled = true)
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
const filteredSkills = useMemo(() => {
if (!skills) {
return []
}
const q = query.trim().toLowerCase()
return skills
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
.sort(
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
)
}, [query, skills])
const filteredToolsets = useMemo(() => {
if (!toolsets) {
return []
}
const q = query.trim().toLowerCase()
return toolsets
.filter(t => {
if (!q) {
return true
}
return (
includesQuery(t.name, q) ||
includesQuery(t.label, q) ||
includesQuery(t.description, q) ||
toolNames(t).some(n => includesQuery(n, q))
)
})
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}, [query, toolsets])
const skillGroups = useMemo(() => {
const groups = new Map<string, SkillInfo[]>()
for (const skill of filteredSkills) {
const cat = asText(skill.category) || 'other'
groups.set(cat, [...(groups.get(cat) ?? []), skill])
}
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
}, [filteredSkills])
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSavingSkill(skill.name)
try {
await toggleSkill(skill.name, enabled)
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
if (!skills || !toolsets) {
return <LoadingState label="Loading skills and toolsets..." />
}
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
{skillGroups.map(([category, list]) => (
<div className="mt-4 first:mt-0" key={category}>
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-border/40">
{list.map(skill => (
<ListRow
action={
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={c => void handleToggleSkill(skill, c)}
/>
}
description={asText(skill.description)}
key={asText(skill.name)}
title={asText(skill.name)}
/>
))}
</div>
</div>
))}
</div>
<div className="mb-6">
<SectionHeading
icon={Wrench}
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
title="Toolsets"
/>
<div className="divide-y divide-border/40">
{filteredToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<ListRow
action={
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
type="button"
>
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</Pill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={c => void handleToggleToolset(toolset, c)}
/>
</div>
}
below={
<>
{tools.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{tools.slice(0, 10).map(t => (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
key={t}
>
{t}
</span>
))}
{tools.length > 10 && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
+{tools.length - 10} more
</span>
)}
</div>
)}
{expanded && (
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
)}
</>
}
description={asText(toolset.description)}
key={asText(toolset.name) || label}
title={label}
/>
)
})}
</div>
</div>
</SettingsContent>
)
}
@@ -26,6 +26,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'tts',
has_category: true,
active_provider: null,
providers: [
{
name: 'Microsoft Edge TTS',
@@ -33,7 +34,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false
requires_nous_auth: false,
is_active: false
},
{
name: 'ElevenLabs',
@@ -43,7 +45,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
],
post_setup: null,
requires_nous_auth: false
requires_nous_auth: false,
is_active: false
}
],
...overrides
@@ -99,4 +102,54 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
})
it('expands the active provider on load, not just the first configured one', async () => {
// ElevenLabs is the active provider per config, even though the keyless
// Edge TTS provider sorts first and is also "configured". The panel must
// honor is_active and expand ElevenLabs (so its API-key field renders)
// rather than defaulting to the first keyless provider. Regression test
// for the GUI showing the wrong provider selected after relaunch.
getToolsetConfig.mockResolvedValue(
config({
active_provider: 'ElevenLabs',
providers: [
{
name: 'Microsoft Edge TTS',
badge: 'free',
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: false
},
{
name: 'ElevenLabs',
badge: 'paid',
tag: 'Most natural voices',
env_vars: [
{
key: 'ELEVENLABS_API_KEY',
prompt: 'ElevenLabs API key',
url: 'https://x',
default: null,
is_set: true
}
],
post_setup: null,
requires_nous_auth: false,
is_active: true
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// The active provider's env-var field only renders when it's the expanded
// one — so finding it proves ElevenLabs (not Edge TTS) was auto-expanded.
expect(await screen.findByText('ELEVENLABS_API_KEY')).toBeTruthy()
// No provider selection was triggered — this is purely reflecting state.
expect(selectToolsetProvider).not.toHaveBeenCalled()
})
})
@@ -195,16 +195,23 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
// Default the expanded provider to the first one that is fully configured,
// else the first provider.
// Default the expanded provider to the one actually active in config
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
// first fully-configured provider, else the first provider. Without this the
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
useEffect(() => {
if (activeProvider || providers.length === 0) {
return
}
const configured = providers.find(p => providerConfigured(p, envState))
setActiveProvider((configured ?? providers[0]).name)
}, [activeProvider, providers, envState])
const selected =
providers.find(p => p.is_active) ??
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
async function handleSelect(provider: ToolProvider) {
setActiveProvider(provider.name)
+3 -2
View File
@@ -4,14 +4,15 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['models', 'sessions', 'system'] as const
const SECTIONS = ['sessions', 'system', 'usage'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
@@ -1,16 +1,24 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getSkills = vi.fn()
const getToolsets = vi.fn()
const toggleSkill = vi.fn()
const toggleToolset = vi.fn()
const getToolsetConfig = vi.fn()
const selectToolsetProvider = vi.fn()
vi.mock('@/hermes', () => ({
getSkills: () => getSkills(),
getToolsets: () => getToolsets(),
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
deleteEnvVar: vi.fn(),
revealEnvVar: vi.fn(),
setEnvVar: vi.fn()
}))
// Notifications hit nanostores/timers we don't care about here.
@@ -32,10 +40,21 @@ function toolset(overrides: Record<string, unknown> = {}) {
}
}
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
</MemoryRouter>
)
)
}
beforeEach(() => {
getSkills.mockResolvedValue([])
getToolsets.mockResolvedValue([toolset()])
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
})
afterEach(() => {
@@ -43,10 +62,9 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('ToolsSettings toolset toggle', () => {
describe('SkillsView toolset management', () => {
it('renders a switch for each toolset and toggles it off', async () => {
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
await renderSkills()
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(sw.getAttribute('aria-checked')).toBe('true')
@@ -57,10 +75,18 @@ describe('ToolsSettings toolset toggle', () => {
})
it('keeps the configured pill alongside the switch', async () => {
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
await renderSkills()
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(screen.getByText('Configured')).toBeTruthy()
})
it('expands the provider config panel when the configured pill is clicked', async () => {
await renderSkills()
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
fireEvent.click(configureBtn)
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
})
+50 -6
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
@@ -14,6 +14,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
const SKILLS_MODES = ['skills', 'toolsets'] as const
@@ -73,6 +74,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
@@ -88,6 +91,12 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
useEffect(() => {
void refreshCapabilities()
}, [refreshCapabilities])
@@ -148,6 +157,26 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
return (
<PageSearchShell
{...props}
@@ -248,16 +277,30 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<div className="px-0 py-2.5" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
type="button"
>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
/>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
@@ -275,6 +318,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
))}
</div>
)}
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
</div>
)
})}
@@ -19,9 +19,9 @@ import {
filePathFromMediaPath,
mediaExternalUrl,
mediaKind,
mediaMime,
mediaName,
mediaPathFromMarkdownHref
mediaPathFromMarkdownHref,
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
@@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
const blob = await fetch(dataUrl).then(response => response.blob())
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
@@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
// render, which churns Streamdown's outer memo + propagates new prop
// identities into every Block. The plugin set really only varies on
// `isStreaming`, so memoize on that.
const plugins = useMemo(
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
[isStreaming]
)
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
const components = useMemo(
() =>
@@ -62,9 +62,7 @@ function formatStageName(name: string): string {
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
)
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(' ')
}
@@ -116,17 +114,10 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
state === 'failed' && 'bg-destructive/10'
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</div>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className={cn(
'truncate text-sm font-medium',
state === 'pending' && 'text-muted-foreground'
)}
>
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
@@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
{state === 'failed' ? STATE_LABEL[state] : null}
</span>
</div>
{reason && state !== 'pending' && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
)}
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
</div>
</li>
)
@@ -180,7 +169,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
durationMs: ev.durationMs ?? null,
// Stamp the start time on the running transition so the UI can show
// a live elapsed timer; preserve it across repeated running events.
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
json: ev.json ?? null,
error: ev.error ?? null
}
@@ -217,6 +206,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
run the command below, then relaunch this app. Subsequent launches will skip this step.
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
command below, then relaunch this app. Subsequent launches will skip this step.
</p>
<div className="mt-4">
@@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button
variant="default"
size="sm"
onClick={() => window.location.reload()}
>
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
I{'\u2019'}ve run it -- retry
</Button>
</div>
@@ -362,7 +348,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
@@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full transition-all duration-300',
failed ? 'bg-destructive' : 'bg-primary'
)}
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
<span className="ml-1 tabular-nums">
({state.log.length} line{state.log.length === 1 ? '' : 's'})
</span>
</button>
{logOpen && (
<div className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}>
<div
className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}
>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
) : (
@@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
</div>
{/* Active footer: let the user actually cancel a running install. */}
{state.active && !failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-end">
<Button
disabled={cancelling}
onClick={async () => {
setCancelling(true)
try {
await window.hermesDesktop?.cancelBootstrap?.()
} catch {
// ignore -- the failed/cancelled event will surface the result
}
}}
size="sm"
variant="ghost"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{cancelling ? 'Cancelling...' : 'Cancel install'}
</Button>
</div>
</div>
)}
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
Full transcript saved to{' '}
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
@@ -0,0 +1,183 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
// the render level means no timer logic (even a stale HMR one) can ever
// scramble "CONN".
const PREFIX = 'CONN'
const TAIL = 'ECTING'
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
// nousnet-web download-button decode effect).
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
const TEXT_OUT_MS = 360
const POST_TEXT_HOLD_MS = 300
const OVERLAY_OUT_MS = 520
// Preview-only: how long to "connect" for, and the pause before replaying.
const PREVIEW_CONNECT_MS = 2600
const PREVIEW_REPLAY_MS = 1100
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
// only flashes. Load with `?connecting=1` to force a looping preview.
function forcedPreview(): boolean {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false
}
try {
return new URLSearchParams(window.location.search).get('connecting') === '1'
} catch {
return false
}
}
function scrambledTail(resolvedCount: number): string {
return Array.from(TAIL, (ch, i) =>
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
).join('')
}
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const [previewing] = useState(forcedPreview)
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
const connecting = gatewayState !== 'open' && !boot.error
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.
const shownRef = useRef(false)
if (previewing || connecting) {
shownRef.current = true
}
// Decode loop — only while live (freeze the resolved word during the exit).
useEffect(() => {
if (phase !== 'live' || (!previewing && !connecting)) {
return
}
let resolved = 0
let hold = 0
const id = window.setInterval(() => {
if (resolved >= TAIL.length) {
hold += 1
if (hold > 16) {
resolved = 0
hold = 0
}
setTail(TAIL)
return
}
resolved += 0.5
setTail(scrambledTail(Math.floor(resolved)))
}, TICK_MS)
return () => window.clearInterval(id)
}, [phase, previewing, connecting])
// Kick off the exit when connected: real connect, or a faked timer in preview.
useEffect(() => {
if (phase !== 'live') {
return
}
if (previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('text-out')
}, PREVIEW_CONNECT_MS)
return () => window.clearTimeout(id)
}
if (gatewayState === 'open' && shownRef.current) {
setTail(TAIL)
setPhase('text-out')
}
}, [phase, previewing, gatewayState])
// Advance the exit choreography: text-out -> overlay-out -> gone.
useEffect(() => {
if (phase === 'text-out') {
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
return () => window.clearTimeout(id)
}
if (phase === 'overlay-out') {
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
return () => window.clearTimeout(id)
}
// Preview replays so we can keep watching the transition.
if (phase === 'gone' && previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('live')
}, PREVIEW_REPLAY_MS)
return () => window.clearTimeout(id)
}
}, [phase, previewing])
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
if (boot.error && !previewing) {
return null
}
// Real connect: once the fade finishes, get out of the way for good.
if (phase === 'gone' && !previewing) {
return null
}
// Never showed (e.g. gateway already up on a warm reload) — stay out.
if (!previewing && !connecting && !shownRef.current) {
return null
}
const leaving = phase !== 'live'
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
return (
<div
className={cn(
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
<span
className={cn(
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
{PREFIX}
{tail}
<span
aria-hidden="true"
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
style={{ animation: 'gco-cursor 1s step-end infinite' }}
/>
</span>
</div>
)
}
+2 -7
View File
@@ -48,6 +48,7 @@ declare global {
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
@@ -194,12 +195,7 @@ export interface DesktopBootstrapStageDescriptor {
needs_user_input?: boolean
}
export type DesktopBootstrapStageState =
| 'pending'
| 'running'
| 'succeeded'
| 'skipped'
| 'failed'
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
@@ -248,7 +244,6 @@ export type DesktopBootstrapEvent =
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string
+15 -2
View File
@@ -111,9 +111,14 @@ export class HermesGateway extends JsonRpcGatewayClient {
}
}
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
})
return {
@@ -123,6 +128,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
}
}
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
@@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
const PICKER_OWNED_COMMANDS = new Set(['/model'])
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
+4
View File
@@ -2,6 +2,8 @@ import {
IconActivity as Activity,
IconAlertCircle as AlertCircle,
IconAlertTriangle as AlertTriangle,
IconArchive as Archive,
IconArchiveOff as ArchiveOff,
IconArrowUp as ArrowUp,
IconArrowUpRight as ArrowUpRight,
IconAt as AtSign,
@@ -98,6 +100,8 @@ export {
Activity,
AlertCircle,
AlertTriangle,
Archive,
ArchiveOff,
ArrowUp,
ArrowUpRight,
AtSign,
+7
View File
@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
// file with Range support. Used for audio/video so playback bypasses the data
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
export function mediaStreamUrl(path: string): string {
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
}
export function mediaPathFromMarkdownHref(href?: string): string | null {
if (!href?.startsWith('#media:')) {
return null
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { sessionPinId } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
cwd: null,
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: null,
tool_call_count: 0,
...over
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
})
it('uses the lineage root so a pin survives compression', () => {
// After auto-compression the entry surfaces under a fresh tip id but keeps
// the original root — pinning on the root keeps the pin stable.
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
+6
View File
@@ -16,6 +16,12 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
}
/** Durable id for pinning. Auto-compression rotates a conversation's session
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
* lineage root is stable across every compression, so we pin on that. */
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({
persistString: (key: string, value: null | string) => {
if (value === null) {
storage.delete(key)
} else {
storage.set(key, value)
}
},
storedString: (key: string) => storage.get(key) ?? null
}))
const notifySpy = vi.fn()
const dismissSpy = vi.fn()
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => notifySpy(...args),
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
}))
const { maybeNotifyUpdateAvailable } = await import('./updates')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
supported: true,
behind: 3,
targetSha: 'sha-a',
fetchedAt: 0,
...over
})
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
describe('maybeNotifyUpdateAvailable', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
vi.useRealTimers()
})
it('shows when an update is available and not snoozed', () => {
maybeNotifyUpdateAvailable(status())
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('stays quiet for new commits once the toast was closed', () => {
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// A different commit lands while still within the cooldown window.
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
expect(notifySpy).not.toHaveBeenCalled()
})
it('re-shows once the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('does nothing when already up to date', () => {
maybeNotifyUpdateAvailable(status({ behind: 0 }))
expect(notifySpy).not.toHaveBeenCalled()
})
})
+25 -18
View File
@@ -48,7 +48,22 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
const UPDATE_TOAST_ID = 'desktop-update-available'
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
// a day, so a "don't show this exact sha again" guard re-popped the toast on
// every new commit. We instead suppress the toast for a cooldown window that
// (re)starts whenever the user closes it.
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeUpdateToast(): void {
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
}
function isUpdateToastSnoozed(): boolean {
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
@@ -74,25 +89,18 @@ export function reportBackendContract(contract: number | undefined): void {
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
title: 'Backend out of date'
})
}
function markToastDismissed(sha: string | undefined) {
if (sha) {
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
}
}
/**
* Fire a one-shot toast the first time we see a particular target commit so
* users don't have to notice the status-bar version pill turning colors.
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
* back for the same update across restarts.
* Fire a toast when an update is available, at most once per cooldown window.
* Closing the toast dismissing it or opening the updates window from it
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
* on every new commit. The snooze is persisted, so it survives relaunches too.
*/
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
if (!status || status.supported === false || status.error || !status.targetSha) {
return
}
@@ -101,7 +109,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
return
}
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
if (isUpdateToastSnoozed()) {
return
}
@@ -110,13 +118,12 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
}
const behind = status.behind ?? 0
const targetSha = status.targetSha
notify({
action: {
label: "See what's new",
onClick: () => {
markToastDismissed(targetSha)
snoozeUpdateToast()
openUpdatesWindow()
}
},
@@ -124,7 +131,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
onDismiss: () => markToastDismissed(targetSha),
onDismiss: () => snoozeUpdateToast(),
title: 'Update ready'
})
}
+10
View File
@@ -240,9 +240,14 @@ export interface SessionCreateResponse {
}
export interface SessionInfo {
archived?: boolean
cwd?: null | string
ended_at: null | number
id: string
/** Original root id of a compression chain, when this entry is a projected
* continuation tip. Stable across compressions used as the durable id for
* pins so a pinned conversation survives auto-compression. */
_lineage_root_id?: null | string
input_tokens: number
is_active: boolean
last_active: number
@@ -470,12 +475,17 @@ export interface ToolProvider {
env_vars: ToolEnvVar[]
post_setup: string | null
requires_nous_auth: boolean
/** True when this is the provider currently written to config (mirrors the
* CLI `hermes tools` active-provider detection). */
is_active: boolean
}
export interface ToolsetConfig {
name: string
has_category: boolean
providers: ToolProvider[]
/** Name of the currently active provider, or null if none is configured. */
active_provider: string | null
}
export interface SessionSearchResult {
+106 -3
View File
@@ -872,6 +872,17 @@ _cleanup_done = False
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
# Set True once the TUI's prompt_toolkit app starts (which enables focus
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
# escape codes for modes they never enabled (#36823).
_tui_input_modes_active = False
def _mark_tui_input_modes_active() -> None:
"""Record that the TUI app started, so _run_cleanup resets input modes."""
global _tui_input_modes_active
_tui_input_modes_active = True
def _prepare_deferred_agent_startup() -> None:
@@ -927,6 +938,12 @@ def _run_cleanup():
return
_cleanup_done = True
# Reset terminal input modes first, before the slower resource teardown
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
# user's terminal becomes usable immediately, and a later step raising
# can't skip the reset (#36823). No-op unless the TUI actually ran.
_reset_terminal_input_modes_on_exit()
try:
_cleanup_all_terminals()
except Exception:
@@ -972,6 +989,50 @@ def _run_cleanup():
pass
def _reset_terminal_input_modes_on_exit() -> None:
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
don't leak into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
SIGHUP and crashes can bypass its unwind, leaving the modes 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
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
``os._exit(0)`` path bypasses ``atexit``; neither runs this but both are
non-TTY / non-TUI, so there is nothing to reset there.
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
controlling terminal directly: by exit, prompt_toolkit's own output is torn
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
stdout is redirected away from the terminal.
"""
global _tui_input_modes_active
if not _tui_input_modes_active:
return
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
# a long-lived process that reuses it) doesn't re-emit them.
_tui_input_modes_active = False
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
# /dev/tty while stdout was redirected — reset there instead of nowhere.
try:
stream = sys.stdout
if stream is not None and stream.isatty():
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
stream.flush()
return
except Exception:
pass
try:
with open("/dev/tty", "w", encoding="ascii") as tty:
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
tty.flush()
except Exception:
pass
# =============================================================================
# Git Worktree Isolation (#652)
# =============================================================================
@@ -2116,6 +2177,41 @@ def _cprint(text: str):
pass
def _prepend_note_to_message(message, note: str):
"""Prepend a one-shot system-style note to a user message.
``message`` is normally a plain string, but when the user attaches an image
to a vision-capable model it becomes a list of OpenAI-style content parts
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
then raises ``TypeError: can only concatenate str (not "list") to str``
e.g. running ``/model ...`` (which queues a model-switch note) and then
sending a pasted image in the same turn.
Returns the message with ``note`` prepended:
* ``str`` ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
* ``list`` note folded into the first text part, or inserted as a new
leading ``{"type": "text"}`` part when there is no text part.
Unknown shapes are returned unchanged (fail-open).
"""
note = str(note or "").strip()
if not note:
return message
if isinstance(message, str):
return f"{note}\n\n{message}" if message else note
if isinstance(message, list):
parts = list(message)
for i, part in enumerate(parts):
if isinstance(part, dict) and part.get("type") == "text":
merged = dict(part)
text = merged.get("text", "")
merged["text"] = f"{note}\n\n{text}" if text else note
parts[i] = merged
return parts
# No text part (image-only) — insert the note as a leading text block.
return [{"type": "text", "text": note}, *parts]
return message
# ---------------------------------------------------------------------------
# File-drop / local attachment detection — extracted as pure helpers for tests.
# ---------------------------------------------------------------------------
@@ -12135,17 +12231,21 @@ class HermesCLI:
reset_current_session_key = None # type: ignore[assignment]
_approval_session_token = None
agent_message = _voice_prefix + message if _voice_prefix else message
# Prepend pending model switch note so the model knows about the switch
# Prepend pending notes via _prepend_note_to_message, which
# handles both plain-string and multimodal content-parts list
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
# TypeError when an image was attached (agent_message is a list)
# and a /model or /reload-skills note was queued for the turn.
_msn = getattr(self, '_pending_model_switch_note', None)
if _msn:
agent_message = _msn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _msn)
self._pending_model_switch_note = None
# Prepend pending /reload-skills note so the model sees which
# skills were added/removed before handling this turn. Same
# one-shot queue pattern as the model-switch note above.
_srn = getattr(self, '_pending_skills_reload_note', None)
if _srn:
agent_message = _srn + "\n\n" + agent_message
agent_message = _prepend_note_to_message(agent_message, _srn)
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
@@ -15096,6 +15196,9 @@ class HermesCLI:
pass # No running loop -- nothing to patch
except Exception:
pass
# The app enables focus reporting + mouse tracking; record that
# so _run_cleanup resets them on exit (#36823).
_mark_tui_input_modes_active()
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
+27 -8
View File
@@ -428,22 +428,18 @@ def load_jobs() -> List[Dict[str, Any]]:
ensure_dirs()
if not JOBS_FILE.exists():
return []
_strict_retry = False # track whether we used the strict=False fallback
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("jobs", [])
except json.JSONDecodeError:
# Retry with strict=False to handle bare control chars in string values
_strict_retry = True
try:
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
data = json.loads(f.read(), strict=False)
jobs = data.get("jobs", [])
if jobs:
# Auto-repair: rewrite with proper escaping
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
return jobs
except Exception as e:
logger.error("Failed to auto-repair jobs.json: %s", e)
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
@@ -451,6 +447,29 @@ def load_jobs() -> List[Dict[str, Any]]:
logger.error("IOError reading jobs.json: %s", e)
raise RuntimeError(f"Failed to read cron database: {e}") from e
# Validate the top-level JSON shape: accept a dict (expected) or a bare
# list (auto-repair). Anything else (str/number/null) is corruption that
# would otherwise raise an uncaught AttributeError on ``.get()`` and take
# down the whole cron subsystem.
if isinstance(data, dict):
jobs = data.get("jobs", [])
if _strict_retry and jobs:
# Hit control-character corruption — rewrite with proper escaping.
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
return jobs
if isinstance(data, list):
# Bare array — likely saved/edited outside save_jobs(). Wrap it back
# into the expected {"jobs": [...]} structure.
if data:
save_jobs(data)
logger.warning("Auto-repaired jobs.json (bare list wrapped as dict)")
return data
raise RuntimeError(
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}"
)
def save_jobs(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage."""
+13 -5
View File
@@ -1182,14 +1182,22 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
markdown often security docs / runbooks that *describe* attack
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
pattern set is used: only unambiguous prompt-injection directives
and invisible unicode block, command-shape patterns are dropped
to avoid false-positives. Skill bodies are vetted at install time
by ``skills_guard.py``.
block; command-shape patterns are dropped and invisible unicode is
sanitized (stripped + logged) rather than blocked, to avoid
false-positives that permanently kill a job. Skill bodies are
vetted at install time by ``skills_guard.py``.
"""
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
scan_error = scanner(assembled)
if has_skills:
# Skill content is install-time vetted by skills_guard.py. Invisible
# unicode is sanitized (not blocked) so a stray zero-width space in a
# skill code example can't permanently kill the job; the cleaned
# prompt is what actually runs.
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
assembled = cleaned
else:
scan_error = _scan_cron_prompt(assembled)
if scan_error:
job_label = job.get("name") or job.get("id") or "<unknown>"
logger.warning(
+10
View File
@@ -27,10 +27,20 @@ drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
# don't try to write to /root.
export HOME=/opt/data
# Save the Docker -w (or default) working directory before init
# scripts cd to /opt/data, so the container starts in the
# directory the user requested.
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
cd /opt/data
# shellcheck disable=SC1091
. /opt/hermes/.venv/bin/activate
# Restore the original working directory before handing off to
# the user's command so `hermes chat` starts in the Docker -w
# directory, not /opt/data.
cd "$_hermes_orig_cwd"
if [ $# -eq 0 ]; then
drop hermes
fi
+39
View File
@@ -0,0 +1,39 @@
# Multi-gateway deployment
Hermes supports multiple gateway processes running concurrently — one per profile
(default, writer, admin, coder, researcher). Each gateway opens its own connection
to platform APIs and delivers messages for its profile's subscribers.
## Single-dispatcher posture
Only one gateway owns the kanban dispatcher. The owning gateway keeps
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
to `false`.
**Why this matters:** a gateway with `dispatch_in_gateway: true` opens per-board
SQLite connections for both the dispatcher and the notifier watcher. Multiple
gateways doing this concurrently multiplies the open file descriptors on each
`kanban.db` and amplifies WAL `-shm` reader contention. Gating both paths on the
same flag means exactly one process touches the kanban DBs.
## Configuration
On the dispatch-owning gateway (typically the `default` profile), no change is
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
```yaml
kanban:
dispatch_in_gateway: false
```
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
## What each gateway does
| Gateway role | dispatch_in_gateway | Opens per-board DBs? | Runs dispatcher + notifier? |
|---|---|---|---|
| default (dispatch owner) | true (default) | yes | yes |
| writer, admin, coder, etc. | false | no | no |
Non-dispatch gateways still deliver messages for their own platform adapters
(Telegram, Discord, etc.) — they just don't poll kanban boards.
+27 -4
View File
@@ -361,10 +361,17 @@ class StreamingConfig:
# fall back to edit-based when not.
# "draft" — explicitly request native drafts; falls back to edit when
# the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy/default
# behaviour).
# "edit" — progressive editMessageText only (legacy behaviour).
# "off" — disable streaming entirely.
transport: str = "edit"
#
# Default is "auto": prefer native draft streaming on platforms that
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
# back to edit-based streaming everywhere else. This is safe as a global
# default because adapters without draft support (Discord, Slack, Matrix,
# …) report supports_draft_streaming() == False and transparently use the
# edit path — so "auto" never regresses non-Telegram platforms, it only
# upgrades the chats that can render the smoother native preview.
transport: str = "auto"
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = DEFAULT_STREAMING_CURSOR
@@ -393,7 +400,7 @@ class StreamingConfig:
return cls()
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
transport=data.get("transport", "edit"),
transport=data.get("transport", "auto"),
edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
),
@@ -1722,6 +1729,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
})
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
if bluebubbles_require_mention is not None:
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
)
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
if bluebubbles_mention_patterns:
try:
parsed_patterns = json.loads(bluebubbles_mention_patterns)
except Exception:
parsed_patterns = [
part.strip()
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
if part.strip()
]
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
+238 -12
View File
@@ -1265,6 +1265,107 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int:
return removed
# ---------------------------------------------------------------------------
# Unified media caching
#
# One entry point for "I have raw attachment bytes from a platform — cache them
# and tell me what I got." Classifies by extension/MIME against the shared
# registries above, routes to the right cache_*_from_bytes helper, and returns
# a small result the caller can store and/or describe in a transcript. Used by
# both the addressed-message path and the observed-group-context path, on any
# platform — not Telegram-specific.
# ---------------------------------------------------------------------------
@dataclass
class CachedMedia:
"""Result of caching one attachment's bytes."""
path: str # absolute cache path, agent-visible (sandbox-translated)
media_type: str # MIME type recorded on the MessageEvent
kind: str # "image" | "video" | "audio" | "document"
display_name: str # human-readable name for transcript notes
def context_note(self) -> str:
"""One-line transcript annotation pointing the agent at the file."""
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
def _resolve_media_ext(filename: str, mime_type: str) -> str:
"""Best-effort file extension from filename, then MIME fallback."""
if filename:
ext = os.path.splitext(filename)[1].lower()
if ext:
return ext
mime = (mime_type or "").lower()
if not mime:
return ""
for table in (
SUPPORTED_IMAGE_DOCUMENT_TYPES,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
):
for ext, m in table.items():
if m == mime:
return ext
return ""
def cache_media_bytes(
data: bytes,
*,
filename: str = "",
mime_type: str = "",
default_kind: Optional[str] = None,
) -> Optional[CachedMedia]:
"""Classify and cache raw attachment bytes; return a CachedMedia or None.
``default_kind`` ("image"/"video"/"audio"/"document") biases classification
when the extension/MIME are ambiguous e.g. a Telegram native photo whose
file has no usable name. Unsupported document types return None so the
caller can record an "unsupported" note. Images that fail validation
(``cache_image_from_bytes`` raises ValueError) also return None.
"""
from tools.credential_files import to_agent_visible_cache_path
ext = _resolve_media_ext(filename, mime_type)
mime = (mime_type or "").lower()
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
is_image = (
mime.startswith("image/")
or ext in SUPPORTED_IMAGE_DOCUMENT_TYPES
or default_kind == "image"
)
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
is_audio = mime.startswith("audio/") or default_kind == "audio"
if is_image:
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
try:
path = cache_image_from_bytes(data, ext=img_ext)
except ValueError:
return None
out_mime = mime if mime.startswith("image/") else SUPPORTED_IMAGE_DOCUMENT_TYPES.get(img_ext, "image/jpeg")
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "image", display)
if is_video:
vid_ext = ext if ext in SUPPORTED_VIDEO_TYPES else ".mp4"
path = cache_video_from_bytes(data, ext=vid_ext)
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
if is_audio:
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
path = cache_audio_from_bytes(data, ext=aud_ext)
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
if ext not in SUPPORTED_DOCUMENT_TYPES:
return None
path = cache_document_from_bytes(data, filename or f"document{ext}")
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}")
class MessageType(Enum):
"""Types of incoming messages."""
TEXT = "text"
@@ -1644,6 +1745,22 @@ def resolve_channel_skills(
return None
def _strip_media_directives(text: str) -> str:
"""Strip internal delivery directives ([[audio_as_voice]], [[as_document]],
MEDIA:<path>) so they never render as visible text.
Backstop only: run ``extract_media`` first. MEDIA cleanup uses the shared
``MEDIA_TAG_CLEANUP_RE`` (only tags whose path has a known deliverable
extension are removed; an unknown-extension tag is intentionally left so the
bare-path detector downstream can still pick it up, per #34517). [[...]] is
exact.
"""
if not text:
return text
text = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
return MEDIA_TAG_CLEANUP_RE.sub("", text)
class BasePlatformAdapter(ABC):
"""
Base class for platform adapters.
@@ -1734,8 +1851,8 @@ class BasePlatformAdapter(ABC):
def enforces_own_access_policy(self) -> bool:
"""Whether this adapter gates inbound access before dispatch.
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
config-driven access surface ``dm_policy`` / ``group_policy`` /
Some adapters (WeCom, Weixin, Yuanbao, QQBot, WhatsApp) implement a
documented config-driven access surface ``dm_policy`` / ``group_policy`` /
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` and
enforce it at intake: a message is dropped inside the adapter and never
reaches the gateway unless it already passed that policy.
@@ -1799,6 +1916,84 @@ class BasePlatformAdapter(ABC):
f"{type(self).__name__} does not implement send_draft"
)
# ── Structured stream-event rendering ────────────────────────────────
#
# These methods let an adapter decide *how* to present each structured
# streaming event (see gateway/stream_events.py). The default
# implementations reproduce the historical behavior exactly: assistant
# text/commentary/segment events delegate to the stream consumer, and
# tool events render the same "emoji tool_name: preview" chrome the
# gateway has always produced. Adapters override these to be more native
# to their platform (e.g. Telegram streaming a MarkdownV2 ```bash``` block
# as a draft; iMessage eating tool chrome it cannot format).
#
# The contract is presentation-only: nothing rendered here is persisted to
# conversation history. History is owned by the agent; what an adapter
# chooses to "eat" must never change the bytes the agent stored.
def render_message_event(self, event: Any, sink: Any) -> None:
"""Render a MessageChunk / MessageStop / Commentary onto the sink.
Default: map onto the stream consumer's existing primitives, preserving
today's behavior 1:1. ``sink`` is a GatewayStreamConsumer.
"""
from gateway.stream_events import MessageChunk, MessageStop, Commentary
if isinstance(event, MessageChunk):
if event.text:
sink.on_delta(event.text)
elif isinstance(event, MessageStop):
# An intermediate stop (text → tool → text) is a segment break;
# the terminal stop is signalled by the gateway via finish(),
# not here, so we only break segments on non-final stops.
if not event.final:
sink.on_segment_break()
elif isinstance(event, Commentary):
if event.text:
sink.on_commentary(event.text)
def format_tool_event(self, event: Any, *, mode: str = "all",
preview_max_len: int = 40) -> Optional[str]:
"""Return the rendered chrome for a ToolCallChunk, or None to eat it.
Reproduces the gateway's historical tool-progress formatting: an emoji
for the tool, the tool name, and a short argument preview (or the full
args dict in ``verbose`` mode). Adapters that cannot render tool chrome
(no message editing, plain-text only) should override to return None so
the event is dropped rather than spamming separate bubbles.
``mode`` is the resolved tool-progress mode ("all" / "new" / "verbose");
``preview_max_len`` mirrors the ``tool_preview_length`` config (0 means
"no cap" in verbose mode).
"""
from gateway.stream_events import ToolCallChunk
if not isinstance(event, ToolCallChunk):
return None
from agent.display import get_tool_emoji
emoji = get_tool_emoji(event.tool_name, default="⚙️")
if mode == "verbose":
if event.args:
import json
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
if preview_max_len > 0 and len(args_str) > preview_max_len:
args_str = args_str[:preview_max_len - 3] + "..."
return f"{emoji} {event.tool_name}({list(event.args.keys())})\n{args_str}"
if event.preview:
return f"{emoji} {event.tool_name}: \"{event.preview}\""
return f"{emoji} {event.tool_name}..."
# "all" / "new": short preview, capped (default 40 to keep gateway
# progress bubbles compact — they persist as permanent messages).
preview = event.preview
if preview:
cap = preview_max_len if preview_max_len > 0 else 40
if len(preview) > cap:
preview = preview[:cap - 3] + "..."
return f"{emoji} {event.tool_name}: \"{preview}\""
return f"{emoji} {event.tool_name}..."
@property
def has_fatal_error(self) -> bool:
return self._fatal_error_message is not None
@@ -3884,21 +4079,20 @@ class BasePlatformAdapter(ABC):
# where Telegram's sendPhoto recompression destroys legibility.
force_document_attachments = "[[as_document]]" in response
# Pre-extract snapshot for the #29346 recovery/invariant below.
_response_pre_extract = response
# Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response)
media_files = self.filter_media_delivery_paths(media_files)
# Extract image URLs and send them as native platform attachments
images, text_content = self.extract_images(response)
# 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()
# 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()
# Strip any remaining internal directives from message body (fixes #1561).
# _strip_media_directives shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag
# with an unknown extension is intentionally left in the body for
# extract_local_files below to pick up rather than silently dropped (#34517).
text_content = _strip_media_directives(text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
@@ -3912,7 +4106,25 @@ class BasePlatformAdapter(ABC):
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))
# A2 (#29346): extraction can reduce a non-empty response to
# empty text with no attachment, and the `if text_content` guard
# below then drops it silently. Recover on every platform (#33842
# was Discord-only); the guard avoids duplicating an attachment.
if not (text_content or images or local_files or media_files):
# Recover from the post-extract_media `response`, not the raw
# snapshot: extract_media already stripped MEDIA (incl. spaced
# paths) with its full grammar, so no fragment can leak.
_recovered = _strip_media_directives(response).strip()
if _recovered:
logger.warning(
"[%s] response_delivery_recovered: extract pipeline "
"reduced a non-empty response (%d chars) to empty with "
"no attachment; delivering recovered original to %s",
self.name, len(_response_pre_extract), event.source.chat_id,
)
text_content = _recovered
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
@@ -4110,6 +4322,20 @@ class BasePlatformAdapter(ABC):
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
# A3 (#29346): if a non-empty response produced nothing
# deliverable, fail loudly rather than dropping it in silence.
_anything_delivered = (
delivery_attempted or _tts_caption_delivered
or images or local_files or media_files
)
if not _anything_delivered and _response_pre_extract.strip():
logger.error(
"[%s] response_delivery_dropped: non-empty response "
"(%d chars) produced no delivered message or attachment "
"for %s (empty after extract, recovery yielded nothing).",
self.name, len(_response_pre_extract), event.source.chat_id,
)
# Determine overall success for the processing hook
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
await self._run_processing_hook(
+81
View File
@@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
MAX_TEXT_LENGTH = 4000
# BlueBubbles/iMessage does not expose a stable bot mention identity like
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
# group mention gating without custom aliases, use conservative Hermes wake
# words so `require_mention: true` is a one-line enablement path.
DEFAULT_MENTION_PATTERNS = [
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
r"(?<![\w@])@?hermes\b[,:\-]?",
]
# Tapback reaction codes (BlueBubbles associatedMessageType values)
_TAPBACK_ADDED = {
2000: "love", 2001: "like", 2002: "dislike",
@@ -127,6 +136,15 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not str(self.webhook_path).startswith("/"):
self.webhook_path = f"/{self.webhook_path}"
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
_require_mention = extra.get("require_mention")
if _require_mention is None:
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
self._mention_patterns = self._compile_mention_patterns(
extra["mention_patterns"]
if "mention_patterns" in extra
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
)
self.client: Optional[httpx.AsyncClient] = None
self._runner = None
self._private_api_enabled: Optional[bool] = None
@@ -141,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
sep = "&" if "?" in path else "?"
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
@staticmethod
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
"""Compile group-mention wake words from config/env.
``raw`` is a list (from config or env JSON), a string (raw env var:
JSON list, or comma/newline-separated), or None (use Hermes defaults).
"""
if raw is None:
patterns = list(DEFAULT_MENTION_PATTERNS)
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled: List["re.Pattern"] = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
return compiled
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip a leading BlueBubbles wake word before dispatch.
Custom mention patterns are regular expressions, so stripping only a
leading match avoids deleting ordinary words later in the prompt.
"""
if not text:
return text
for pattern in self._mention_patterns:
match = pattern.match(text.lstrip())
if match:
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
return cleaned or text
return text
async def _api_get(self, path: str) -> Dict[str, Any]:
assert self.client is not None
res = await self.client.get(self._api_url(path))
@@ -921,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
session_chat_id = chat_guid or chat_identifier
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
if is_group and self.require_mention:
if not self._message_matches_mention_patterns(text):
logger.debug(
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
)
return web.Response(text="ok")
text = self._clean_mention_text(text)
source = self.build_source(
chat_id=session_chat_id,
chat_name=chat_identifier or sender,
+156 -55
View File
@@ -2521,31 +2521,55 @@ class TelegramAdapter(BasePlatformAdapter):
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": text,
}
thread_id = self._metadata_thread_id(metadata)
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# Most likely: BadRequest because this bot/chat doesn't allow
# drafts, or a transient server hiccup. The caller treats any
# failure as "fall back to edit-based for this response".
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
# so the animated draft preview renders with identical formatting to
# the final message. Without this, the draft streams as raw text and
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
# formatted output, producing a jarring visual shift at the end of the
# response. We try MarkdownV2 first and fall back to plain text if a
# malformed escape would be rejected — mirroring the (True, False)
# retry the streaming send loop uses — so a single bad token never
# kills draft streaming for the whole response.
for use_markdown in (True, False):
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": self.format_message(text) if use_markdown else text,
}
if use_markdown:
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
# is recoverable: retry once as plain text. Any other failure
# (chat doesn't allow drafts, transient hiccup) — or a failure
# on the plain-text attempt — propagates to the caller, which
# treats it as "fall back to edit-based for this response".
if use_markdown and self._is_bad_request_error(e):
logger.debug(
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
"as plain text (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
continue
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
return SendResult(success=False, error="draft_rejected")
async def _send_message_with_thread_fallback(self, **kwargs):
"""Send a Telegram message, retrying once without message_thread_id
@@ -4918,13 +4942,109 @@ class TelegramAdapter(BasePlatformAdapter):
channel_prompt=channel_prompt,
)
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
def _media_message_type(self, msg: Message) -> MessageType:
"""Classify a Telegram media message into a MessageType."""
if msg.sticker:
return MessageType.STICKER
if msg.photo:
return MessageType.PHOTO
if msg.video:
return MessageType.VIDEO
if msg.audio:
return MessageType.AUDIO
if msg.voice:
return MessageType.VOICE
return MessageType.DOCUMENT
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
"""Cache an unmentioned group attachment and annotate the observed text.
Passive group traffic, so downloads are bounded by the same
``_max_doc_bytes`` limit as the addressed document path. Oversized or
unsupported attachments are noted in the transcript without downloading.
"""
from gateway.platforms.base import cache_media_bytes
source, filename, mime, kind = self._observed_media_source(msg)
if source is None:
return
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
file_size = getattr(source, "file_size", None)
try:
size = int(file_size or 0)
except (TypeError, ValueError):
size = 0
if not (0 < size <= max_bytes):
limit_mb = max_bytes // (1024 * 1024)
event.text = self._append_observed_note(
event.text,
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
)
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
return
try:
file_obj = await source.get_file()
data = bytes(await file_obj.download_as_bytearray())
if not filename:
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
except Exception as exc:
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
return
if cached is None:
event.text = self._append_observed_note(
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
)
return
event.media_urls = [cached.path]
event.media_types = [cached.media_type]
if cached.kind == "image":
event.message_type = MessageType.PHOTO
elif cached.kind == "video":
event.message_type = MessageType.VIDEO
event.text = self._append_observed_note(event.text, cached.context_note())
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
def _observed_media_source(self, msg: Message):
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
if msg.photo:
return msg.photo[-1], "", "", "image"
if msg.video:
return msg.video, "", "video/mp4", "video"
if msg.voice:
return msg.voice, "voice.ogg", "audio/ogg", "audio"
if msg.audio:
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
if msg.document:
doc = msg.document
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
return None, "", "", None
@staticmethod
def _append_observed_note(existing: Optional[str], note: str) -> str:
if not note:
return existing or ""
if not existing:
return note
return f"{existing}\n\n{note}"
def _observe_unmentioned_group_message(
self,
message: Message,
msg_type: MessageType,
update_id: Optional[int] = None,
event: Optional[MessageEvent] = None,
) -> None:
"""Append skipped group chatter to the target session without dispatching."""
store = getattr(self, "_session_store", None)
if not store:
return
try:
event = self._build_message_event(message, msg_type, update_id=update_id)
event = event or self._build_message_event(message, msg_type, update_id=update_id)
shared_source = self._telegram_group_observe_shared_source(event.source)
session_entry = store.get_or_create_session(shared_source)
entry = {
@@ -5285,39 +5405,20 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._should_process_message(update.message):
if self._should_observe_unmentioned_group_message(update.message):
_m = update.message
if _m.sticker:
_observe_type = MessageType.STICKER
elif _m.photo:
_observe_type = MessageType.PHOTO
elif _m.video:
_observe_type = MessageType.VIDEO
elif _m.audio:
_observe_type = MessageType.AUDIO
elif _m.voice:
_observe_type = MessageType.VOICE
else:
_observe_type = MessageType.DOCUMENT
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
_observe_type = self._media_message_type(_m)
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
if _m.caption:
_event.text = self._clean_bot_trigger_text(_m.caption)
await self._cache_observed_media(_m, _event)
self._observe_unmentioned_group_message(
_m, _event.message_type, update_id=update.update_id, event=_event
)
return
msg = update.message
# Determine media type
if msg.sticker:
msg_type = MessageType.STICKER
elif msg.photo:
msg_type = MessageType.PHOTO
elif msg.video:
msg_type = MessageType.VIDEO
elif msg.audio:
msg_type = MessageType.AUDIO
elif msg.voice:
msg_type = MessageType.VOICE
elif msg.document:
msg_type = MessageType.DOCUMENT
else:
msg_type = MessageType.DOCUMENT
msg_type = self._media_message_type(msg)
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
# Add caption as text
+9
View File
@@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": f"Unknown route: {route_name}"}, status=404
)
# Disabled routes are kept in the subscriptions file (so the dashboard
# can re-enable them) but reject incoming events. Default-enabled:
# only an explicit ``enabled: false`` turns a route off, matching the
# mcp_servers ``enabled`` semantics.
if route_config.get("enabled", True) is False:
return web.json_response(
{"error": f"Route disabled: {route_name}"}, status=403
)
# ── Auth-before-body ─────────────────────────────────────
# Check Content-Length before reading the full payload.
content_length = request.content_length or 0
+9 -1
View File
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
).strip() or DEFAULT_WS_URL
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
# (dm_policy=allowlist via env, no config extra) runs with an empty
# allowlist and drops every authorized DM at intake.
self._allow_from = _coerce_list(
extra.get("allow_from")
or extra.get("allowFrom")
or os.getenv("WECOM_ALLOWED_USERS", "")
)
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
+20 -12
View File
@@ -378,12 +378,16 @@ async def _api_post(
) -> Dict[str, Any]:
body = _json_dumps({**payload, "base_info": _base_info()})
url = f"{base_url.rstrip('/')}/{endpoint}"
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.post(url, data=body, headers=_headers(token, body)) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _api_get(
@@ -398,12 +402,16 @@ async def _api_get(
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
}
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.get(url, headers=headers, timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.get(url, headers=headers) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _get_updates(
+5
View File
@@ -379,6 +379,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
return True
return False
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Check whether a DM from the given sender should be processed."""
if self._dm_policy == "disabled":
+26 -2
View File
@@ -5121,6 +5121,30 @@ class GatewayRunner:
cross boards, so delivery semantics are unchanged this is
purely a fan-out of the single-DB poll.
"""
# Gate: only the dispatch-owning gateway opens kanban DBs for notifier polling.
# Non-dispatch gateways have no subscriptions to deliver — all kanban state lives
# in the dispatch owner's per-board DBs. This prevents N-gateway -shm contention.
# TODO: gate per-board when per-board dispatcher_owner tracking lands.
try:
from hermes_cli.config import load_config as _load_config
except Exception:
logger.warning("kanban notifier: config loader unavailable; disabled")
return
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
if env_override in {"0", "false", "no", "off"}:
logger.info("kanban notifier: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
return
try:
cfg = _load_config()
except Exception as exc:
logger.warning("kanban notifier: cannot load config (%s); disabled", exc)
return
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
if not kanban_cfg.get("dispatch_in_gateway", True):
logger.info(
"kanban notifier: disabled via config kanban.dispatch_in_gateway=false"
)
return
from gateway.config import Platform as _Platform
try:
from hermes_cli import kanban_db as _kb
@@ -6820,8 +6844,8 @@ class GatewayRunner:
"""Whether the adapter for *platform* gates access at intake itself.
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
+23 -4
View File
@@ -261,6 +261,12 @@ class GatewayStreamConsumer:
self._last_sent_text = ""
self._fallback_final_send = False
self._fallback_prefix = ""
# #29346: a tool/segment boundary means what we delivered was an interim
# preamble, not the final answer — clear the flags so a premature setter
# can't fool the gateway. Safe: got_done returns before any reset, and
# run.py reads these only after the consumer task exits.
self._final_response_sent = False
self._final_content_delivered = False
# Native draft streaming: bump the draft_id so the next text segment
# animates as a fresh preview below the tool-progress bubbles, not
# over the prior segment's already-finalized draft. This is how
@@ -549,6 +555,9 @@ class GatewayStreamConsumer:
current_update_visible = await self._send_or_edit(
display_text,
finalize=(got_done or got_segment_break),
# A segment-break finalize closes a preamble, not the
# turn-final answer — only got_done marks delivered (#29346).
is_turn_final=got_done,
)
self._last_edit_time = time.monotonic()
@@ -1058,12 +1067,17 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
async def _try_fresh_final(self, text: str) -> bool:
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
time. Returns True on successful delivery, False on any failure so
the caller falls back to the normal edit path.
``is_turn_final`` is False when finalizing an interim segment at a tool
boundary (a preamble) rather than the turn-final answer; the
final-delivery flag is then left unset so the gateway still delivers the
real answer from the next API call (#29346).
Ported from openclaw/openclaw#72038.
"""
old_message_id = self._message_id
@@ -1108,10 +1122,13 @@ class GatewayStreamConsumer:
self._message_created_ts = None
self._already_sent = True
self._last_sent_text = text
self._final_response_sent = True
if is_turn_final:
self._final_response_sent = True
return True
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
@@ -1205,7 +1222,9 @@ class GatewayStreamConsumer:
if (
finalize
and self._should_send_fresh_final()
and await self._try_fresh_final(text)
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
):
return True
# Edit existing message
+132
View File
@@ -0,0 +1,132 @@
"""Adapter-driven dispatch of structured stream events to a delivery sink.
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
events (gateway/stream_events.py), and the *adapter* decides how each one is
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
resolved per-channel presentation settings (tool-progress mode, preview length)
and routes each event through the adapter's render hooks.
Message/commentary/segment events flow into the consumer (native draft on
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
adapter which may return None to *eat* the event on platforms that can't
render tool chrome and the rendered line is enqueued onto the same tool
progress queue the gateway already drains, so the two no longer race through
independent code paths.
This module deliberately has no platform knowledge and no asyncio: it is a thin
synchronous router callable from the agent's worker thread, exactly like the
callbacks it replaces.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
StreamEvent,
ToolCallChunk,
ToolCallFinished,
)
logger = logging.getLogger("gateway.stream_events")
class GatewayEventDispatcher:
"""Route typed stream events through an adapter onto a delivery sink.
Parameters
----------
adapter:
The platform adapter. Provides ``render_message_event`` and
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
behavior; adapters may override for native rendering).
sink:
The GatewayStreamConsumer for assistant-text delivery. May be None
when streaming is disabled, in which case message events are dropped
(the final response still goes out via the normal send path).
enqueue_tool_line:
Callback that places a rendered tool-progress line onto the gateway's
progress queue (the same queue ``send_progress_messages`` drains). May
be None when tool progress is disabled for this channel.
tool_mode:
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
/ "off").
preview_max_len:
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
on_long_tool / on_notice:
Optional hooks for LongToolHint / GatewayNotice events, letting the
gateway own the "should I surface this here?" decision.
"""
def __init__(
self,
adapter: Any,
sink: Any = None,
*,
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
tool_mode: str = "all",
preview_max_len: int = 40,
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
) -> None:
self.adapter = adapter
self.sink = sink
self._enqueue_tool_line = enqueue_tool_line
self.tool_mode = tool_mode or "all"
self.preview_max_len = preview_max_len
self._on_long_tool = on_long_tool
self._on_notice = on_notice
# "new" mode dedup — only report when the tool changes.
self._last_tool: Optional[str] = None
def dispatch(self, event: StreamEvent) -> None:
"""Route a single event. Never raises into the agent's worker thread."""
try:
self._dispatch(event)
except Exception: # presentation must never break the agent loop
logger.debug("stream-event dispatch error", exc_info=True)
def _dispatch(self, event: StreamEvent) -> None:
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
if self.sink is not None:
self.adapter.render_message_event(event, self.sink)
return
if isinstance(event, ToolCallChunk):
if self.tool_mode == "off" or self._enqueue_tool_line is None:
return
# "new" mode: only emit when the tool changes.
if self.tool_mode == "new" and event.tool_name == self._last_tool:
return
self._last_tool = event.tool_name
line = self.adapter.format_tool_event(
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
)
# None == adapter chose to eat this event (can't render tool chrome).
if line:
self._enqueue_tool_line(line)
return
if isinstance(event, ToolCallFinished):
# Default: no chrome on completion (matches today — the gateway only
# rendered "started" events). Completion drives onboarding hints.
return
if isinstance(event, LongToolHint):
if self._on_long_tool is not None:
self._on_long_tool(event)
return
if isinstance(event, GatewayNotice):
if self._on_notice is not None:
self._on_notice(event)
return
__all__ = ["GatewayEventDispatcher"]
+171
View File
@@ -0,0 +1,171 @@
"""Structured streaming events — the agent→gateway delivery contract.
Historically the agent drove gateway delivery through a fan of loosely-typed
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
tool_name, preview, args)``, ``interim_assistant_callback(text)`` ) and each
gateway callback decided *both* what to render and how to send it. That
coupling is why tool-progress bubbles and the streaming draft raced each other
on Telegram, and why tool-call formatting lived agent-side even though only the
gateway knows what a given platform can render.
This module defines a small, typed event vocabulary that names *what happened*
without prescribing *how it is delivered*. The gateway's stream consumer
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
native draft; iMessage has no rich formatting and may collapse or drop tool
chrome). Separation of concerns: smart agent emits structured data, smart
gateway decides delivery.
These are intentionally plain frozen dataclasses no behavior, no platform
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
safe to hand across the thread/async boundary into the consumer queue.
Design constraints (see hermes-agent-dev skill message-flow + cache
invariants):
* Events describe *transport*, never *context*. Nothing here is persisted to
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
a platform that can't render it) must never diverge from the bytes stored in
the agent's message history. History is owned by the agent; these events are
a presentation-layer stream only.
* Backward compatible by construction. The gateway adapts its existing
callbacks into these events at the boundary; adapters that don't opt into
event-native rendering get identical behavior via the base-class default.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
# ── Message (assistant text) events ──────────────────────────────────────────
@dataclass(frozen=True)
class MessageChunk:
"""A delta of streamed assistant text.
``text`` is the incremental content as it arrives from the model. The
consumer accumulates chunks and progressively renders them (native draft on
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
filtered upstream and never arrives as a MessageChunk.
"""
text: str
@dataclass(frozen=True)
class MessageStop:
"""The current assistant message segment is complete.
Emitted when a contiguous run of assistant text ends either the whole
response finished, or a tool boundary interrupts the text so the next
segment should render as a fresh message *below* any tool chrome.
``final`` is True only for the terminal stop of the whole turn; an
intermediate stop (text tool call more text) carries ``final=False`` so
the consumer finalizes the current bubble and prepares a new segment without
treating the turn as done.
"""
final: bool = False
@dataclass(frozen=True)
class Commentary:
"""A complete interim assistant message emitted between tool iterations.
Example: the model says "I'll inspect the repo first." before issuing a tool
call. Unlike a MessageChunk this is already-complete text (not a delta); the
consumer renders it as its own message so it reads as a distinct beat.
"""
text: str
# ── Tool-call events ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ToolCallChunk:
"""A tool invocation has started (or its in-progress state changed).
Carries the raw facts about the call name, a short argument ``preview``,
and the full ``args`` dict and lets the *gateway* decide presentation
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
don't show tool chrome). Previously the agent's gateway callback baked the
emoji + preview formatting in; that decision now belongs to the adapter.
"""
tool_name: str
preview: Optional[str] = None
args: Optional[Dict[str, Any]] = None
# Monotonic per-turn index, so the consumer can correlate a finish with its
# start and so "new"-mode dedup (only report when the tool changes) works
# without the consumer tracking call order itself.
index: int = 0
@dataclass(frozen=True)
class ToolCallFinished:
"""A tool invocation completed.
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
returned without raising. The gateway uses this to clear/settle a progress
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
long tool run). No tool *output* travels here output is the agent's
concern and is persisted to history, not streamed as presentation.
"""
tool_name: str
duration: float = 0.0
ok: bool = True
index: int = 0
# ── Gateway control / lifecycle events ───────────────────────────────────────
@dataclass(frozen=True)
class LongToolHint:
"""One-shot onboarding nudge when a tool runs longer than the threshold.
The gateway gates this on platform capability (the /verbose command must be
usable) and on the user not having seen the hint before. Modeled as an
event so the *gateway* owns the "should I surface this here?" decision rather
than the agent.
"""
tool_name: str = ""
duration: float = 0.0
@dataclass(frozen=True)
class GatewayNotice:
"""A gateway-originated control message (restart, online, long-run notice).
``kind`` is a stable string the adapter can switch on
(``"restart"`` / ``"online"`` / ``"long_run"`` / ). ``text`` is the
human-readable default the base class renders when an adapter has no
platform-specific treatment.
"""
kind: str
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
# than a marker base class) so a missing ``case`` in an exhaustive match is a
# visible type error rather than a silent fall-through.
StreamEvent = Union[
MessageChunk,
MessageStop,
Commentary,
ToolCallChunk,
ToolCallFinished,
LongToolHint,
GatewayNotice,
]
__all__ = [
"MessageChunk",
"MessageStop",
"Commentary",
"ToolCallChunk",
"ToolCallFinished",
"LongToolHint",
"GatewayNotice",
"StreamEvent",
]
+1
View File
@@ -6165,6 +6165,7 @@ def _prompt_model_selection(
selected=default_idx,
cancel_returns=-1,
description=description,
searchable=True,
)
if idx < 0:
return None
+1 -1
View File
@@ -177,7 +177,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
"conflicts (Telegram, Discord, and Slack only allow one active "
"session per token)."
)
print_info("Recommendation: stop the gateway first with 'hermes stop'.")
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
print()
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
print_info("Migration cancelled. Stop the gateway and try again.")
+1 -1
View File
@@ -124,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
args_hint="[model] [--provider name] [--global] [--refresh]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
+7 -3
View File
@@ -105,7 +105,9 @@ _hermes_profiles() {{
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
for f in "$profiles_dir"/*/; do
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
done
fi
echo "$profiles"
}}
@@ -206,7 +208,7 @@ _hermes_profiles() {{
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
profiles+=($HOME/.hermes/profiles/*(N/:t))
fi
_describe 'profile' profiles
}}
@@ -260,7 +262,9 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
"function __hermes_profiles",
" echo default",
" if test -d $HOME/.hermes/profiles",
" ls $HOME/.hermes/profiles 2>/dev/null",
" for d in $HOME/.hermes/profiles/*/",
" basename $d",
" end",
" end",
"end",
"",
+61 -2
View File
@@ -1345,7 +1345,27 @@ DEFAULT_CONFIG = {
# responses and content messages are never touched. Default 0
# (disabled) preserves prior behavior.
"ephemeral_system_ttl": 0,
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
# Per-platform display/streaming overrides. Each key is a gateway
# platform ("telegram", "discord", "slack", …) mapping to a dict of
# display settings that override the global value for that platform
# only. A setting left unset here falls through to the global default.
#
# Shipped defaults encode the streaming experience that works best
# per platform:
# - Telegram has native animated draft streaming (sendMessageDraft),
# which is smooth, so streaming is on by default there.
# - Discord/Slack/etc. only have edit-based streaming (repeated
# editMessage), which flickers and is noticeably jankier, so
# streaming is off by default there.
# These are gap-fillers: a user who explicitly sets, e.g.,
# display.platforms.discord.streaming: true keeps their value
# (config deep-merge has user values win over defaults). The global
# streaming.enabled master switch still gates everything — these
# per-platform flags only take effect once streaming is enabled.
"platforms": {
"telegram": {"streaming": True},
"discord": {"streaming": False},
},
# Gateway runtime-metadata footer appended to the FINAL message of a turn
# (disabled by default to keep replies minimal). When enabled, renders
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
@@ -2037,6 +2057,45 @@ DEFAULT_CONFIG = {
"trust_recent_files_seconds": 600,
},
# Real-time token streaming to messaging platforms (Telegram, Discord,
# Slack, etc.). Read at the top level by the gateway; absent this block the
# gateway falls back to these same defaults, so adding it here only makes
# the feature discoverable in config.yaml — it does not change behavior.
#
# Disabled by default: streaming costs extra edit/draft API calls per
# response. Set ``enabled: true`` and restart the gateway to turn it on.
"streaming": {
# Master switch. When false, each response is delivered as a single
# final message (no progressive updates).
"enabled": False,
# Transport selection:
# "auto" — prefer native draft streaming where the platform
# supports it (Telegram DMs via sendMessageDraft,
# Bot API 9.5+) and fall back to edit-based elsewhere.
# Safe global default: platforms without draft support
# (Discord, Slack, Matrix, Telegram groups) transparently
# use the edit path, so "auto" only upgrades chats that
# can render the smoother native preview.
# "draft" — explicitly request native drafts; falls back to edit
# when the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy behavior).
# "off" — disable streaming entirely (same as enabled: false).
"transport": "auto",
# Minimum seconds between progressive edits — tuned for Telegram's
# ~1 edit/s flood envelope.
"edit_interval": 0.8,
# Flush the buffer to the platform once this many characters have
# accumulated, so short replies feel near-instant.
"buffer_threshold": 24,
# Cursor glyph appended to the in-progress message while streaming.
"cursor": " \u2589",
# When >0, the final edit for a long-running streamed response is
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 60.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
# state.db accumulates every session, message, tool call, and FTS5 index
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
@@ -3756,7 +3815,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions",
"sessions", "streaming",
}
# Valid fields inside a custom_providers list entry
-79
View File
@@ -473,66 +473,6 @@ def _cmd_list_archived(args) -> int:
return 0
def _cmd_usage(args) -> int:
"""Show usage telemetry for ALL skills, with provenance.
Unlike `status` (curator-scoped to agent-created candidates), this lists
every skill on disk bundled built-ins and hub-installed included so you
can see how often each is actually used regardless of curation.
"""
import json as _json
from tools import skill_usage
rows = skill_usage.usage_report()
prov_filter = getattr(args, "provenance", None)
if prov_filter:
rows = [r for r in rows if r.get("provenance") == prov_filter]
sort_key = getattr(args, "sort", "activity")
if sort_key == "name":
rows.sort(key=lambda r: r["name"])
elif sort_key == "recent":
# Most-recently-active first; never-active sinks to the bottom.
rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True)
else: # "activity" (default): most-used first
rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True)
if getattr(args, "json", False):
print(_json.dumps(rows, indent=2, ensure_ascii=False))
return 0
if not rows:
print("curator: no skills found")
return 0
# Provenance tallies for a quick header.
counts = {"agent": 0, "bundled": 0, "hub": 0}
for r in rows:
counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1
print(
f"skills: {len(rows)} total "
f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})"
)
print()
print(
f" {'skill':40s} {'origin':8s} "
f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity"
)
for r in rows:
last = _fmt_ts(r.get("last_activity_at"))
print(
f" {r['name'][:40]:40s} "
f"{r.get('provenance', 'agent'):8s} "
f"{r.get('use_count', 0):>4d} "
f"{r.get('view_count', 0):>4d} "
f"{r.get('patch_count', 0):>5d} "
f"{r.get('activity_count', 0):>4d} "
f"{last}"
)
return 0
# ---------------------------------------------------------------------------
# argparse wiring (called from hermes_cli.main)
# ---------------------------------------------------------------------------
@@ -549,25 +489,6 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
p_status = subs.add_parser("status", help="Show curator status and skill stats")
p_status.set_defaults(func=_cmd_status)
p_usage = subs.add_parser(
"usage",
help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
)
p_usage.add_argument(
"--sort", choices=("activity", "recent", "name"), default="activity",
help="Sort order: activity (most-used first, default), recent "
"(most-recently-active first), or name (alphabetical)",
)
p_usage.add_argument(
"--provenance", choices=("agent", "bundled", "hub"), default=None,
help="Only show skills of this origin",
)
p_usage.add_argument(
"--json", action="store_true",
help="Emit the full report as JSON instead of a table",
)
p_usage.set_defaults(func=_cmd_usage)
p_run = subs.add_parser("run", help="Trigger a curator review now")
p_run.add_argument(
"--sync", "--synchronous", dest="synchronous", action="store_true",
+349 -27
View File
@@ -5,11 +5,242 @@ Provides a curses multi-select with keyboard navigation, plus a
text-based numbered fallback for terminals without curses support.
"""
import sys
from dataclasses import dataclass
from typing import Callable, List, Optional, Set
from hermes_cli.colors import Colors, color
def _query_matches(label: str, query: str) -> bool:
"""Return True when every query token is a case-insensitive subsequence."""
normalized = label.lower()
tokens = query.lower().split()
if not tokens:
return True
for token in tokens:
pos = 0
for ch in token:
pos = normalized.find(ch, pos)
if pos < 0:
return False
pos += 1
return True
_WORD_BOUNDARY = frozenset("-_/. ")
def _is_boundary(target: str, index: int) -> bool:
"""True if position ``index`` in ``target`` starts a word.
Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a
separator char, or a lower->upper camelCase transition.
"""
if index == 0:
return True
prev = target[index - 1]
if prev in _WORD_BOUNDARY:
return True
# camelCase / lower->upper transition (e.g. the `O` in `gptO`).
cur = target[index]
return prev == prev.lower() and cur != cur.lower() and cur == cur.upper()
def _token_score(orig: str, lower: str, token: str) -> float | None:
"""Score one token against a target. None if the token isn't a subsequence.
A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and
web/src/lib/fuzzy.ts so all three surfaces rank model ids identically:
contiguous runs, word-boundary / first-char starts, prefix matches, and
exact matches all score higher than scattered subsequence hits.
``lower`` is ``orig`` lowercased; matching is done against ``lower`` while
boundary detection uses ``orig`` (so the camelCase rule works), exactly as
in the TS scorer.
"""
score = 0.0
prev = -1
search_from = 0
positions: list[int] = []
for ch in token:
idx = lower.find(ch, search_from)
if idx < 0:
return None
positions.append(idx)
score += 1
if prev >= 0 and idx == prev + 1:
score += 5
elif prev >= 0:
score -= min(idx - prev - 1, 3)
if _is_boundary(orig, idx):
score += 3
if idx == 0:
score += 5
prev = idx
search_from = idx + 1
# Prefix bonus: the token matched a contiguous prefix of the target.
if positions and positions[0] == 0 and positions[-1] == len(positions) - 1:
score += 8
# Exact full match dominates everything else.
if lower == token:
score += 20
# Slightly prefer shorter targets when scores are otherwise close.
score -= len(lower) * 0.01
return score
def _fuzzy_score(label: str, query: str) -> float | None:
"""Aggregate score for a multi-token query (AND). None if any token fails.
Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated
token must match; per-token scores are summed.
"""
lower = label.lower()
tokens = query.lower().split()
if not tokens:
return 0.0
total = 0.0
for token in tokens:
token_score = _token_score(label, lower, token)
if token_score is None:
return None
total += token_score
return total
def _filter_indices(items: List[str], query: str) -> List[int]:
"""Return item indices matching *query*, ranked best-first.
An empty query keeps every item in original order. Otherwise items are
filtered to fuzzy matches and sorted by score descending, ties broken by
original index so equal-scoring rows keep their catalog order.
"""
q = query.strip()
if not q:
return list(range(len(items)))
scored = []
for i, label in enumerate(items):
score = _fuzzy_score(label, q)
if score is not None:
scored.append((i, score))
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return [i for i, _ in scored]
@dataclass
class _SearchState:
"""Mutable search state shared by curses picker loops."""
active: bool = False
query: str = ""
def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]:
"""Return ``(cursor, cursor_pos)`` inside the filtered index list."""
if not filtered:
return cursor, 0
if cursor not in filtered:
cursor = filtered[0]
return cursor, filtered.index(cursor)
def _move_filtered_cursor(
filtered: List[int], cursor: int, cursor_pos: int, delta: int
) -> int:
"""Move through the filtered index list, wrapping like the legacy menus."""
if not filtered:
return cursor
return filtered[(cursor_pos + delta) % len(filtered)]
def _scroll_for_cursor(
scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int
) -> int:
"""Clamp scroll offset so the cursor remains visible."""
visible_rows = max(1, visible_rows)
if cursor_pos < scroll_offset:
scroll_offset = cursor_pos
elif cursor_pos >= scroll_offset + visible_rows:
scroll_offset = cursor_pos - visible_rows + 1
return max(0, min(scroll_offset, max(0, total_rows - visible_rows)))
def _handle_active_search_key(
curses_mod, key: int, search: _SearchState
) -> tuple[bool, bool, bool]:
"""Handle a key while the search prompt is active.
Returns ``(handled, confirm, changed)``. Active search consumes query
editing keys, but leaves navigation keys for the menu loop to handle.
"""
if not search.active:
return False, False, False
if key == 27:
# Esc stops search AND clears the query, restoring the full list (so a
# no-match filter can't strand the user on an empty list). Signals
# `changed` when there was a query so the driver resets scroll/cursor.
had_query = bool(search.query)
search.active = False
search.query = ""
return True, False, had_query
if key in (curses_mod.KEY_BACKSPACE, 127, 8):
search.query = search.query[:-1]
return True, False, True
if key == 21: # Ctrl+U
search.query = ""
return True, False, True
if key in (curses_mod.KEY_ENTER, 10, 13):
return True, True, False
if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255
search.query += chr(key)
return True, False, True
return False, False, False
def flush_stdin() -> None:
"""Flush any stray bytes from the stdin input buffer.
@@ -58,9 +289,16 @@ def read_menu_key(stdscr) -> str:
the escape path; ``q`` also cancels. Unknown sequences map to
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
"""
import curses
return _decode_menu_key(stdscr, stdscr.getch())
key = stdscr.getch()
def _decode_menu_key(stdscr, key: int) -> str:
"""Normalize an already-read keypress to a menu action.
Split out from ``read_menu_key`` so search-aware loops can peek the raw
key (e.g. to catch ``/``) before falling back to nav decoding.
"""
import curses
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
@@ -121,6 +359,8 @@ def _run_curses_menu(
extra_color_pairs=False,
fallback,
cancel_value,
searchable=False,
search_labels=None,
):
"""Shared curses single-/multi-select event loop.
@@ -135,9 +375,12 @@ def _run_curses_menu(
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.
index where the scrollable item list should start. When search is
active it receives the live ``_SearchState`` via the optional
``search`` keyword (drawn by the menu so the hint line can show it).
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
Draw one item row.
Draw one item row. ``idx`` is always the ORIGINAL item index, so
per-menu rendering is unchanged whether or not a filter is active.
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.
@@ -151,6 +394,10 @@ def _run_curses_menu(
fallback() -> value
Called when curses errors out on a real TTY (curses unavailable).
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
searchable: when true, ``/`` opens a type-to-filter prompt over
``search_labels``. Returned values are always ORIGINAL item indices.
search_labels: per-item text used for filtering (required when
``searchable`` is true; length must equal ``item_count``).
"""
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
# so return the cancel value directly — matching the pre-refactor guard in
@@ -158,6 +405,8 @@ def _run_curses_menu(
if not sys.stdin.isatty():
return cancel_value
use_search = searchable and search_labels is not None and len(search_labels) == item_count
try:
import curses
result_holder = [_KEEP]
@@ -175,22 +424,46 @@ def _run_curses_menu(
)
cursor = initial_cursor
scroll_offset = 0
search = _SearchState()
# Non-None labels for filtering; empty when search is disabled so
# _filter_indices stays a cheap identity range.
labels: List[str] = (
search_labels if (use_search and search_labels is not None) else []
)
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
items_start = draw_header(stdscr, max_y, max_x)
filtered = (
_filter_indices(labels, search.query)
if use_search
else list(range(item_count))
)
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
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
# draw_header accepts an optional `search` kwarg when the menu
# wants to render the live filter; tolerate headers that don't.
try:
items_start = draw_header(stdscr, max_y, max_x, search=search)
except TypeError:
items_start = draw_header(stdscr, max_y, max_x)
for draw_i, i in enumerate(
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
visible_rows = max(1, max_y - items_start - reserve_bottom)
scroll_offset = _scroll_for_cursor(
scroll_offset, cursor_pos, visible_rows, len(filtered)
)
if use_search and search.query and not filtered:
try:
stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM)
except curses.error:
pass
for draw_i, filtered_pos in enumerate(
range(scroll_offset, min(len(filtered), scroll_offset + visible_rows))
):
i = filtered[filtered_pos]
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
@@ -200,13 +473,46 @@ def _run_curses_menu(
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
action = read_menu_key(stdscr)
if use_search:
key = stdscr.getch()
if search.active:
# Active search consumes query-editing keys; nav keys
# fall through to be decoded below.
handled, confirm, changed = _handle_active_search_key(
curses, key, search
)
if changed:
scroll_offset = 0
cursor, cursor_pos = _reconcile_cursor(
_filter_indices(search_labels, search.query), cursor
)
if confirm:
if filtered:
outcome = on_action(NAV_SELECT, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
return
continue
if handled:
continue
action = _decode_menu_key(stdscr, key)
elif key == ord("/"):
search.active = True
continue
else:
action = _decode_menu_key(stdscr, key)
else:
action = read_menu_key(stdscr)
if action == NAV_UP:
cursor = (cursor - 1) % item_count
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
elif action == NAV_DOWN:
cursor = (cursor + 1) % item_count
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
if action == NAV_SELECT and use_search and not filtered:
continue
outcome = on_action(action, cursor)
if outcome is not _KEEP:
result_holder[0] = outcome
@@ -320,6 +626,7 @@ def curses_radiolist(
*,
cancel_returns: int | None = None,
description: str | None = None,
searchable: bool = False,
) -> int:
"""Curses single-select radio list. Returns the selected index.
@@ -331,6 +638,9 @@ def curses_radiolist(
description: Optional multi-line text shown between the title and
the item list. Useful for context that should survive the
curses screen clear.
searchable: When true, ``/`` opens a type-to-filter prompt. The
returned value is always the original item index, not a filtered
row position.
"""
if cancel_returns is None:
cancel_returns = selected
@@ -339,7 +649,7 @@ def curses_radiolist(
if description:
desc_lines = description.splitlines()
def _draw_header(stdscr, max_y, max_x):
def _draw_header(stdscr, max_y, max_x, search=None):
import curses
row = 0
try:
@@ -356,11 +666,13 @@ def curses_radiolist(
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
if searchable and search is not None and search.active:
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
elif searchable:
hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel"
else:
hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel"
stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM)
row += 1
except curses.error:
pass
@@ -396,6 +708,8 @@ def curses_radiolist(
reserve_bottom=1,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
searchable=searchable,
search_labels=list(items) if searchable else None,
)
@@ -431,27 +745,33 @@ def curses_single_select(
default_index: int = 0,
*,
cancel_label: str = "Cancel",
searchable: bool = False,
) -> int | None:
"""Curses single-select menu. Returns selected index or None on cancel.
Works inside prompt_toolkit because curses.wrapper() restores the terminal
safely, unlike simple_term_menu which conflicts with /dev/tty.
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the
returned value is always the original item index (or None for cancel).
"""
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
def _draw_header(stdscr, max_y, max_x):
def _draw_header(stdscr, max_y, max_x, search=None):
import curses
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,
)
if searchable and search is not None and search.active:
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
elif searchable:
hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel"
else:
hint = " ↑↓ navigate ENTER confirm ESC/q cancel"
stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM)
except curses.error:
pass
return 3
@@ -488,6 +808,8 @@ def curses_single_select(
reserve_bottom=1,
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
cancel_value=None,
searchable=searchable,
search_labels=list(all_items) if searchable else None,
)
+132 -24
View File
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from hermes_cli.dashboard_auth import list_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
from hermes_cli.dashboard_auth.cookies import read_session_cookies
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
@@ -185,44 +185,94 @@ async def gated_auth_middleware(
return await call_next(request)
at, _rt = read_session_cookies(request)
if not at:
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh; force login.
return _unauth_response(request, reason="no_cookie")
# Try every registered provider's verify_session in turn. Providers
# MUST return None for tokens they don't recognise (not raise). This
# lets multiple providers stack — the first one that recognises a
# token wins.
#
# When the access-token cookie is absent but a refresh-token cookie is
# present, skip verification and go straight to the refresh path below.
# This is the COMMON expiry case, not an edge case: the access-token
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
# the browser EVICTS it the moment the token lapses, while the
# refresh-token cookie lives for 30 days. From that point the browser
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
# bounce the user to /login on every expiry despite holding a perfectly
# good refresh token — defeating the whole transparent-refresh feature.
session = None
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if at:
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if session is None:
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; on RefreshExpiredError (RT dead /
# revoked / reuse-detected) we fall through to clear-and-relogin.
refreshed = _attempt_refresh(request, refresh_token=_rt)
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
response = await call_next(request)
# Persist the ROTATED tokens. Portal rotates the refresh token on
# every refresh and runs reuse-detection, so writing the new RT
# back is mandatory: a stale RT cookie would replay a rotated
# token on the next refresh and (outside Portal's grace) revoke
# the whole session. Bind cookie Secure/Path to the request shape.
from hermes_cli.dashboard_auth.cookies import (
detect_https,
set_session_cookies,
)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_cookies(
response,
access_token=new_session.access_token,
refresh_token=new_session.refresh_token,
access_token_expires_in=_expires_in_seconds(new_session),
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
audit_log(
AuditEvent.REFRESH_SUCCESS,
provider=refreshing_provider,
user_id=new_session.user_id,
ip=_client_ip(request),
)
return response
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
reason="no_provider_recognises",
ip=_client_ip(request),
)
response = _unauth_response(request, reason="invalid_or_expired_session")
# Clear the dead cookie so the browser doesn't keep sending it.
# Contract v1: no refresh token to retry with, so the only correct
# Clear the dead cookies so the browser doesn't keep sending them.
# Refresh already failed (or there was no RT), so the only correct
# next step is full re-auth via /login. Importing locally avoids a
# cycle with cookies → middleware at module load. Pass the active
# prefix so the deletion's Path matches the set-Path (otherwise
@@ -234,3 +284,61 @@ async def gated_auth_middleware(
request.state.session = session
return await call_next(request)
def _expires_in_seconds(session) -> int:
"""Seconds until the access token's ``exp``, floored at 60.
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
cookie's Max-Age tracks the token lifetime even on a slightly skewed
clock. ``time`` imported locally to keep the module's import surface
minimal.
"""
import time
return max(60, int(session.expires_at) - int(time.time()))
def _attempt_refresh(request: Request, *, refresh_token):
"""Try to rotate an expired session via the refresh token.
Returns ``(new_session, provider_name)`` on success, or ``None`` if
there's no RT or every provider's ``refresh_session`` failed with
``RefreshExpiredError`` (dead/revoked/reuse-detected RT force re-login).
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
here re-raising would 500 the request; instead we log and return None so
the caller forces a clean re-login, which is the safer UX than a hard
error on a transient network blip during the narrow refresh window.
"""
if not refresh_token:
return None
for provider in list_providers():
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
# This provider owns the RT but it's dead — stop trying others
# (an RT belongs to exactly one provider) and force re-login.
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
return None
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during refresh: %s",
provider.name, e,
)
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return None
if new_session is not None:
return new_session, provider.name
return None
+54 -12
View File
@@ -453,11 +453,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
if pid == my_pid or pid in exclude_pids:
continue
try:
cmdline = (
open(f"/proc/{pid}/cmdline", "rb")
.read()
.decode("utf-8", errors="replace")
)
with open(f"/proc/{pid}/cmdline", "rb") as _f:
cmdline = _f.read().decode("utf-8", errors="replace")
cmdline = cmdline.replace("\x00", " ")
cmdline_lc = cmdline.lower()
if any(p in cmdline_lc for p in patterns) and (
@@ -5877,15 +5874,60 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool:
file=sys.stderr,
flush=True,
)
# Block until the container is signalled. The supervised gateway's
# lifetime is independent of this process — s6-supervise restarts
# it on crash, and we don't want the container to exit when the
# gateway flaps. `sleep infinity` matches the static main-hermes
# service's pattern (see docker/s6-rc.d/main-hermes/run): the CMD
# process is a no-op heartbeat that keeps /init alive until
# Keep the CMD process alive as a no-op heartbeat. The supervised
# gateway's lifetime is independent of this process — s6-supervise
# restarts it on crash, and we don't want the container to exit when
# the gateway flaps. The CMD process keeps /init alive until
# `docker stop` sends SIGTERM, at which point /init runs stage 3
# shutdown (which tears down the supervised gateway cleanly).
os.execvp("sleep", ["sleep", "infinity"])
#
# Prefer `sleep infinity` (matches the static main-hermes service's
# pattern in docker/s6-rc.d/main-hermes/run, and frees the Python
# interpreter — the heartbeat is a tiny `sleep` process, not a
# resident interpreter). But `os.execvp` does a PATH lookup for the
# `sleep` binary and historically crashed the whole container with
# FileNotFoundError when PATH was empty/truncated/clobbered at this
# point — e.g. after user customizations rewrote PATH, or on minimal
# images without `sleep` on PATH (issue #36208). Fall back to an
# in-process block (no external binary, can't fail on PATH) so the
# container keeps running instead of dying during boot.
try:
os.execvp("sleep", ["sleep", "infinity"])
except OSError:
# execvp only returns by raising; on success it replaces this
# process. ENOENT (no `sleep` on PATH) and any other exec error
# land here.
print(
"→ `sleep` is unavailable; keeping the s6 CMD process alive "
"in-process until the container is stopped.",
file=sys.stderr,
flush=True,
)
_block_until_terminated()
return True # unreachable on the execvp success path
def _block_until_terminated() -> None:
"""Keep the s6 CMD process alive until the container is stopped.
Fallback heartbeat for when ``os.execvp("sleep", ...)`` can't run
(``sleep`` missing from PATH issue #36208). Installs a SIGTERM
handler that exits with the conventional 128+signum code so
``docker stop`` produces a clean, expected exit, then blocks on
``signal.pause()``. Falls back to ``threading.Event().wait()`` on
platforms without ``signal.pause()`` (e.g. Windows) although this
path only runs inside the s6 Linux container image, the fallback
keeps the helper safe to import and unit-test anywhere.
"""
signal.signal(signal.SIGTERM, lambda signum, _frame: sys.exit(128 + signum))
pause = getattr(signal, "pause", None)
if pause is not None:
while True:
pause()
else: # pragma: no cover - non-Unix fallback, not exercised in the s6 image
import threading
threading.Event().wait()
def _gateway_command_inner(args):
+25 -3
View File
@@ -4353,13 +4353,21 @@ def decompose_triage_task(
child_ids: list[str] = []
with write_txn(conn):
root_row = conn.execute(
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
"SELECT id, status, tenant, workspace_kind, workspace_path "
"FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if root_row is None:
return None
if root_row["status"] != "triage":
return None
tenant = root_row["tenant"]
# Children inherit the root's workspace by default so a fan-out
# of a code-gen task lands in the parent's project dir/worktree
# rather than throwaway scratch tmp dirs. A child dict can still
# override with its own 'workspace_kind' / 'workspace_path'.
root_ws_kind = root_row["workspace_kind"] or "scratch"
root_ws_path = root_row["workspace_path"]
# Create children. Status is 'todo' regardless of parents — we
# link them under the root AFTER creation so the dispatcher
@@ -4370,16 +4378,30 @@ def decompose_triage_task(
title = child["title"].strip()
body = child.get("body")
assignee = _canonical_assignee(child.get("assignee"))
# Per-child override wins; otherwise inherit the root's
# workspace. A child that sets workspace_kind without a path
# falls back to the root path only when kinds match (so a
# child can't accidentally point a 'dir' at the root's
# worktree path or vice versa).
child_ws_kind = child.get("workspace_kind") or root_ws_kind
if child.get("workspace_path"):
child_ws_path = child.get("workspace_path")
elif child_ws_kind == root_ws_kind:
child_ws_path = root_ws_path
else:
child_ws_path = None
conn.execute(
"INSERT INTO tasks "
"(id, title, body, assignee, status, workspace_kind, "
" tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
" workspace_path, tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)",
(
new_id,
title,
body if isinstance(body, str) else None,
assignee,
child_ws_kind,
child_ws_path,
tenant,
now,
(author or "decomposer"),
+57 -9
View File
@@ -4575,6 +4575,7 @@ def _model_flow_named_custom(config, provider_info):
menu_items,
selected=default_idx,
cancel_returns=-1,
searchable=True,
)
print()
if idx < 0 or idx >= len(models):
@@ -7478,8 +7479,23 @@ def _update_via_zip(args):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
sys.exit(1)
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
@@ -8579,16 +8595,27 @@ def _install_psutil_android_compat(
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
if uv_bin or not _is_termux_env():
return uv_bin
"""Best-effort uv bootstrap on Termux for faster update installs.
The normal path (``ensure_uv()`` in managed_uv) installs the managed
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
which gets a Termux-compatible binary.
"""
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _is_termux_env():
return None
try:
print(" → Termux detected: trying to install uv for faster dependency updates...")
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
return shutil.which("uv")
# After pip install, check managed path first, then PATH
return resolve_uv() or shutil.which("uv")
def _update_node_dependencies() -> None:
@@ -9236,7 +9263,12 @@ def _cmd_update_pip(args):
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
uv = shutil.which("uv")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current before using it.
update_managed_uv()
uv, _fresh_bootstrap = ensure_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)
@@ -9251,7 +9283,8 @@ def _cmd_update_pip(args):
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
@@ -9647,8 +9680,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
sys.exit(1)
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
+229
View File
@@ -0,0 +1,229 @@
"""Managed uv — one path, no guessing.
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
Windows). Every code path that needs uv resolves it from that single location.
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def managed_uv_path() -> Path:
"""Return the path where Hermes keeps *its* uv binary.
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
Windows. The directory may not exist yet callers should use
``ensure_uv()`` to bootstrap it.
"""
home = get_hermes_home()
if platform.system() == "Windows":
return home / "bin" / "uv.exe"
return home / "bin" / "uv"
def resolve_uv() -> Optional[str]:
"""Return the managed uv path if it exists, else ``None``.
No side effects pure lookup.
"""
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
back to pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
print(f" → Installing managed uv into {target.parent} ...")
try:
_install_uv(target)
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
# Verify
result = resolve_uv()
if result:
version = subprocess.run(
[result, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
"""
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
shutil.rmtree(venv_dir, ignore_errors=True)
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path if a managed uv is present (always even when
``uv self update`` fails, the old binary still works). Returns ``None``
only when no managed uv exists yet (``ensure_uv()`` handles that case).
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
return existing
# ---------------------------------------------------------------------------
# Installer internals
# ---------------------------------------------------------------------------
def _install_uv(target: Path) -> None:
"""Bootstrap uv into *target* using the official standalone installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
"""
system = platform.system()
env = {
**os.environ,
# Tell the astral installer to drop the binary in our dir, not
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
# uses UV_INSTALL_DIR.
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
else:
_install_uv_posix(env)
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
installer_path = f.name
try:
subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
check=True,
capture_output=True,
)
subprocess.run(
["sh", installer_path],
env=env,
check=True,
capture_output=True,
)
finally:
try:
os.unlink(installer_path)
except OSError:
pass
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
+115 -19
View File
@@ -700,6 +700,48 @@ def switch_model(
target_provider = pdef.id
# Guard against silent aggregator hops. A vendor name like bare
# "openai" is an alias that resolves to an aggregator ("openrouter").
# If the user explicitly asked for that vendor but the aggregator it
# routes to has no credentials, do NOT silently switch them onto an
# unauthed endpoint (the classic HTTP 401 "Missing Authentication
# header"). Point them at the real direct provider instead.
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
_explicit_norm = explicit_provider.strip().lower()
_alias_target = _PROVIDER_ALIAS_TABLE.get(_explicit_norm)
if (
_alias_target
and _alias_target == target_provider
and target_provider != _explicit_norm
and target_provider in _AGG_PROVIDERS
):
_authed = get_authenticated_provider_slugs(
current_provider=current_provider,
user_providers=user_providers,
custom_providers=custom_providers,
)
if target_provider not in _authed:
_suggestions = [
s for s in _authed
if s.startswith(_explicit_norm) and s != _explicit_norm
]
_hint = (
f" Did you mean: {', '.join(_suggestions)}?"
if _suggestions else ""
)
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=pdef.name,
is_global=is_global,
error_message=(
f"Provider '{_explicit_norm}' is an alias that routes "
f"through {get_label(target_provider)}, which "
f"has no credentials configured.{_hint}"
),
)
# If no model specified, try auto-detect from endpoint
if not new_model:
if pdef.base_url:
@@ -854,25 +896,62 @@ def switch_model(
api_mode = ""
if provider_changed or explicit_provider:
try:
runtime = resolve_runtime_provider(
requested=target_provider,
target_model=new_model,
)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception as e:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Could not resolve credentials for provider "
f"'{provider_label}': {e}"
),
)
import os
# User-config providers (providers.<name> in config.yaml) carry their
# own base_url + transport + key reference. resolve_runtime_provider()
# resolves by provider NAME and doesn't know user-config slugs (e.g. a
# block named "openai"), so it would re-resolve from scratch and fail
# or hop to an aggregator. Use the pdef's endpoint directly instead.
_user_pdef = None
if explicit_provider and user_providers:
from hermes_cli.providers import resolve_user_provider as _ruser
_user_pdef = _ruser(explicit_provider.strip().lower(), user_providers)
if _user_pdef is None:
_user_pdef = _ruser(target_provider, user_providers)
if _user_pdef is not None and _user_pdef.base_url:
_ucfg = (user_providers or {}).get(explicit_provider.strip().lower()) \
or (user_providers or {}).get(target_provider) or {}
_ukey = str(_ucfg.get("api_key", "") or "").strip()
if _ukey.startswith("${") and _ukey.endswith("}"):
_ukey = os.environ.get(_ukey[2:-1], "").strip()
if not _ukey:
_kenv = str(_ucfg.get("key_env", "") or "").strip()
if _kenv:
_ukey = os.environ.get(_kenv, "").strip()
try:
runtime = resolve_runtime_provider(
requested=target_provider,
explicit_api_key=_ukey or None,
explicit_base_url=_user_pdef.base_url,
target_model=new_model,
)
api_key = runtime.get("api_key", "") or _ukey
base_url = runtime.get("base_url", "") or _user_pdef.base_url
api_mode = runtime.get("api_mode", "")
except Exception:
api_key = _ukey
base_url = _user_pdef.base_url
api_mode = ""
else:
try:
runtime = resolve_runtime_provider(
requested=target_provider,
target_model=new_model,
)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception as e:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Could not resolve credentials for provider "
f"'{provider_label}': {e}"
),
)
else:
try:
runtime = resolve_runtime_provider(
@@ -1195,7 +1274,24 @@ def list_authenticated_providers(
curated["lmstudio"] = live
# --- 1. Check Hermes-mapped providers ---
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
# Skip vendor names that are merely aliases routing through an
# aggregator (e.g. bare "openai" → "openrouter"). These are NOT
# directly-routable providers: emitting them as their own picker
# row produces a phantom entry that, when selected, resolves via
# resolve_provider_full() to the aggregator (OpenRouter) — silently
# switching a user off their real provider onto an endpoint they
# may have no key for (HTTP 401). The user's real provider (e.g.
# openai-api, or a providers.openai config row) covers this vendor.
_alias_target = _PROVIDER_ALIAS_TABLE.get(hermes_id)
if (
_alias_target
and _alias_target != hermes_id
and _alias_target in _AGG_PROVIDERS
):
continue
# Skip aliases that map to the same models.dev provider (e.g.
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
# The first one with valid credentials wins (#10526).
+25 -2
View File
@@ -235,13 +235,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.5-flash",
"gemini-3.1-flash-lite-preview",
],
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.5-flash",
],
"zai": [
"glm-5.1",
@@ -2106,9 +2106,32 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if api_key:
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
base = base_raw or "https://api.openai.com/v1"
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
# may serve a small curated catalog — use the live list verbatim so
# discovery works. But the canonical api.openai.com /v1/models dump
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
# legacy chat models — none of which belong in the agent model picker.
# For the default endpoint, intersect the live list with our curated
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
is_default_openai = base.rstrip("/") in (
"https://api.openai.com/v1",
"https://api.openai.com",
)
try:
live = fetch_api_models(api_key, base)
if live:
if is_default_openai:
live_lower = {m.lower() for m in live}
curated = list(_PROVIDER_MODELS.get(normalized, []))
# Keep curated order; only surface curated models the
# account actually has access to.
filtered = [m for m in curated if m.lower() in live_lower]
if filtered:
return filtered
# Account serves none of the curated models (rare —
# e.g. org without GPT-5 access). Fall back to curated
# so the picker still offers sane defaults.
return curated or live
return live
except Exception:
pass
+15 -2
View File
@@ -47,7 +47,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
"openrouter": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
extra_env_vars=("OPENAI_API_KEY",),
base_url_env_var="OPENROUTER_BASE_URL",
),
"nous": HermesOverlay(
@@ -677,6 +676,20 @@ def resolve_provider_full(
ProviderDef if found, else None.
"""
canonical = normalize_provider(name)
raw = name.strip().lower()
# 0. User-defined config providers win over the built-in alias table.
# A user who declares ``providers.<name>`` in config.yaml has stated
# explicit intent for that name — it must not be hijacked by a legacy
# vendor alias (e.g. bare "openai" → "openrouter"). Resolve the raw
# name against user config FIRST so a configured ``providers.openai``
# (pointing at api.openai.com) beats the alias that would otherwise
# silently route to OpenRouter. Only the raw (pre-alias) name is tried
# here; canonical/alias resolution still happens below.
if user_providers:
user_pdef = resolve_user_provider(raw, user_providers)
if user_pdef is not None:
return user_pdef
# 1. Built-in (models.dev + overlays)
pdef = get_provider(canonical)
@@ -690,7 +703,7 @@ def resolve_provider_full(
if user_pdef is not None:
return user_pdef
# Try original name (in case alias didn't match)
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
user_pdef = resolve_user_provider(raw, user_providers)
if user_pdef is not None:
return user_pdef
+35 -18
View File
@@ -335,7 +335,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Collect results from all (or filtered) sources in parallel.
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
# NOTE: when the centralized index is available, parallel_search_sources
# skips the external API sources and serves everything from "hermes-index".
# That source MUST therefore carry a high limit, or browse silently caps
# the entire hub at the default (50) — it shipped that way and surfaced
# ~136 of 88k skills. The external-source limits below only apply when the
# index is unavailable (offline / first run before the cache populates).
_PER_SOURCE_LIMIT = {
"hermes-index": 5000,
"official": 200, "skills-sh": 200, "well-known": 50,
"github": 200, "clawhub": 500, "claude-marketplace": 100,
"lobehub": 500, "browse-sh": 500,
@@ -396,18 +403,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Build table
table = Table(show_header=True, header_style="bold")
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("Name", style="bold cyan", max_width=25)
table.add_column("Description", max_width=50)
table.add_column("Name", style="bold cyan", max_width=22)
table.add_column("Description", max_width=44)
table.add_column("Source", style="dim", width=12)
table.add_column("Trust", width=10)
# The identifier is what you pass to `hermes skills install`. Browse used
# to omit it entirely, so users couldn't act on what they saw without a
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
for i, r in enumerate(page_items, start=start + 1):
trust_style = {"builtin": "bright_cyan", "trusted": "green",
"community": "yellow"}.get(r.trust_level, "dim")
trust_label = "★ official" if r.source == "official" else r.trust_level
desc = r.description[:50]
if len(r.description) > 50:
desc = r.description[:44]
if len(r.description) > 44:
desc += "..."
table.add_row(
@@ -416,6 +427,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
desc,
r.source,
f"[{trust_style}]{trust_label}[/]",
r.identifier,
)
c.print(table)
@@ -439,7 +451,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
f"— run again for cached results[/]")
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
"'hermes skills install <identifier>' to install, "
"'hermes skills search <query>' to search deeper[/]\n")
def do_install(identifier: str, category: str = "", force: bool = False,
@@ -725,24 +739,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
"""
from tools.skills_hub import GitHubAuth, create_source_router
from tools.skills_hub import (
GitHubAuth, create_source_router, parallel_search_sources,
)
page_size = max(1, min(page_size, 100))
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
# "hermes-index" must carry a high limit: when the index is available the
# router skips external API sources and serves everything from it, so a
# low cap here silently truncates the whole hub (see do_browse note).
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
"well-known": 25, "github": 100, "clawhub": 50,
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
auth = GitHubAuth()
sources = create_source_router(auth)
all_results: list = []
for src in sources:
sid = src.source_id()
if source != "all" and sid != source and sid != "official":
continue
try:
limit = _PER_SOURCE_LIMIT.get(sid, 50)
all_results.extend(src.search("", limit=limit))
except Exception:
continue
# Delegate to the shared parallel walker so this inherits the index-aware
# source-skip logic — querying hermes-index AND the external APIs at once
# would double-count every skill.
all_results, _counts, _timed_out = parallel_search_sources(
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source, overall_timeout=30,
)
if not all_results:
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {}
@@ -759,7 +776,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
page_items = deduped[start : min(start + page_size, total)]
return {
"items": [{"name": r.name, "description": r.description, "source": r.source,
"trust": r.trust_level} for r in page_items],
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
+836 -39
View File
File diff suppressed because it is too large Load Diff
+208 -7
View File
@@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_platform TEXT,
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@@ -451,12 +452,8 @@ class SessionDB:
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)",
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python. (underlying error: %s)",
self.db_path,
exc,
)
@@ -1430,6 +1427,22 @@ class SessionDB:
row = cursor.fetchone()
return row["title"] if row else None
def set_session_archived(self, session_id: str, archived: bool) -> bool:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages this is a soft hide, not a delete. Returns True when a
row was updated.
"""
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET archived = ? WHERE id = ?",
(1 if archived else 0, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
@@ -1549,6 +1562,8 @@ class SessionDB:
min_message_count: int = 0,
project_compression_tips: bool = True,
order_by_last_active: bool = False,
include_archived: bool = False,
archived_only: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -1604,6 +1619,10 @@ class SessionDB:
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
if order_by_last_active:
@@ -3027,7 +3046,13 @@ class SessionDB:
# Utility
# =========================================================================
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
def session_count(
self,
source: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
) -> int:
"""Count sessions, optionally filtered by source."""
where_clauses = []
params = []
@@ -3038,6 +3063,10 @@ class SessionDB:
if min_message_count > 0:
where_clauses.append("message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("archived = 1")
elif not include_archived:
where_clauses.append("archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
@@ -3153,6 +3182,178 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Children of every deleted ID are orphaned
(``parent_session_id NULL``), never cascade-deleted, so a
branch / subagent transcript survives an inadvertent parent
delete.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
# Orphan children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def prune_sessions(
self,
older_than_days: int = 90,
+109 -22
View File
@@ -36,8 +36,13 @@ Key contract points encoded here:
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
JWKS is cached for 5 minutes.
- V1 has NO refresh tokens ``refresh_session`` always raises
``RefreshExpiredError`` so the middleware redirects to ``/auth/login``.
- the dashboard auth-code grant issues a 24h rotating refresh token
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
to rotate the access token; ``complete_login`` and ``refresh_session``
both populate ``Session.refresh_token`` with the (rotating) value the
middleware persists back to the HttpOnly cookie. On a dead/expired/
reuse-detected refresh token Portal returns 400 ``RefreshExpiredError``
middleware redirects to ``/auth/login``.
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
- tolerant ``oauth_contract_version`` check: missing warn + proceed;
present and ``!= 1`` refuse.
@@ -49,11 +54,11 @@ of cookie names; this provider just hands back ``{"code_verifier": …,
"state": }`` and the route serializes those into the ``hermes_session_pkce``
cookie.
Forward compatibility: if a future Portal contract starts issuing refresh
tokens, ``complete_login`` already captures the value forward-compatibly
(populates ``Session.refresh_token``). Wiring the RT cookie back into the
middleware's near-expiry refresh path lives in the host application, not
here.
Refresh-token rotation: Portal rotates the refresh token on every
successful refresh and runs reuse-detection (replaying a rotated token
outside Portal's 60s grace revokes the whole session). The host
middleware therefore MUST persist the rotated ``Session.refresh_token``
back to the cookie on every refresh.
Skip reasons:
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
@@ -229,12 +234,94 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
except httpx.RequestError as exc:
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
# The dashboard auth-code grant now issues a rotating refresh token
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
return self._token_response_to_session(
response, bad_request_exc=InvalidCodeError
)
def refresh_session(self, *, refresh_token: str) -> Session:
"""Rotate the access token using the refresh token.
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
so it never lands in Portal's request-body access logs — mirroring the
device-flow CLI convention; Portal reconciles header vs. body and
rejects conflicts.
Portal rotates the refresh token on every successful refresh, so the
returned ``Session.refresh_token`` is a NEW value the caller MUST
persist (replacing the old cookie). Failing to persist it means the
next refresh replays a rotated token and outside Portal's 60s grace
trips reuse-detection and revokes the whole session.
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
detected), so the middleware clears cookies and forces re-login.
Raises ``ProviderError`` if Portal is unreachable.
"""
if not refresh_token:
# No RT to present — treat as a dead session so middleware
# forces a clean re-login rather than emitting a malformed POST.
raise RefreshExpiredError("no refresh token present in session")
try:
response = httpx.post(
self._token_url,
# The refresh token goes in BOTH the body and the
# ``x-nous-refresh-token`` header. Portal's token endpoint
# requires ``refresh_token`` in the body (its request schema
# rejects a header-only request as ``invalid_request``), and
# additionally reconciles the header against the body — sending
# both lets Portal keep the value out of body-access-logs while
# still satisfying the schema. The header name must match
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
# token``); any other name is silently ignored. (Verified
# against the NAS #293 preview deploy: header-only → 400
# invalid_request; body → accepted.)
data={
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
headers={
"Accept": "application/json",
"x-nous-refresh-token": refresh_token,
},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
raise ProviderError(
f"Portal token endpoint unreachable: {exc}"
) from exc
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
# surface as RefreshExpiredError so middleware forces re-login.
return self._token_response_to_session(
response, bad_request_exc=RefreshExpiredError
)
def _token_response_to_session(
self,
response: httpx.Response,
*,
bad_request_exc: type[Exception],
) -> Session:
"""Translate a Portal ``/api/oauth/token`` response into a Session.
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
(refresh grant). ``bad_request_exc`` is the exception type raised on a
400 ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
for the refresh path so the middleware's distinct handling
(400-on-callback vs. force-relogin) is preserved.
"""
if response.status_code == 400:
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
# (auth-code) and expired / revoked / reuse-detected (refresh) all
# surface as 400 with an OAuth-shaped JSON error envelope.
body = self._parse_json_body(response)
error_code = body.get("error", "invalid_request")
raise InvalidCodeError(f"Portal rejected code: {error_code}")
raise bad_request_exc(f"Portal rejected token request: {error_code}")
if response.status_code != 200:
raise ProviderError(
f"Portal token endpoint returned {response.status_code}: "
@@ -251,21 +338,14 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
raise ProviderError(f"unexpected token_type={token_type!r}")
claims = self._verify_jwt(access_token)
# Contract V1: no refresh token expected. If a future Portal ever
# adds one, capture it forward-compatibly.
# The dashboard grant issues a rotating refresh token; capture it so
# the caller can persist it. Empty string if Portal omitted it (the
# session then behaves as access-token-only until expiry).
refresh_token = payload.get("refresh_token") or ""
if not isinstance(refresh_token, str):
refresh_token = ""
return self._session_from_claims(access_token, refresh_token, claims)
def refresh_session(self, *, refresh_token: str) -> Session:
# Contract V1 has no refresh tokens — always force re-auth. If a
# future Portal contract starts issuing them, this method needs to
# be re-implemented; until then it's an unconditional refusal.
raise RefreshExpiredError(
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
"user must re-authenticate via /auth/login."
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Contract: returns None on expiry/invalidity (middleware then
@@ -284,9 +364,16 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
return self._session_from_claims(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
# Contract V1: no refresh tokens to revoke, and no Portal revocation
# endpoint documented for dashboard tokens. Logout is purely
# client-side cookie clearing; this is a best-effort no-op.
# Portal exposes no public refresh-token revocation grant on its token
# endpoint (revocation is driven from the authenticated /sessions UI,
# keyed by sessionId + userId, not by the RT value). So logout is
# client-side cookie clearing; the server-side refresh session simply
# expires within its 24h TTL. Best-effort no-op, must not raise.
#
# If Portal later adds a token-endpoint revoke grant (e.g.
# grant_type=... + X-Refresh-Token), implement it here so logout
# invalidates the RT server-side immediately rather than waiting out
# the TTL.
_ = refresh_token
return None
@@ -1,17 +0,0 @@
"""Example dashboard plugin — backend API routes.
Mounted at /api/plugins/example/ by the dashboard plugin system.
This minimal plugin exists so the test suite has a stable, side-effect-free
GET endpoint to verify that plugin API routes work with auth.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
+142 -52
View File
@@ -228,6 +228,9 @@ class HonchoMemoryProvider(MemoryProvider):
self._session_initialized = False
self._lazy_init_kwargs: Optional[dict] = None
self._lazy_init_session_id: Optional[str] = None
self._init_thread: Optional[threading.Thread] = None
self._init_lock = threading.Lock()
self._init_error = ""
# Port #4053: cron guard — when True, plugin is fully inactive
self._cron_skipped = False
@@ -326,22 +329,24 @@ class HonchoMemoryProvider(MemoryProvider):
# aiPeer comes from honcho.json (host block or root) only.
# SOUL.md is persona content, not identity config.
# ----- Port #1957: lazy session init for tools-only mode -----
self._lazy_init_kwargs = dict(kwargs)
self._lazy_init_session_id = session_id
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
# Network-backed session creation can block on Honcho service or DB
# outages. Startup must fail open for context/hybrid modes, where
# Honcho is initialized only to enrich prompts. Tools-only mode has
# an explicit contract: init_on_session_start=False stays lazy until
# the first tool call, while init_on_session_start=True remains an
# eager, ready-on-return initialization path.
if self._recall_mode == "tools":
if cfg.init_on_session_start:
# Eager init even in tools mode (opt-in)
self._do_session_init(cfg, session_id, **kwargs)
self._ensure_session()
return
# Defer actual session creation until first tool call
self._lazy_init_kwargs = kwargs
self._lazy_init_session_id = session_id
# Still need a client reference for _ensure_session
self._config = cfg
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
return
# ----- Eager init (context or hybrid mode) -----
self._do_session_init(cfg, session_id, **kwargs)
self._start_session_init_background(wait_timeout=0.1)
except ImportError:
logger.debug("honcho-ai package not installed — plugin inactive")
@@ -349,6 +354,66 @@ class HonchoMemoryProvider(MemoryProvider):
logger.warning("Honcho init failed: %s", e)
self._manager = None
def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
"""Resolve the Honcho session key without touching the network."""
session_title = kwargs.get("session_title")
gateway_session_key = kwargs.get("gateway_session_key")
return (
cfg.resolve_session_name(
session_title=session_title,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
or session_id
or "hermes-default"
)
def _start_session_init_background(self, *, wait_timeout: float = 0.0) -> None:
"""Start Honcho session initialization in a daemon thread.
This keeps Hermes CLI/gateway startup responsive when Honcho is down,
slow, or its database is unhealthy. The thread may still take the SDK
timeout path, but it cannot block agent construction or first prompt
assembly. ``wait_timeout`` lets fast/mock initializations finish before
returning while still failing open for slow backends.
"""
if self._cron_skipped or self._session_initialized:
return
if not self._config or self._lazy_init_kwargs is None:
return
with self._init_lock:
if self._cron_skipped or self._session_initialized:
return
if self._init_thread and self._init_thread.is_alive():
return
if not self._config or self._lazy_init_kwargs is None:
return
cfg = self._config
init_kwargs = dict(self._lazy_init_kwargs)
init_session_id = self._lazy_init_session_id or "hermes-default"
def _run() -> None:
try:
self._do_session_init(cfg, init_session_id, **init_kwargs)
self._lazy_init_kwargs = None
self._lazy_init_session_id = None
self._init_error = ""
except Exception as e:
self._init_error = str(e)
self._manager = None
logger.warning("Honcho background session init failed: %s", e)
self._init_thread = threading.Thread(
target=_run,
daemon=True,
name="honcho-session-init",
)
self._init_thread.start()
if wait_timeout > 0:
self._init_thread.join(timeout=wait_timeout)
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
"""Shared session initialization logic for both eager and lazy paths."""
from plugins.memory.honcho.client import get_honcho_client
@@ -364,22 +429,15 @@ class HonchoMemoryProvider(MemoryProvider):
)
# ----- B3: resolve_session_name -----
session_title = kwargs.get("session_title")
gateway_session_key = kwargs.get("gateway_session_key")
self._session_key = (
cfg.resolve_session_name(
session_title=session_title,
session_id=session_id,
gateway_session_key=gateway_session_key,
)
or session_id
or "hermes-default"
)
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
logger.debug("Honcho session key resolved: %s", self._session_key)
# Create session eagerly
# Create the remote session before running startup-only migration and
# prewarm work. Do not mark the provider ready until this method's
# synchronous setup has finished; background startup sets _manager before
# get_or_create()/migration/prewarm are complete, and lifecycle hooks must
# not treat that partially initialized state as usable.
session = self._manager.get_or_create(self._session_key)
self._session_initialized = True
# ----- B6: Memory file migration (one-time, for new sessions) -----
# Skip under per-session strategy: every Hermes run creates a fresh
@@ -434,12 +492,15 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
prewarm_thread = threading.Thread(
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
)
self._prefetch_thread.start()
prewarm_thread.start()
self._prefetch_thread = prewarm_thread
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
self._session_initialized = True
def _ensure_session(self) -> bool:
"""Lazily initialize the Honcho session (for tools-only mode).
@@ -449,7 +510,9 @@ class HonchoMemoryProvider(MemoryProvider):
return True
if self._cron_skipped:
return False
if not self._config or not self._lazy_init_kwargs:
if self._init_thread and self._init_thread.is_alive():
return False
if not self._config or self._lazy_init_kwargs is None:
return False
try:
@@ -463,9 +526,26 @@ class HonchoMemoryProvider(MemoryProvider):
self._lazy_init_session_id = None
return self._manager is not None
except Exception as e:
self._manager = None
self._session_initialized = False
logger.warning("Honcho lazy session init failed: %s", e)
return False
def _session_ready(self) -> bool:
"""Return whether a manager/session key can be used safely.
Background initialization sets ``_manager`` before the blocking
get-or-create call completes, so ``_session_initialized`` guards real
async startup. Tests and legacy direct construction may inject a ready
manager/session key without setting that flag; allow that only when no
init thread is currently in flight.
"""
if not self._manager or not self._session_key:
return False
if self._session_initialized:
return True
return not (self._init_thread and self._init_thread.is_alive())
def _format_first_turn_context(self, ctx: dict) -> str:
"""Format the prefetch context dict into a readable system prompt block."""
parts = []
@@ -505,14 +585,8 @@ class HonchoMemoryProvider(MemoryProvider):
if self._cron_skipped:
return ""
if not self._manager or not self._session_key:
# tools-only mode without session yet still returns a minimal block
if self._recall_mode == "tools" and self._config:
return (
"# Honcho Memory\n"
"Active (tools-only mode). Use honcho_profile, honcho_search, "
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
)
return ""
if not self._config:
return ""
# ----- B1: adapt text based on recall_mode -----
if self._recall_mode == "context":
@@ -563,6 +637,10 @@ class HonchoMemoryProvider(MemoryProvider):
if self._recall_mode == "tools":
return ""
if not self._session_ready():
self._start_session_init_background()
return ""
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
if self._injection_frequency == "first-turn" and self._turn_count > 1:
@@ -575,18 +653,17 @@ class HonchoMemoryProvider(MemoryProvider):
parts = []
# ----- Layer 1: Base context (representation + card) -----
# On first call, fetch synchronously so turn 1 isn't empty.
# After that, serve from cache and refresh in background on cadence.
# First fetch is asynchronous: a slow Honcho backend must not block the
# first response. Serve empty context now and consume the background
# result on a later turn.
with self._base_context_lock:
if self._base_context_cache is None:
# First call — synchronous fetch
self._base_context_cache = ""
self._last_context_turn = self._turn_count
try:
ctx = self._manager.get_prefetch_context(self._session_key)
self._base_context_cache = self._format_first_turn_context(ctx) if ctx else ""
self._last_context_turn = self._turn_count
self._manager.prefetch_context(self._session_key, query or None)
except Exception as e:
logger.debug("Honcho base context fetch failed: %s", e)
self._base_context_cache = ""
logger.debug("Honcho base context prefetch failed: %s", e)
base_context = self._base_context_cache
# Check if background context prefetch has a fresher result
@@ -641,10 +718,11 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
first_turn_thread = threading.Thread(
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
)
self._prefetch_thread.start()
first_turn_thread.start()
self._prefetch_thread = first_turn_thread
self._prefetch_thread.join(timeout=_first_turn_timeout)
if self._prefetch_thread.is_alive():
logger.debug(
@@ -709,13 +787,14 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key or not query:
return
# B1: tools-only mode — no prefetch
if self._recall_mode == "tools":
return
if not self._session_ready() or not query:
self._start_session_init_background()
return
# Trivial prompts don't warrant either a context refresh or a dialectic call.
if self._is_trivial_prompt(query):
return
@@ -769,10 +848,11 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
self._prefetch_thread.start()
prefetch_thread.start()
self._prefetch_thread = prefetch_thread
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
@@ -1126,7 +1206,10 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key:
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
return
msg_limit = self._config.message_max_chars if self._config else 25000
@@ -1169,7 +1252,10 @@ class HonchoMemoryProvider(MemoryProvider):
return
if self._cron_skipped:
return
if not self._manager or not self._session_key:
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
return
def _write():
@@ -1187,6 +1273,8 @@ class HonchoMemoryProvider(MemoryProvider):
return
if not self._manager:
return
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
return
# Wait for pending sync
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=10.0)
@@ -1213,6 +1301,8 @@ class HonchoMemoryProvider(MemoryProvider):
# Port #1957: ensure session is initialized for tools-only mode
if not self._session_initialized:
if self._init_thread and self._init_thread.is_alive():
return tool_error("Honcho session is still initializing; try again shortly.")
if not self._ensure_session():
return tool_error("Honcho session could not be initialized.")
@@ -1313,7 +1403,7 @@ class HonchoMemoryProvider(MemoryProvider):
if t and t.is_alive():
t.join(timeout=5.0)
# Flush any remaining messages
if self._manager:
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
try:
self._manager.flush_all()
except Exception:
+1 -1
View File
@@ -56,7 +56,7 @@ gemini = GeminiProfile(
env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url="https://generativelanguage.googleapis.com/v1beta",
auth_type="api_key",
default_aux_model="gemini-3-flash-preview",
default_aux_model="gemini-3.5-flash",
)
google_gemini_cli = GeminiProfile(
+27 -17
View File
@@ -269,7 +269,13 @@ class SimplexAdapter(BasePlatformAdapter):
# ------------------------------------------------------------------
async def _health_monitor(self) -> None:
"""Force reconnect if the WebSocket has been idle too long."""
"""Observe WebSocket idleness without reconnecting healthy quiet links.
simplex-chat can legitimately stay application-silent for long periods
when no messages arrive. The websockets client already sends protocol
pings (see _ws_listener ping_interval/ping_timeout), so treating lack of
chat events as a stale connection causes needless reconnect churn.
"""
while self._running:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
if not self._running:
@@ -277,15 +283,7 @@ class SimplexAdapter(BasePlatformAdapter):
elapsed = time.time() - self._last_ws_activity
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
logger.warning(
"SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
)
self._last_ws_activity = time.time()
if self._ws:
try:
await self._ws.close()
except Exception:
pass
logger.debug("SimpleX: WS application-idle for %.0fs", elapsed)
# ------------------------------------------------------------------
# Inbound event handling
@@ -293,7 +291,12 @@ class SimplexAdapter(BasePlatformAdapter):
async def _handle_event(self, event: dict) -> None:
"""Dispatch a daemon event to the appropriate handler."""
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
# simplex-chat WebSocket messages are usually shaped as:
# {"corrId": "...", "resp": {"type": "newChatItems", ...}}
# Older/examples may put the response fields at top-level. Normalize
# both forms before dispatching, otherwise inbound chatItems are lost.
resp = event.get("resp") if isinstance(event.get("resp"), dict) else event
resp_type = event.get("type") or resp.get("type", "")
# Filter responses to our own commands (echoes)
corr_id = event.get("corrId", "")
@@ -302,10 +305,10 @@ class SimplexAdapter(BasePlatformAdapter):
return
if resp_type == "newChatItem":
await self._handle_new_chat_item(event)
await self._handle_new_chat_item(resp)
elif resp_type == "newChatItems":
# Batch variant — process each item
items = event.get("chatItems") or []
items = resp.get("chatItems") or []
for item_wrapper in items:
await self._handle_new_chat_item(item_wrapper)
# Ignore all other event types (delivery receipts, contact updates, etc.)
@@ -347,7 +350,9 @@ class SimplexAdapter(BasePlatformAdapter):
or contact_info.get("localDisplayName")
or contact_id
)
chat_id = contact_id
# Replies must be routed by SimpleX CLI display name, while
# authorization should still use the stable numeric contactId.
chat_id = contact_name or contact_id
chat_name = contact_name
if not chat_id:
@@ -364,7 +369,7 @@ class SimplexAdapter(BasePlatformAdapter):
or sender_id
)
else:
sender_id = chat_id
sender_id = contact_id if not is_group else chat_id
sender_name = chat_name
# Extract text
@@ -508,7 +513,11 @@ class SimplexAdapter(BasePlatformAdapter):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {content}"
else:
cmd_str = f"@[{chat_id}] {content}"
# SimpleX CLI addresses direct contacts by display name, e.g.
# `@Alice hello`. `@[Alice]` is interpreted literally as a contact
# named "[Alice]" and `@[4]` as "[4]", so do not wrap direct
# chat IDs / display names in brackets.
cmd_str = f"@{chat_id} {content}"
payload = {
"corrId": corr_id,
@@ -643,7 +652,8 @@ async def _standalone_send(
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {message}"
else:
cmd_str = f"@[{chat_id}] {message}"
# Direct contacts are addressed by display name without brackets.
cmd_str = f"@{chat_id} {message}"
payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
+76 -13
View File
@@ -21,9 +21,12 @@ delivers it.
from __future__ import annotations
import asyncio
import base64
import logging
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import httpx
@@ -42,7 +45,9 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
DEFAULT_MODEL = "grok-imagine-video"
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
DEFAULT_DURATION = 8
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_RESOLUTION = "720p"
@@ -58,10 +63,18 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"grok-imagine-video": {
"display": "Grok Imagine Video",
"speed": "~60-240s",
"strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.",
"price": "see https://docs.x.ai/docs/models",
"strengths": "Text-to-video; legacy image-to-video fallback.",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
"modalities": ["text", "image"],
},
"grok-imagine-video-1.5-preview": {
"display": "Grok Imagine Video 1.5 Preview",
"speed": "~60-240s",
"strengths": "Latest xAI image-to-video model.",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
"modalities": ["image"],
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
},
}
@@ -111,10 +124,31 @@ def _xai_headers(api_key: str) -> Dict[str, str]:
}
def _image_ref_to_xai_url(value: str) -> str:
"""Return a URL/data URI accepted by xAI for image inputs."""
ref = (value or "").strip()
if not ref:
return ""
lower = ref.lower()
if lower.startswith(("http://", "https://", "data:image/")):
return ref
path = Path(ref).expanduser()
if not path.is_file():
return ref
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if not mime.startswith("image/"):
return ref
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
refs = []
for url in reference_image_urls or []:
normalized = (url or "").strip()
normalized = _image_ref_to_xai_url(url)
if normalized:
refs.append({"url": normalized})
return refs or None
@@ -131,6 +165,28 @@ def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
return value
def _resolve_model_for_modality(
model: Optional[str],
*,
modality: str,
explicit_model: bool,
) -> str:
"""Select xAI's text/video model without treating config as a prompt override.
``grok-imagine-video-1.5-preview`` currently rejects text-only video
generation, but it is the desired image-to-video backend. Explicit tool
``model=`` still wins for users who intentionally request another model.
"""
requested = (model or "").strip()
if explicit_model and requested:
return requested
if modality == "image":
return DEFAULT_IMAGE_TO_VIDEO_MODEL
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
return DEFAULT_TEXT_TO_VIDEO_MODEL
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
async def _submit(
client: httpx.AsyncClient,
payload: Dict[str, Any],
@@ -192,7 +248,7 @@ async def _poll(
class XAIVideoGenProvider(VideoGenProvider):
"""xAI grok-imagine-video backend (text-to-video + image-to-video)."""
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
@property
def name(self) -> str:
@@ -222,7 +278,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return {
"name": "xAI Grok Imagine",
"badge": "paid",
"tag": "grok-imagine-video text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
@@ -260,6 +316,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return loop.run_until_complete(self._generate_async(
prompt=prompt,
model=model,
explicit_model=bool(kwargs.get("_model_override_explicit")),
image_url=image_url,
reference_image_urls=reference_image_urls,
duration=duration,
@@ -284,6 +341,7 @@ class XAIVideoGenProvider(VideoGenProvider):
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
@@ -303,10 +361,15 @@ class XAIVideoGenProvider(VideoGenProvider):
)
prompt = (prompt or "").strip()
image_url_norm = (image_url or "").strip() or None
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
modality_used = "image" if image_url_norm else "text"
resolved_model = _resolve_model_for_modality(
model,
modality=modality_used,
explicit_model=explicit_model,
)
if not prompt:
return error_response(
@@ -340,7 +403,7 @@ class XAIVideoGenProvider(VideoGenProvider):
normalized_resolution = DEFAULT_RESOLUTION
payload: Dict[str, Any] = {
"model": model or DEFAULT_MODEL,
"model": resolved_model,
"prompt": prompt,
"duration": clamped_duration,
"aspect_ratio": normalized_aspect_ratio,
@@ -366,7 +429,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
error_type="api_error",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
@@ -388,7 +451,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error="xAI video generation completed without a video URL",
error_type="empty_response",
provider="xai",
model=body.get("model") or model or DEFAULT_MODEL,
model=body.get("model") or resolved_model,
prompt=prompt,
)
extra: Dict[str, Any] = {
@@ -399,7 +462,7 @@ class XAIVideoGenProvider(VideoGenProvider):
extra["usage"] = body["usage"]
return success_response(
video=url,
model=body.get("model") or model or DEFAULT_MODEL,
model=body.get("model") or resolved_model,
prompt=prompt,
modality=modality_used,
aspect_ratio=normalized_aspect_ratio,
@@ -413,7 +476,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
error_type="timeout",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
@@ -426,7 +489,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=message,
error_type=f"xai_{status}",
provider="xai",
model=model or DEFAULT_MODEL,
model=resolved_model,
prompt=prompt,
)
+1 -1
View File
@@ -1,6 +1,6 @@
name: xai
version: 1.0.0
description: "xAI Grok-Imagine video generation backend. Supports text-to-video, image-to-video, reference-image-guided generation, video edit, and video extend via the xAI async videos API."
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
author: NousResearch
kind: backend
requires_env:
+50 -77
View File
@@ -289,79 +289,57 @@ function Install-AgentBrowser {
# ============================================================================
function Install-Uv {
Write-Info "Checking for uv package manager..."
# Check if uv is already available
if (Get-Command uv -ErrorAction SilentlyContinue) {
$version = uv --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv found ($version)"
return $true
}
# Check common install locations
$uvPaths = @(
"$env:USERPROFILE\.local\bin\uv.exe",
"$env:USERPROFILE\.cargo\bin\uv.exe"
)
foreach ($uvPath in $uvPaths) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
$version = & $uvPath --version
Write-Success "uv found at $uvPath ($version)"
return $true
}
}
# Install uv
Write-Info "Installing uv (fast Python package manager)..."
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value -- if the assignment lived inside try and the
# try body threw before reaching it, the catch would see $prevEAP
# unset and leave EAP at whatever the previous protected call set.
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
$prevUVInstallDir = $env:UV_INSTALL_DIR
try {
# Relax ErrorActionPreference around the nested astral installer.
# The astral installer (a separate `powershell -c "irm ... | iex"`)
# writes download progress to stderr. With $ErrorActionPreference
# = "Stop" set at the top of this script, PowerShell wraps stderr
# lines from native commands (which `powershell -c` is, from our
# perspective) as ErrorRecord objects when captured via 2>&1, then
# throws a terminating exception on the first one -- even though
# uv installs successfully and the child exits 0. Same fix
# pattern Test-Python uses for `uv python install`; verify success
# via Test-Path on the expected binary afterwards, which is more
# reliable than exit-code/stderr signal anyway.
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Find the installed binary
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
if (-not (Test-Path $uvExe)) {
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
# Restore UV_INSTALL_DIR — don't leak it into subsequent stages.
if ($null -eq $prevUVInstallDir) {
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
} else {
$env:UV_INSTALL_DIR = $prevUVInstallDir
}
if (-not (Test-Path $uvExe)) {
# Refresh PATH and try again
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$uvExe = (Get-Command uv).Source
}
}
if (Test-Path $uvExe) {
$script:UvCmd = $uvExe
$version = & $uvExe --version
Write-Success "uv installed ($version)"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv installed ($version)"
return $true
}
Write-Err "uv installed but not found on PATH"
Write-Info "Try restarting your terminal and re-running"
Write-Err "uv installed but not found at $managedUv"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
# Restore UV_INSTALL_DIR on error too.
if ($null -eq $prevUVInstallDir) {
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
} else {
$env:UV_INSTALL_DIR = $prevUVInstallDir
}
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
@@ -385,11 +363,9 @@ function Sync-EnvPath {
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from PATH or known install paths.
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch. Fast path is a single
# Get-Command call when uv is on PATH (the common case after Stage-Uv
# ran path-modifying installs in a sibling process).
# at the top to populate $script:UvCmd from the managed location.
# Throws if uv is not findable the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
# in the same process and set $script:UvCmd).
@@ -404,9 +380,15 @@ function Resolve-UvCmd {
# Stale; fall through to re-discover.
}
# Try PATH first (covers `winget install astral.uv`, manual installs,
# and the post-Install-Uv state where uv.exe lives in
# %USERPROFILE%\.local\bin which the installer added to PATH).
# Check the managed location first — this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
return
}
# Fall back to PATH (covers edge cases where the installer ran in a
# sibling process and HERMES_HOME wasn't propagated).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
@@ -420,16 +402,7 @@ function Resolve-UvCmd {
return
}
# Check the well-known install locations the astral.sh installer drops
# uv into. Mirrors the probe order Install-Uv uses.
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
return
}
}
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
throw "uv is not installed. Run install.ps1 -Stage uv first."
}
function Test-Python {
+17 -138
View File
@@ -475,39 +475,22 @@ install_uv() {
return 0
fi
log_info "Checking for uv package manager..."
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.sh and `hermes update` stay in sync.
local _managed_uv="$HERMES_HOME/bin/uv"
# Check common locations for uv
if command -v uv &> /dev/null; then
UV_CMD="uv"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found ($UV_VERSION)"
log_success "Managed uv found ($UV_VERSION)"
return 0
fi
# Check ~/.local/bin (default uv install location) even if not on PATH yet
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.local/bin ($UV_VERSION)"
return 0
fi
log_info "Installing managed uv into $HERMES_HOME/bin ..."
mkdir -p "$HERMES_HOME/bin"
# Check ~/.cargo/bin (alternative uv install location)
if [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
return 0
fi
# Install uv
log_info "Installing uv (fast Python package manager)..."
# Capture installer output so a failure shows the user WHY (network,
# glibc mismatch on old distros, missing curl, ~/.local/bin not
# writable, disk full, corp proxy / TLS interception, etc.) instead
# of the previous "✗ Failed to install uv" with zero diagnostic.
#
# Two-stage: download the installer, then run it. Piping
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
# and conflates network errors with installer errors.
@@ -522,26 +505,22 @@ install_uv() {
rm -f "$_uv_install_log" "$_uv_installer"
exit 1
fi
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
rm -f "$_uv_installer"
# uv installs to ~/.local/bin by default
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
elif [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
elif command -v uv &> /dev/null; then
UV_CMD="uv"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
else
log_error "uv installer reported success but binary not found on PATH"
log_error "uv installer reported success but binary not found at $_managed_uv"
log_info "Installer output:"
sed 's/^/ /' "$_uv_install_log" >&2
log_info "Try adding ~/.local/bin to your PATH and re-running"
rm -f "$_uv_install_log"
exit 1
fi
rm -f "$_uv_install_log"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv installed ($UV_VERSION)"
log_success "Managed uv installed ($UV_VERSION)"
else
log_error "Failed to install uv"
log_info "Installer output:"
@@ -579,7 +558,6 @@ check_python() {
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
ensure_fts5
return 0
fi
@@ -589,7 +567,6 @@ check_python() {
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
ensure_fts5
else
log_error "Failed to install Python $PYTHON_VERSION"
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
@@ -597,104 +574,6 @@ check_python() {
fi
}
# Probe whether $1 (a python executable) links a SQLite with the FTS5
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
# virtual tables for full-text session search; a SQLite without FTS5 makes
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
_python_has_fts5() {
"$1" - <<'PY' 2>/dev/null
import sqlite3, sys
try:
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
except Exception:
sys.exit(1)
PY
}
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
# Returns 0 if the resulting interpreter ships FTS5.
_reinstall_python_with_fts5() {
local uv_bin="$1"
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
}
_warn_no_fts5() {
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
# Install proceeds — Hermes degrades gracefully and disables only full-text
# session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
}
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
# but WHICH builds a given uv can install is baked into the uv binary's
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
#
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
# an already-current uv)
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
# this is what fixes a stale standalone uv
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
# fresh standalone uv via the official installer into a temp dir and use
# THAT to reinstall — this fixes package-manager-managed stale uv
#
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
# stale interpreter in place and the installer's later `uv python find` resolves
# to it. Keeps session search working without bundling a second SQLite or asking
# the user to do anything.
ensure_fts5() {
[ -n "${PYTHON_PATH:-}" ] || return 0
if _python_has_fts5 "$PYTHON_PATH"; then
return 0
fi
# Termux / non-uv installs have nothing to escalate.
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
# Python builds. Try to update uv in place.
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
if "$UV_CMD" self update >/dev/null 2>&1; then
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
fi
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
# a fresh standalone uv into a temp dir and use it just for the reinstall.
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
local _tmp_uv_dir _fresh_uv
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
mkdir -p "$_tmp_uv_dir"
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
_fresh_uv="$_tmp_uv_dir/uv"
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
rm -rf "$_tmp_uv_dir"
return 0
fi
fi
rm -rf "$_tmp_uv_dir"
_warn_no_fts5
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
# (which downloads PortableGit on Windows). git is required to clone the repo,
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
+8
View File
@@ -45,9 +45,13 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"ben.bartholomew@vectorize.io": "benfrank241",
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
"subw3@mail2.sysu.edu.cn": "Subway2023",
"trevin@trevinchow.com": "tmchow",
"zhipengli@thebrainly.ai": "a1245582339",
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
"david.gutowsky@gmail.com": "davidgut1982",
"drpelagik@gmail.com": "SeaXen",
"lengr@users.noreply.github.com": "LengR",
"17255546+CharZhou@users.noreply.github.com": "CharZhou",
@@ -61,6 +65,7 @@ AUTHOR_MAP = {
"524706+Twanislas@users.noreply.github.com": "Twanislas",
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
"kchuang1015@users.noreply.github.com": "kchuang1015",
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
"45688690+fujinice@users.noreply.github.com": "fujinice",
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
"195255660+EvilHumphrey@users.noreply.github.com": "EvilHumphrey",
@@ -83,6 +88,7 @@ AUTHOR_MAP = {
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
"tillfalko@gmail.com": "tillfalko",
"hi@fesalfayed.com": "fesalfayed",
"marek.les@seznam.cz": "maxcz79",
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"kenyon1977@gmail.com": "kenyonxu",
@@ -1198,6 +1204,7 @@ AUTHOR_MAP = {
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
"caojiguang@gmail.com": "caojiguang", # PR #35117 carries #31853 (weixin _api_post/_api_get wait_for)
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
# Kanban bug-fix batch salvage (May 2026)
@@ -1416,6 +1423,7 @@ AUTHOR_MAP = {
"me@simontaggart.com": "SiTaggart", # PR #35583 (docker_forward_env empty-secret .env fallback)
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
}
@@ -0,0 +1,106 @@
"""Tests for the container-context sandbox-mirror guard (#32049 follow-up).
Brian's shape-based guard (#32213) catches paths that carry the full
``/sandboxes/<backend>/<task>/home/.hermes/`` prefix. This covers the
complementary inner-container case: when file tools execute inside Docker,
the bind-mount strips that prefix and the guard sees plain ``/root/.hermes/``.
The root:root ownership on the divergent SOUL.md in #32049 confirms this
is the primary failure mode.
"""
from __future__ import annotations
import pytest
class TestClassifyContainerMirrorTarget:
def test_returns_none_without_context(self):
"""No Docker context — /root/.hermes/… must not be flagged."""
from agent.file_safety import classify_container_mirror_target
assert classify_container_mirror_target("/root/.hermes/profiles/group1/SOUL.md") is None
def test_catches_soul_md_with_context(self):
"""Primary failure mode from #32049: agent writes SOUL.md via container path."""
from agent.file_safety import classify_container_mirror_target
result = classify_container_mirror_target(
"/root/.hermes/profiles/group1/SOUL.md",
mirror_prefix="/root/.hermes",
)
assert result is not None
assert result["mirror_root"].replace("\\", "/").endswith("root/.hermes")
assert result["inner_path"] == "profiles/group1/SOUL.md"
@pytest.mark.parametrize("inner", [
"SOUL.md",
"memories/MEMORY.md",
])
def test_catches_authoritative_profile_files(self, inner):
from agent.file_safety import classify_container_mirror_target
result = classify_container_mirror_target(
f"/root/.hermes/{inner}",
mirror_prefix="/root/.hermes",
)
assert result is not None
assert result["inner_path"] == inner
def test_non_hermes_path_not_flagged(self):
"""/root/workspace/… is not .hermes state and must not be blocked."""
from agent.file_safety import classify_container_mirror_target
assert (
classify_container_mirror_target(
"/root/workspace/main.py",
mirror_prefix="/root/.hermes",
)
is None
)
class TestGetContainerMirrorWarning:
def test_warning_names_inner_path_and_bypass(self):
from agent.file_safety import get_container_mirror_warning
warn = get_container_mirror_warning(
"/root/.hermes/profiles/group1/SOUL.md",
mirror_prefix="/root/.hermes",
)
assert warn is not None
assert "profiles/group1/SOUL.md" in warn
assert "cross_profile=True" in warn
class TestOrthogonality:
"""Container-context guard catches what the shape-based guard (#32213) misses."""
def test_inner_container_path_caught_by_context_guard(self):
"""No sandboxes/ segment — shape guard passes, context guard blocks."""
from agent.file_safety import classify_container_mirror_target
path = "/root/.hermes/profiles/group1/SOUL.md"
assert classify_container_mirror_target(path) is None # no context
assert classify_container_mirror_target(path, mirror_prefix="/root/.hermes") is not None
class TestFileToolIntegration:
"""file_tools must catch the mirror path before creating DockerEnvironment."""
def test_guard_uses_current_docker_config_before_env_exists(self, monkeypatch):
import tools.file_tools as file_tools
monkeypatch.setattr(
file_tools,
"_get_container_mirror_prefix_for_task",
lambda task_id: "/root/.hermes",
)
warning = file_tools._check_cross_profile_path(
"/root/.hermes/profiles/group1/SOUL.md",
task_id="new-task",
)
assert warning is not None
assert "Sandbox-mirror write blocked" in warning
assert "profiles/group1/SOUL.md" in warning
@@ -0,0 +1,224 @@
"""Tests for the sandbox-mirror write guard in agent/file_safety.
The guard fires when a tool tries to write into the per-task mirror
directory created by a non-local terminal backend (Docker, Daytona, etc.).
Those paths look like ``/sandboxes/<backend>/<task>/home/.hermes/`` and
they accumulate divergent copies of authoritative profile state (SOUL.md,
config.yaml, memories/*.md) because the host Hermes process never reads
them. Soft guard defense in depth, NOT a security boundary.
Reference: #32049 — under ``terminal.backend: docker``, the agent's
``write_file`` / ``patch`` calls landed on the sandbox mirror of SOUL.md
while the host process kept loading the untouched authoritative file.
The agent reported success; the rule never took effect.
"""
from __future__ import annotations
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# classify_sandbox_mirror_target — pure path-shape detection
# ---------------------------------------------------------------------------
class TestClassifySandboxMirrorTarget:
def test_docker_mirror_soul_md_classified(self, tmp_path):
"""The exact path shape reported in #32049."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("# mirror copy\n")
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["target_path"] == str(target.resolve())
assert result["mirror_root"].endswith(
"sandboxes/docker/default/home/.hermes"
)
assert result["inner_path"] == "profiles/group1/SOUL.md"
@pytest.mark.parametrize(
"backend,inner",
[
("docker", "profiles/coder/memories/MEMORY.md"),
("daytona", "profiles/default/cron/jobs.json"),
("podman", ".env"),
],
)
def test_other_backends_and_inner_files_match(self, tmp_path, backend, inner):
"""The detector is backend-agnostic — sandbox-mirror shape is what matters."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / backend / "task-42" / "home" / ".hermes"
/ Path(inner)
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("x")
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["inner_path"] == inner
assert backend in result["mirror_root"]
def test_path_outside_sandbox_returns_none(self, tmp_path):
"""A plain Hermes path is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
target.parent.mkdir(parents=True)
target.write_text("# real SOUL\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_sandboxes_segment_without_home_hermes_returns_none(self, tmp_path):
"""A ``sandboxes/`` directory unrelated to Hermes-state mirroring (e.g.
the sandbox workspace itself) is not flagged."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / "docker" / "task-42" / "workspace" / "main.py"
)
target.parent.mkdir(parents=True)
target.write_text("print('hi')\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_sandboxes_segment_with_home_but_no_hermes_returns_none(self, tmp_path):
"""``sandboxes/<backend>/<task>/home/anything-not-hermes`` is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "sandboxes" / "docker" / "task-42" / "home" / ".bashrc"
)
target.parent.mkdir(parents=True)
target.write_text("alias ll='ls -la'\n")
assert classify_sandbox_mirror_target(str(target)) is None
def test_truncated_sandbox_path_returns_none(self, tmp_path):
"""``…/sandboxes/<backend>/<task>`` without ``home/.hermes/<thing>`` is not a mirror."""
from agent.file_safety import classify_sandbox_mirror_target
target = tmp_path / "sandboxes" / "docker" / "task-42"
target.mkdir(parents=True)
assert classify_sandbox_mirror_target(str(target)) is None
def test_non_existent_path_still_classifies_by_shape(self, tmp_path):
"""Detection is path-shape only — it must not require the file to exist
(the agent is about to CREATE the mirror file, that's the bug)."""
from agent.file_safety import classify_sandbox_mirror_target
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
# Parent directory exists so .resolve() doesn't strip the tail
# under strict mode, but the file itself does NOT exist.
target.parent.mkdir(parents=True)
assert not target.exists()
result = classify_sandbox_mirror_target(str(target))
assert result is not None
assert result["inner_path"] == "profiles/group1/SOUL.md"
# ---------------------------------------------------------------------------
# get_sandbox_mirror_warning — the model-facing string
# ---------------------------------------------------------------------------
class TestGetSandboxMirrorWarning:
def test_non_mirror_returns_none(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
target.parent.mkdir(parents=True)
target.write_text("# real SOUL\n")
assert get_sandbox_mirror_warning(str(target)) is None
def test_mirror_warning_names_mirror_root_and_inner_path(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("# mirror copy\n")
warn = get_sandbox_mirror_warning(str(target))
assert warn is not None
# Must name the mirror root so the user can locate the sandbox.
assert "sandboxes/docker/default/home/.hermes" in warn
# Must hint at what the agent likely meant.
assert "profiles/group1/SOUL.md" in warn
# Must name the bypass kwarg shared with the cross-profile guard.
assert "cross_profile=True" in warn
def test_warning_is_defense_in_depth_not_boundary(self, tmp_path):
from agent.file_safety import get_sandbox_mirror_warning
target = (
tmp_path
/ "sandboxes" / "docker" / "t" / "home" / ".hermes"
/ "profiles" / "g" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("x")
warn = get_sandbox_mirror_warning(str(target))
# Must self-document as defense-in-depth so future reviewers
# don't promote it to a hard block (matches the existing
# cross-profile guard's contract).
assert "not a security boundary" in warn.lower()
# ---------------------------------------------------------------------------
# Independence from cross-profile classifier
# ---------------------------------------------------------------------------
class TestSandboxMirrorIsOrthogonalToCrossProfile:
"""The sandbox-mirror guard must fire even when the inner path is
in-profile from the host's view — the bug is the mirror, not the
profile mismatch."""
def test_same_profile_mirror_still_flagged(self, tmp_path, monkeypatch):
import agent.file_safety as fs
monkeypatch.setattr(fs, "_hermes_root_path", lambda: tmp_path)
monkeypatch.setattr(fs, "_hermes_home_path", lambda: tmp_path / "profiles" / "group1")
target = (
tmp_path
/ "profiles" / "group1"
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
/ "profiles" / "group1" / "SOUL.md"
)
target.parent.mkdir(parents=True)
target.write_text("x")
# cross-profile classifier: active profile == target's inner-mirror
# profile name; on the existing detector the path's parts[2] is
# ``sandboxes``, not a scoped area, so it returns None.
assert fs.classify_cross_profile_target(str(target)) is None
# sandbox-mirror classifier: fires unconditionally on the shape.
assert fs.classify_sandbox_mirror_target(str(target)) is not None
+72 -2
View File
@@ -4,8 +4,9 @@ from unittest.mock import patch
class TestMinimaxContextLengths:
"""Verify context length entries match official docs (204,800 for all models).
"""Verify context length entries match official docs.
M2.x series is 204,800; M3 is 1M (max output 512K).
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
@@ -15,11 +16,80 @@ class TestMinimaxContextLengths:
def test_minimax_models_resolve_via_prefix(self):
from agent.model_metadata import get_model_context_length
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
# M2.x models resolve to 204,800 via the "minimax" catch-all
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
ctx = get_model_context_length(model, "")
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
def test_minimax_m3_resolves_to_1m(self):
from agent.model_metadata import get_model_context_length
# M3 must beat the generic "minimax" catch-all (204,800) and resolve to
# a 1M-class context. The exact value depends on the source: our
# hardcoded catalog says 1,000,000; the OpenRouter catalog reports
# 1,048,576 (1024²). Either is correct — assert "≥ 1M, not 204,800".
for model in ("MiniMax-M3", "minimax/minimax-m3", "minimax-m3"):
ctx = get_model_context_length(model, "")
assert ctx >= 1_000_000, f"{model} expected 1M-class, got {ctx}"
class TestMinimaxM3StaleCacheGuard:
"""Pre-catalog builds resolved M3 via the generic 'minimax' catch-all
(204,800) and persisted it before the 'minimax-m3' (1M) catalog entry
existed. The step-1 cache guard must drop that stale value and re-resolve
to 1M, while leaving correct M2.x entries (204,800) untouched.
"""
def test_suggests_minimax_m3(self):
from agent.model_metadata import _model_name_suggests_minimax_m3
assert _model_name_suggests_minimax_m3("MiniMax-M3")
assert _model_name_suggests_minimax_m3("minimax/minimax-m3")
assert not _model_name_suggests_minimax_m3("MiniMax-M2.7")
assert not _model_name_suggests_minimax_m3("MiniMax-M2.5")
def test_stale_m3_cache_dropped_and_reresolves(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
mm.save_context_length("MiniMax-M3", base, 204_800)
ctx = mm.get_model_context_length(
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
)
# Invariant: the stale 204,800 catch-all value must be DROPPED and
# re-resolved to M3's real, larger context. The exact value depends on
# the resolution source (hardcoded catalog = 1,000,000; the models.dev
# registry currently reports 512,000) — both are large-context values
# well above the generic "minimax" catch-all. Assert the contract
# ("> 204,800, stale value gone"), not a brittle literal.
assert ctx > 204_800, f"stale M3 cache not dropped/re-resolved, got {ctx}"
def test_correct_m3_cache_preserved(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
mm.save_context_length("MiniMax-M3", base, 1_000_000)
ctx = mm.get_model_context_length(
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
)
assert ctx == 1_000_000
def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import importlib
import agent.model_metadata as mm
importlib.reload(mm)
base = "https://api.minimaxi.com/anthropic"
# 204,800 is the CORRECT value for M2.x — guard must not touch it.
for slug in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"):
mm.save_context_length(slug, base, 204_800)
ctx = mm.get_model_context_length(
slug, base_url=base, api_key="", provider="minimax-cn"
)
assert ctx == 204_800, f"{slug} should stay 204800, got {ctx}"
class TestMinimaxThinkingSupport:
+23 -1
View File
@@ -927,6 +927,29 @@ class TestEnvironmentHints:
assert "Terminal backend: docker" in result
assert "inside" in result.lower()
def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
"""THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
(the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
configured = tmp_path / "workspace"
configured.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(configured))
monkeypatch.chdir(tmp_path)
_pb._clear_backend_probe_cache()
assert f"Current working directory: {configured}" in _pb.build_environment_hints()
def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
"""The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
import agent.prompt_builder as _pb
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
_pb._clear_backend_probe_cache()
assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
"""When the probe succeeds, its output must appear in the hint block."""
import agent.prompt_builder as _pb
@@ -1247,4 +1270,3 @@ class TestOpenAIModelExecutionGuidance:
# =========================================================================
+79
View File
@@ -0,0 +1,79 @@
"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
import os
from pathlib import Path
import pytest
import agent.runtime_cwd as rt
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
def _raise_oserror(*args, **kwargs):
raise OSError("cwd gone")
class TestResolveAgentCwd:
def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
monkeypatch.chdir(os.path.expanduser("~"))
assert resolve_agent_cwd() == tmp_path
def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
# The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_expands_leading_tilde(self, monkeypatch):
monkeypatch.setenv("TERMINAL_CWD", "~")
assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
# " ".strip() → "" → falsy, so the launch dir wins (not a " " path).
monkeypatch.setenv("TERMINAL_CWD", " ")
monkeypatch.chdir(tmp_path)
assert resolve_agent_cwd() == tmp_path
def test_propagates_oserror_from_getcwd(self, monkeypatch):
# The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
# The resolver must NOT swallow it — build_environment_hints owns the
# try/except OSError guard at the call site (prompt_builder.py:805).
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
with pytest.raises(OSError):
resolve_agent_cwd()
class TestResolveContextCwd:
def test_returns_dir_when_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert resolve_context_cwd() == tmp_path
def test_returns_none_when_unset(self, monkeypatch):
# Unset → None; the caller (build_context_files_prompt) then getcwds —
# the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert resolve_context_cwd() is None
def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
# Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
# guard, so a missing dir is returned (not None) — discovery just finds nothing.
missing = tmp_path / "gone"
monkeypatch.setenv("TERMINAL_CWD", str(missing))
assert resolve_context_cwd() == missing
def test_expands_leading_tilde(self, monkeypatch):
monkeypatch.setenv("TERMINAL_CWD", "~")
assert resolve_context_cwd() == Path(os.path.expanduser("~"))
def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
# " ".strip() → "" → None, so the caller getcwds for discovery rather
# than building Path(" ") and resolving garbage under the launch dir.
monkeypatch.setenv("TERMINAL_CWD", " ")
assert resolve_context_cwd() is None
+57
View File
@@ -0,0 +1,57 @@
"""Tests for agent/system_prompt.py — context-file cwd wiring."""
from types import SimpleNamespace
from unittest.mock import patch
from agent.system_prompt import build_system_prompt_parts
def _make_agent(**overrides):
base = dict(
load_soul_identity=False,
skip_context_files=False,
valid_tool_names=[],
_task_completion_guidance=False,
_tool_use_enforcement=False,
_environment_probe=False,
_kanban_worker_guidance="",
_memory_store=None,
_memory_manager=None,
model="",
provider="",
platform="",
pass_session_id=False,
session_id="",
)
base.update(overrides)
return SimpleNamespace(**base)
def _captured_context_cwd(agent):
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
captured = {}
def fake_context_files(cwd=None, skip_soul=False):
captured["cwd"] = cwd
return ""
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
):
build_system_prompt_parts(agent)
return captured["cwd"]
class TestContextFileCwd:
def test_none_when_terminal_cwd_unset(self, monkeypatch):
# Unset → None, so discovery falls back to the launch dir inside
# build_context_files_prompt (the local-CLI #19242 contract).
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert _captured_context_cwd(_make_agent()) is None
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert _captured_context_cwd(_make_agent()) == tmp_path

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