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
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
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
102 changed files with 7064 additions and 1499 deletions
+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`
@@ -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'
)
})
+35
View File
@@ -435,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()
@@ -1740,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;
@@ -1761,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}'` : ''}: ` +
@@ -3256,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())
@@ -3726,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",
+189 -19
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'
@@ -73,7 +74,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{
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 }
]
@@ -120,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>()
@@ -133,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()]
}
@@ -179,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
@@ -189,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(
@@ -236,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) => {
@@ -331,6 +451,56 @@ 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"
@@ -352,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"
@@ -536,7 +706,7 @@ function SidebarSessionsSection({
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
}
@@ -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'
@@ -77,7 +78,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
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)
}
+6 -370
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'
}
@@ -115,7 +83,7 @@ interface SectionSearchEntry {
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ 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>
+16 -10
View File
@@ -32,6 +32,8 @@ import {
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
@@ -224,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)
}
}, [])
@@ -531,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>
)}
@@ -541,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))}
/>
@@ -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 -21
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,15 +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' | 'sessions' | '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...',
sessions: 'Search archived sessions...',
tools: 'Search skills and tools...'
sessions: 'Search archived sessions...'
}
+5 -15
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, 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'
@@ -20,7 +20,6 @@ import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
@@ -29,11 +28,10 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'mcp',
'sessions',
'tools',
'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>>({
@@ -42,8 +40,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
gateway: '',
keys: '',
mcp: '',
sessions: '',
tools: ''
sessions: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -140,12 +137,6 @@ 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}
@@ -203,16 +194,15 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : activeView === 'sessions' ? (
<SessionsSettings query={queries.sessions} />
) : (
<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>
)
}
@@ -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' | 'sessions' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | '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>
)
})}
@@ -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
+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
+3 -2
View File
@@ -114,10 +114,11 @@ export class HermesGateway extends JsonRpcGatewayClient {
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude'
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)}&archived=${archived}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
})
return {
+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'
})
}
+9
View File
@@ -244,6 +244,10 @@ export interface SessionInfo {
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
@@ -471,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 {
+64
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)
# =============================================================================
@@ -15135,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
+11 -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,
),
+78
View File
@@ -1916,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
+47 -23
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
+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",
]
+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
+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
+56 -9
View File
@@ -7479,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):
@@ -8580,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:
@@ -9237,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)
@@ -9252,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:
@@ -9648,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,
)
+23
View File
@@ -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
-1
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(
+136
View File
@@ -1363,6 +1363,7 @@ async def get_sessions(
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
):
"""List sessions.
@@ -1370,12 +1371,22 @@ async def get_sessions(
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
``order`` controls pagination order: ``created`` (default, by original
start time) or ``recent`` (by latest activity across the compression
chain). ``recent`` keeps a long-running conversation on the first page
after it auto-compresses into a fresh continuation id.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
if order not in ("created", "recent"):
raise HTTPException(
status_code=400,
detail="order must be one of: created, recent",
)
try:
from hermes_state import SessionDB
db = SessionDB()
@@ -1389,6 +1400,7 @@ async def get_sessions(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
)
total = db.session_count(
min_message_count=min_message_count,
@@ -3913,6 +3925,117 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
# pattern is unconstrained — it would otherwise swallow e.g.
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
# ``GET /api/sessions/stats`` as "operate on the session with id
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
# succeed and delete the wrong row). Same story as the older
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
@app.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped see contract above)
and can prune its in-memory list directly from the request.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
return {"count": db.count_empty_sessions()}
finally:
db.close()
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
@@ -3945,6 +4068,7 @@ async def get_session_stats():
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -5792,6 +5916,7 @@ async def get_toolset_config(name: str):
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
)
from hermes_cli.config import get_env_value
@@ -5803,6 +5928,7 @@ async def get_toolset_config(name: str):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
if cat:
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
@@ -5815,6 +5941,13 @@ async def get_toolset_config(name: str):
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
providers.append({
"name": prov["name"],
"badge": prov.get("badge", ""),
@@ -5822,11 +5955,13 @@ async def get_toolset_config(name: str):
"env_vars": env_vars,
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
})
return {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
@@ -6722,6 +6857,7 @@ def mount_spa(application: FastAPI):
_BUILTIN_DASHBOARD_THEMES = [
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
+174 -6
View File
@@ -452,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,
)
@@ -3186,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"}
+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
@@ -1,209 +0,0 @@
---
name: dynamic-workflow
description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims).
version: 1.0.0
author: Hermes Agent + Teknium
license: MIT
metadata:
hermes:
tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research]
category: autonomous-ai-agents
related_skills: []
when_to_use:
- A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record)
- You want orchestration codified as a re-runnable script, not improvised turn-by-turn
- Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer
- Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked
when_not_to_use:
- Small bounded task (<~10 units) — just call the tool directly or do it inline
- Tight serial dependency (B needs A's output) — orchestration overhead is wasted
- You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead
---
# Dynamic Workflow — plan-in-code fan-out with verification
This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of
parallel subagents in one session). The mechanic worth copying is NOT "more
subagents" — it is **moving the plan, the loop, and the intermediate results
OUT of the context window and INTO a script.** Normally the agent IS the
orchestrator: every intermediate result piles into context, which is exactly
what caps you at a handful of agents. A workflow keeps only the *final verified
answer* in context; the script holds everything else.
> This skill is self-contained, but it builds on standard fan-out hygiene —
> chunk inputs to ~50-70KB per child, route structured output to files (not the
> `summary` field, which truncates under load), use delimiter-separated lines
> over JSON wrappers, and remember that a "stalled" child often completed its
> write anyway (check the filesystem before retrying). If your install has a
> `delegate-task-output-patterns` skill, load it for the detailed thresholds;
> the rules above are the load-bearing subset.
## The two orchestration-script layers (pick the right one — they are NOT interchangeable)
Hermes has no JS runtime. The "orchestration script" is one of two layers, and
the split is enforced by a real capability boundary, not a style preference:
| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch |
|---|---|---|
| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item |
| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent |
| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) |
| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` |
| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) |
| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive |
**Rule of thumb:** do the deterministic part in Layer A first (inline, in a
script), then fan out ONLY the irreducibly-LLM step via Layer B. This is
Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale.
Mixing them: a Layer-A script can write a manifest file, and you (the parent)
then read that manifest and issue a single Layer-B `delegate_task` batch.
## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints)
`delegate_task` runs **synchronously inside the parent turn**. If the user sends
a new message, hits /stop, or /new, every in-flight child is **cancelled and its
work discarded** (status `interrupted`). It does NOT run in the background, and
it does NOT survive the turn. There is no cache-resume of a half-finished fan-out.
So a "workflow" in Hermes is one of:
1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed
within a single turn. Good for minutes-long fan-out (dozens of units). The
user waits. This is what you build 90% of the time.
2. **Durable workflow (hours/days, survives interruption):** use the **kanban
swarm** (the SQLite-backed multi-agent kernel that ships with Hermes —
`hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a
`kanban-multiagent` skill, load it for the workflow). It
writes a task graph (root → parallel workers → verifier → synthesizer) into
the SQLite kanban kernel with a JSON blackboard. State persists across turns
and restarts. This is the ONLY path that matches Claude Code's "runs into
hours and days, resumes where it left off." Reach for it when the foreground
path would time out or when the user must be able to walk away.
Never promise "background, resumable, hundreds of agents over days" from a plain
`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban
swarm is the right fit. For simpler durable/out-of-turn cases there are lighter
options too: a `cronjob` one-shot or scheduled job, or a managed
`terminal(background=True, notify_on_complete=True)` process — both survive the
turn without standing up a full task graph.
## Workflow recipe (foreground)
1. **Decompose into independent units.** What is the unit — a file? an endpoint?
a source? a record? Each unit must be answerable WITHOUT the others' output
(else it's serial, not fan-out — see when_not_to_use).
2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the
manifest: list the files, extract the candidate sites, fetch the raw sources,
compute anything regex/parse can compute. Write a manifest to a **unique
per-run** directory — `/tmp/wf_<name>_<uuid>/manifest.jsonl` (one unit per
line), never a bare `/tmp/wf_<name>/` that a prior interrupted run could have
left stale outputs in. This is the "plan in code." Print the unit count and
the run dir, and stop.
3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each
child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR
~50-70KB of corpus. Look at the LARGEST unit, not the average. One
`delegate_task(tasks=[...])` call is bounded by
`delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds
of tasks internally. For larger fan-out, issue bounded waves yourself (loop:
one batch, collect, next batch) or have the user raise the config
intentionally.
4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]`
array, one task per chunk. Each task: reads its slice from the manifest,
emits delimiter-separated lines to `/tmp/wf_<name>_<uuid>/out_<i>.csv`, prints a
status word, stops. Do NOT depend on the `summary` field for content.
5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the
file count and freshness (each was written this run) so a stale or missing
output from an interrupted child isn't silently read as success — then merge
and present. The cross-cutting "whole picture" step stays on the parent — only
the per-unit work fanned out.
## The novel mechanic worth building: adversarial convergence
This is the part Hermes did NOT already have and the real reason to bother.
Claude Code's quality claim ("independent agents try to refute each other's
findings; only surviving claims surface; iterate until they converge") maps
cleanly onto `delegate_task` batch mode:
### Recipe: N independent attempts + M refuters
For a finding-quality task (security audit, "is this code path actually
vulnerable?", "does this migration preserve behavior?", a high-stakes plan):
1. **Independent attempts (round 1).** Fan out the SAME question to N children
(N=2-4) with DIFFERENT framings/angles in each `context`, so they don't
collapse to the same reasoning. Each writes its claims to
`/tmp/wf_<name>/attempt_<i>.md` as a list of discrete, individually-checkable
claims (one claim per line — atomicity is what makes refutation possible).
2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single
numbered list. Identical claims from independent attempts = higher prior;
note the agreement count per claim.
3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the
claim list and is told "your job is to BREAK these claims — for each, find the
counter-evidence (the auth check that DOES exist, the test that DOES cover it,
the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`."
Give refuters the codebase/sources, not the original attempts' reasoning.
4. **Keep only survivors.** A claim surfaces to the user only if it survived
refutation (no refuter produced valid counter-evidence). Filtered claims are
dropped, with a one-line note of why if the user asked for completeness.
5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find
adjacent issues), feed them back through one more refutation round. Stop when
a round produces no new surviving claims — that's convergence. Cap at 3 rounds
to bound cost.
This gives you the "more trustworthy than a single pass" property without a
runtime — it's just two `delegate_task` batches and a merge, structured so
disagreement is visible and unsupported claims die before they reach the user.
### Why atomic claims matter
A refuter cannot break "the auth layer has problems." It CAN break "endpoint
`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force
attempts to emit specific, located, individually-falsifiable claims or the
refutation round is theater.
## Cost discipline (this is the thing that bites)
A workflow can consume dramatically more tokens than a normal turn — that is
inherent, not a bug. Two real multipliers:
- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls.
`delegation.max_concurrent_children` only bounds *concurrency*, not *total*.
- **Hermes aux/subagent model defaults to main-model-first.** Children inherit
the parent's (often expensive reasoning) model. `delegate_task` does NOT expose
a per-task `model` or `profile` field — its per-task keys are
`{goal, context, toolsets, role}`. To run the fan-out cheaper you either route
delegation globally via `delegation` config (model/provider applied to all
children), or — for genuinely model/profile-scoped work — use cron, the kanban
swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out
is still Layer A: do the deterministic part in a script with no per-item LLM at
all.
Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove
the recipe end-to-end, report the token cost, THEN offer to run it at full scale.
Never silently fan out hundreds of children — surface the cost first and let the
user say go.
## Pitfalls
- **Writing `delegate_task` inside an `execute_code` script.** It's not in
`SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic
tools only. Fan out LLM judgment from the parent turn, not from inside a script.
- **Promising background/resumable from `delegate_task`.** It's synchronous and
turn-scoped. Durable = kanban swarm.
- **Trusting `summary` fields for content.** Route structured output to files
(Pattern 2 in delegate-task-output-patterns).
- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive
refutation by default and pollute the output. Force located, specific claims.
- **Same framing in all "independent" attempts.** They collapse to one answer and
the cross-check is worthless. Vary the angle in each child's context.
- **Fanning out a serial task.** If unit B needs unit A's output, parallelism
produces wrong/empty results. Re-check independence before fanning out.
## Verification before you call it done
- Did the deterministic pre-pass actually run, and does the manifest line-count
match the expected unit count? (`wc -l /tmp/wf_<name>/manifest.jsonl`)
- Did every fan-out child write its output file? (`ls /tmp/wf_<name>/out_*.csv`) —
remember stalled children often completed anyway (Pattern 6).
- For the verify recipe: can you point to the refuter counter-evidence for every
DROPPED claim, and confirm every SURFACED claim went through refutation?
- Did you report token cost on the scoped run before offering full scale?
@@ -0,0 +1,212 @@
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
modes on exit so focus-reporting / mouse-tracking escape sequences 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. ``_run_cleanup`` (the once-only cleanup that
runs on every catchable exit path, including ``atexit``) now emits the disable
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` gated
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
"""
import unittest
from unittest.mock import mock_open, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class _FakeStream:
def __init__(self, isatty: bool = True):
self._isatty = isatty
self.written: list[str] = []
self.flushed = 0
def isatty(self) -> bool:
return self._isatty
def write(self, s: str) -> int:
self.written.append(s)
return len(s)
def flush(self) -> None:
self.flushed += 1
class TestResetTerminalInputModes(unittest.TestCase):
def test_emits_reset_seq_on_tty_when_tui_ran(self):
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
):
cli_mod._reset_terminal_input_modes_on_exit()
written = "".join(fake.written)
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
self.assertGreaterEqual(fake.flushed, 1)
# The focus-reporting disable is the specific leak the issue reports.
self.assertIn("\x1b[?1004l", written)
def test_noop_when_tui_never_ran(self):
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
not emit terminal escape codes they never needed (review finding #1)."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", False),
patch.object(cli_mod.sys, "stdout", fake),
# Guard: must not touch the real /dev/tty either.
patch("builtins.open", mock_open()) as m_open,
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_not_called()
def test_noop_when_not_a_tty_and_no_dev_tty(self):
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
m_open = mock_open()
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", m_open),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
def test_swallows_stdout_errors(self):
cli_mod = _import_cli()
class _Boom:
def isatty(self):
raise OSError("stdout closed")
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", _Boom()),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
# Cleanup runs at process teardown — it must never raise.
cli_mod._reset_terminal_input_modes_on_exit()
def test_mark_tui_input_modes_active_sets_flag(self):
cli_mod = _import_cli()
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = False
try:
cli_mod._mark_tui_input_modes_active()
self.assertTrue(cli_mod._tui_input_modes_active)
finally:
cli_mod._tui_input_modes_active = original
def test_flag_cleared_after_reset(self):
"""Once the modes are disabled they are no longer active — the flag must
flip back so a re-armed cleanup doesn't re-emit the sequence."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = True
try:
with patch.object(cli_mod.sys, "stdout", fake):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertIn("\x1b[?1004l", "".join(fake.written))
self.assertFalse(
cli_mod._tui_input_modes_active, "flag must clear after reset"
)
finally:
cli_mod._tui_input_modes_active = original
class TestRunCleanupWiring(unittest.TestCase):
"""_run_cleanup must call the reset, as its first step, on every invocation
even if a later cleanup step raises."""
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
test is hermetic (review finding #5)."""
original_done = cli_mod._cleanup_done
cli_mod._cleanup_done = False
patches = {
"_cleanup_all_terminals": lambda: None,
"_cleanup_all_browsers": lambda: None,
}
try:
with (
patch.object(
cli_mod, "_reset_terminal_input_modes_on_exit"
) as mock_reset,
patch.object(
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
),
patch.object(
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
),
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
patch(
"agent.auxiliary_client.shutdown_cached_clients",
lambda *a, **k: None,
),
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
):
if extra_patches.get("terminals_raise"):
with patch.object(
cli_mod,
"_cleanup_all_terminals",
side_effect=RuntimeError("boom"),
):
cli_mod._run_cleanup()
else:
cli_mod._run_cleanup()
return mock_reset
finally:
cli_mod._cleanup_done = original_done
def test_run_cleanup_calls_reset(self):
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod)
mock_reset.assert_called_once()
def test_reset_runs_even_when_a_cleanup_step_raises(self):
"""The reset is the first step, so a failing teardown step can't skip
it covering the Ctrl+C / crash paths the issue is about."""
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
mock_reset.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -1,7 +1,7 @@
{
"name": "example",
"label": "Example",
"description": "Example dashboard plugin — used by test suite for auth coverage",
"description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
@@ -0,0 +1,24 @@
"""Example dashboard plugin — backend API routes (test fixture).
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
part of the bundled-plugins set; a stock hermes-agent install does not see
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
plugin discovery cache to rescan, so tests that need a stable, side-effect-
free GET endpoint to verify plugin API auth + static-asset behaviour can
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
manifest.json``) without depending on any production-facing plugin.
Mounted at /api/plugins/example/ by the dashboard plugin system.
"""
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"}
+5 -2
View File
@@ -164,9 +164,12 @@ class TestSessionResetPolicy:
class TestStreamingConfig:
def test_defaults_to_edit_transport(self):
def test_defaults_to_auto_transport(self):
# "auto" prefers native draft streaming where the platform supports
# it (Telegram DMs) and falls back to edit-based everywhere else, so
# it is safe as the global out-of-the-box default.
restored = StreamingConfig.from_dict({"enabled": "true"})
assert restored.transport == "edit"
assert restored.transport == "auto"
def test_from_dict_coerces_quoted_false_enabled(self):
restored = StreamingConfig.from_dict({"enabled": "false"})
@@ -0,0 +1,65 @@
"""Per-platform streaming defaults + dashboard exposure.
Streaming is smooth on Telegram (native sendMessageDraft) but flickers on
edit-only platforms like Discord. The shipped defaults encode that:
display.platforms.telegram.streaming=true, .discord.streaming=false. These are
gap-fillers (user values win via deep-merge) and, because the dashboard schema
is generated from DEFAULT_CONFIG, they automatically appear as editable toggles
in the web UI.
"""
from __future__ import annotations
def test_default_per_platform_streaming_flags():
from hermes_cli.config import DEFAULT_CONFIG
plats = DEFAULT_CONFIG["display"]["platforms"]
assert plats["telegram"]["streaming"] is True
assert plats["discord"]["streaming"] is False
def test_resolver_telegram_on_discord_off_when_global_enabled():
"""With global streaming on, the per-platform defaults make Telegram stream
and Discord not matching the platforms' actual streaming quality."""
from hermes_cli.config import DEFAULT_CONFIG
from gateway.display_config import resolve_display_setting
cfg = dict(DEFAULT_CONFIG)
cfg["streaming"] = {"enabled": True, "transport": "auto"}
def streams(plat):
ov = resolve_display_setting(cfg, plat, "streaming")
# global enabled; None override = follow global (True)
return True if ov is None else bool(ov)
assert streams("telegram") is True
assert streams("discord") is False
# A platform with no default entry follows the global switch.
assert streams("slack") is True
def test_user_override_wins_over_default():
"""A user who explicitly enables Discord streaming keeps their value — the
default false must not clobber it (config deep-merge: user wins)."""
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
user = {"display": {"platforms": {"discord": {"streaming": True}}}}
merged = _deep_merge(dict(DEFAULT_CONFIG), user)
assert merged["display"]["platforms"]["discord"]["streaming"] is True
# Partial override must not wipe the sibling telegram default.
assert merged["display"]["platforms"]["telegram"]["streaming"] is True
def test_dashboard_schema_exposes_per_platform_streaming():
"""Because the web settings schema is built from DEFAULT_CONFIG, the
per-platform streaming toggles surface in the dashboard automatically."""
import pytest
pytest.importorskip("fastapi") # web_server requires fastapi/uvicorn
from hermes_cli.web_server import CONFIG_SCHEMA
assert "display.platforms.telegram.streaming" in CONFIG_SCHEMA
assert "display.platforms.discord.streaming" in CONFIG_SCHEMA
assert CONFIG_SCHEMA["display.platforms.discord.streaming"]["type"] == "boolean"
# Global streaming controls are exposed too.
assert "streaming.enabled" in CONFIG_SCHEMA
assert "streaming.transport" in CONFIG_SCHEMA
+182
View File
@@ -0,0 +1,182 @@
"""Structured stream-event protocol + dispatcher behavior.
Covers the agentgateway delivery contract introduced to decouple *what
happened* (typed events) from *how it's delivered* (adapter decides). The
default BasePlatformAdapter rendering must reproduce today's behavior exactly;
an adapter may override format_tool_event to eat tool chrome on platforms that
can't render it.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from gateway.stream_dispatch import GatewayEventDispatcher
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
ToolCallChunk,
ToolCallFinished,
)
def _base_adapter():
"""A real BasePlatformAdapter instance (abstractmethods cleared) so we
exercise the genuine default render hooks, not a mock."""
from gateway.platforms.base import BasePlatformAdapter
Concrete = type("Concrete", (BasePlatformAdapter,), {})
Concrete.__abstractmethods__ = frozenset()
return Concrete.__new__(Concrete)
class _FakeSink:
def __init__(self):
self.deltas = []
self.commentary = []
self.segment_breaks = 0
def on_delta(self, text):
self.deltas.append(text)
def on_commentary(self, text):
self.commentary.append(text)
def on_segment_break(self):
self.segment_breaks += 1
# ── Message events → sink ────────────────────────────────────────────────────
def test_message_chunk_flows_to_sink_on_delta():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageChunk("hello "))
d.dispatch(MessageChunk("world"))
assert sink.deltas == ["hello ", "world"]
def test_intermediate_message_stop_breaks_segment_but_final_does_not():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageStop(final=False))
d.dispatch(MessageStop(final=True))
assert sink.segment_breaks == 1 # only the non-final stop breaks
def test_commentary_flows_to_sink():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(Commentary("I'll inspect the repo first."))
assert sink.commentary == ["I'll inspect the repo first."]
def test_message_events_dropped_when_no_sink():
# streaming disabled → no sink → message events are no-ops, no crash.
d = GatewayEventDispatcher(_base_adapter(), sink=None)
d.dispatch(MessageChunk("x")) # must not raise
# ── Tool events → progress queue, formatted by adapter ───────────────────────
def test_tool_call_chunk_renders_default_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls -la"))
assert len(lines) == 1
assert "terminal" in lines[0]
assert "ls -la" in lines[0]
def test_tool_preview_truncated_to_cap():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all", preview_max_len=10,
)
d.dispatch(ToolCallChunk(tool_name="x", preview="0123456789ABCDEF"))
# capped at 10 → 7 chars + "..." (then wrapped in quotes by the renderer)
assert '"0123456..."' in lines[0]
assert "89ABCDEF" not in lines[0]
def test_new_mode_dedups_same_tool():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="new",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="a"))
d.dispatch(ToolCallChunk(tool_name="terminal", preview="b")) # deduped
d.dispatch(ToolCallChunk(tool_name="read_file", preview="c"))
assert len(lines) == 2 # terminal once, read_file once
def test_off_mode_emits_nothing():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="off",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == []
def test_adapter_can_eat_tool_chrome():
"""An adapter that returns None from format_tool_event drops the event —
the 'iMessage can't render tool chrome' case."""
adapter = _base_adapter()
adapter.format_tool_event = lambda event, **kw: None # eat everything
lines = []
d = GatewayEventDispatcher(
adapter, _FakeSink(), enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == [] # eaten
def test_tool_finished_emits_no_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallFinished(tool_name="terminal", duration=2.0, ok=True))
assert lines == []
# ── Control events → gateway-owned hooks ─────────────────────────────────────
def test_long_tool_hint_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_long_tool=seen.append,
)
d.dispatch(LongToolHint(tool_name="terminal", duration=45.0))
assert len(seen) == 1
assert seen[0].tool_name == "terminal"
def test_gateway_notice_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_notice=seen.append,
)
d.dispatch(GatewayNotice(kind="restart", text="Gateway restarted"))
assert seen[0].kind == "restart"
def test_dispatch_swallows_render_errors():
"""A render error must never propagate into the agent worker thread."""
adapter = _base_adapter()
def _boom(event, sink):
raise RuntimeError("render blew up")
adapter.render_message_event = _boom
d = GatewayEventDispatcher(adapter, _FakeSink())
d.dispatch(MessageChunk("x")) # must not raise
@@ -0,0 +1,114 @@
"""TelegramAdapter.send_draft MarkdownV2 formatting parity.
Bot API 9.5 ``sendMessageDraft`` powers the animated streaming preview in
DMs. The regular ``send`` path renders with MarkdownV2, so the draft must
too otherwise the live preview streams as raw text and the final
``sendMessage`` snaps into formatted output, producing a jarring visual
shift at the end of the response (reported by an external user, May 2026).
These tests pin:
1. The happy path passes ``parse_mode=MARKDOWN_V2`` with format_message'd
text (formatting parity with the final message).
2. A MarkdownV2 BadRequest triggers a single plain-text retry rather than
killing draft streaming for the whole response.
3. A non-BadRequest failure propagates so the caller falls back to edit.
"""
import sys
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from gateway.platforms import telegram as tg_mod # noqa: E402
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
def _make_adapter() -> TelegramAdapter:
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
adapter._bot = MagicMock()
adapter._bot.send_message_draft = AsyncMock(return_value=True)
return adapter
@pytest.mark.asyncio
async def test_send_draft_passes_markdownv2_parse_mode():
"""Happy path: draft is sent with parse_mode set and format_message'd text."""
adapter = _make_adapter()
# Make format_message observable and deterministic.
adapter.format_message = lambda c: f"FMT::{c}"
result = await adapter.send_draft("123", 7, "**bold** body")
assert result.success is True
adapter._bot.send_message_draft.assert_awaited_once()
kwargs = adapter._bot.send_message_draft.await_args.kwargs
assert kwargs["text"] == "FMT::**bold** body"
assert kwargs["parse_mode"] is tg_mod.ParseMode.MARKDOWN_V2
assert kwargs["chat_id"] == 123
assert kwargs["draft_id"] == 7
@pytest.mark.asyncio
async def test_send_draft_falls_back_to_plain_text_on_markdownv2_error():
"""A MarkdownV2 BadRequest retries once as plain text (no parse_mode),
instead of aborting draft streaming for the whole response."""
adapter = _make_adapter()
adapter.format_message = lambda content: f"FMT::{content}"
# Resolve the BadRequest type the adapter checks via _is_bad_request_error.
from telegram.error import BadRequest # type: ignore
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
if "parse_mode" in kwargs:
raise BadRequest("can't parse entities")
return True
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 9, "weird _text")
assert result.success is True
# First attempt: MarkdownV2; second attempt: plain text, no parse_mode.
assert len(calls) == 2
assert "parse_mode" in calls[0]
assert "parse_mode" not in calls[1]
assert calls[1]["text"] == "weird _text" # raw, unformatted
@pytest.mark.asyncio
async def test_send_draft_non_badrequest_propagates_without_retry():
"""A non-BadRequest failure (e.g. drafts not allowed) returns failure
immediately so the caller falls back to the edit transport."""
adapter = _make_adapter()
adapter.format_message = lambda c: f"FMT::{c}"
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
raise RuntimeError("drafts disabled for this chat")
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 11, "hi")
assert result.success is False
assert len(calls) == 1 # no plain-text retry on non-BadRequest
+36
View File
@@ -39,6 +39,42 @@ def mock_args():
return SimpleNamespace()
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@@ -192,6 +192,95 @@ class TestApi401Envelope:
assert "next=" not in body["login_url"]
class TestTransparentRefreshOnAccessTokenEviction:
"""Regression: an expired access token whose cookie the browser has
ALREADY EVICTED must still transparently refresh via the RT cookie
not bounce to /login.
This is the common-path expiry bug, not an edge case. The access-token
cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
the browser deletes ``hermes_session_at`` the instant the token lapses,
while ``hermes_session_rt`` lives for 30 days. From that moment the
browser sends ONLY the refresh-token cookie. The original gate bailed at
``if not at: return _unauth_response(...)`` bouncing the user to
/login on every single expiry despite holding a perfectly good refresh
token, defeating the entire transparent-refresh feature. The fix lets a
request carrying only the RT flow into the refresh path.
Discrimination: under the pre-fix code, scenario 1 (AT cookie absent,
RT present) returned 401/302 to login with NO rotated cookies and NO
REFRESH_SUCCESS the refresh code never ran. With the fix it returns
200 and rotates both cookies.
"""
def _build_rt_only_app(self):
"""Gate over the real app with a Stub provider whose RT is live
(default_ttl>0 so refresh succeeds). Mint a valid signed RT
directly (the stub's refresh_session only checks the RT's
signature + exp), then send ONLY that RT cookie.
"""
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
clear_providers()
provider = StubAuthProvider(default_ttl=900)
register_provider(provider)
valid_rt = _sign(
{"sub": "stub-user-1", "kind": "refresh", "exp": int(_t.time()) + 30 * 86400}
)
return provider, valid_rt
def test_at_evicted_rt_present_refreshes_transparently(self, gated_app):
provider, valid_rt = self._build_rt_only_app()
# Browser sends ONLY the RT cookie — the AT cookie has aged out.
gated_app.cookies.clear()
gated_app.cookies.set(SESSION_RT_COOKIE, valid_rt)
r = gated_app.get("/api/sessions", follow_redirects=False)
# Transparent refresh — request served, NOT bounced.
assert r.status_code == 200, (
f"expected 200 (transparent refresh) got {r.status_code} "
f"— the AT-evicted/RT-present case bounced to login"
)
# Both cookies rotated onto the response.
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(SESSION_AT_COOKIE) or f"-{SESSION_AT_COOKIE}" in c
for c in set_cookies
), f"no rotated AT cookie in {set_cookies!r}"
assert any(
c.startswith(SESSION_RT_COOKIE) or f"-{SESSION_RT_COOKIE}" in c
for c in set_cookies
), f"no rotated RT cookie in {set_cookies!r}"
def test_no_cookies_at_all_still_bounces(self, gated_app):
"""Guard the fix didn't over-reach: a request with NEITHER cookie
must still 401 to login (nothing to verify or refresh)."""
self._build_rt_only_app()
gated_app.cookies.clear()
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "unauthenticated"
def test_dead_rt_only_bounces_to_login(self, gated_app):
"""An RT-only request whose RT is dead/expired must bounce (the
refresh raises RefreshExpiredError clear + relogin), not 500."""
clear_providers()
# default_ttl=0 → the stub treats the minted RT as born-expired,
# so refresh_session raises RefreshExpiredError.
provider = StubAuthProvider(default_ttl=0)
register_provider(provider)
gated_app.cookies.clear()
# A syntactically-real but expired RT (signed with exp<=now).
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
dead_rt = _sign({"sub": "u", "kind": "refresh", "exp": int(_t.time()) - 1})
gated_app.cookies.set(SESSION_RT_COOKIE, dead_rt)
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "session_expired"
class TestHtmlRedirectNext:
def test_deep_html_path_redirects_with_next(self, gated_app):
r = gated_app.get("/sessions", follow_redirects=False)
+206
View File
@@ -0,0 +1,206 @@
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
from __future__ import annotations
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_executable(path: Path) -> None:
"""Create a minimal fake uv binary at *path*."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
# ---------------------------------------------------------------------------
# managed_uv_path
# ---------------------------------------------------------------------------
class TestManagedUvPath:
def test_posix(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv"
def test_windows(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
# ---------------------------------------------------------------------------
# resolve_uv
# ---------------------------------------------------------------------------
class TestResolveUv:
def test_missing_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
def test_existing_executable(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
result = resolve_uv()
assert result == str(tmp_path / "bin" / "uv")
def test_non_executable_file_returns_none(self, tmp_path):
uv = tmp_path / "bin" / "uv"
uv.parent.mkdir(parents=True)
uv.write_text("not a binary")
# Ensure no execute bit
uv.chmod(0o644)
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
# ---------------------------------------------------------------------------
# ensure_uv
# ---------------------------------------------------------------------------
class TestEnsureUv:
def test_already_installed_no_bootstrap(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv") as mock_install:
# Simulate the installer creating the binary
def fake_install(target):
_make_executable(target)
mock_install.side_effect = fake_install
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is True
mock_install.assert_called_once()
def test_install_failure_returns_none_false(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path is None
assert fresh is False
# ---------------------------------------------------------------------------
# rebuild_venv
# ---------------------------------------------------------------------------
class TestRebuildVenv:
def test_removes_old_venv_and_creates_new(self, tmp_path):
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "old_file").write_text("stale")
uv_bin = str(tmp_path / "bin" / "uv")
def fake_run(cmd, **kwargs):
m = MagicMock(returncode=0)
if cmd[1] == "venv":
# Simulate uv creating the venv dir
venv_dir.mkdir(exist_ok=True)
bin_dir = venv_dir / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
(bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0")
elif "--version" in cmd:
m.stdout = "Python 3.11.0"
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is True
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True)
def test_rebuild_failure_returns_false(self, tmp_path):
venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
patch("hermes_cli.managed_uv.shutil.rmtree"):
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# ---------------------------------------------------------------------------
# update_managed_uv
# ---------------------------------------------------------------------------
class TestUpdateManagedUv:
def test_no_uv_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import update_managed_uv
assert update_managed_uv() is None
def test_self_update_success(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
# uv self update succeeds
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
assert result == str(tmp_path / "bin" / "uv")
# First call is self update, second is --version
assert mock_run.call_count == 2
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
def test_self_update_failure_non_fatal(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
# Still returns the path — failure is non-fatal
assert result == str(tmp_path / "bin" / "uv")
# ---------------------------------------------------------------------------
# _install_uv internals
# ---------------------------------------------------------------------------
class TestInstallUvInternals:
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
target = tmp_path / "bin" / "uv"
with patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \
patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_posix.assert_called_once()
call_env = mock_posix.call_args[0][0]
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
def test_windows_sets_uv_install_dir(self, tmp_path):
target = tmp_path / "bin" / "uv.exe"
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_windows.assert_called_once()
call_env = mock_windows.call_args[0][0]
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
@@ -0,0 +1,96 @@
"""Regression tests for two OpenAI/OpenRouter model-picker bugs.
Bug 1 OpenAI picker dumped the raw ``/v1/models`` catalog
``provider_model_ids("openai")`` hit ``api.openai.com/v1/models`` and
returned the full 120+ entry catalog (embeddings, whisper, tts, dall-e,
moderation, gpt-3.5, ). The ``hermes model`` CLI shows only the curated
agentic list. The picker now intersects the live default-endpoint catalog
with the curated list (preserving curated order) so both surfaces match.
Custom OpenAI-compatible endpoints (proxies, gateways) keep the live list
verbatim so discovery still works.
Bug 2 OpenRouter appeared authenticated whenever OPENAI_API_KEY was set
OpenRouter's HermesOverlay carried ``extra_env_vars=("OPENAI_API_KEY",)``.
``list_authenticated_providers`` reads ``extra_env_vars`` to decide whether
a provider has credentials, so any OpenAI user saw a phantom OpenRouter
row. The overlay entry is removed; runtime credential resolution still
falls back to OPENAI_API_KEY for explicitly-selected OpenRouter (handled
in runtime_provider.py, independent of the overlay).
"""
import os
from unittest.mock import patch
import pytest
from hermes_cli import models as M
from hermes_cli.providers import HERMES_OVERLAYS
# --- Bug 2: overlay no longer lists OPENAI_API_KEY --------------------------
def test_openrouter_overlay_does_not_list_openai_api_key():
overlay = HERMES_OVERLAYS["openrouter"]
assert "OPENAI_API_KEY" not in overlay.extra_env_vars
# --- Bug 1: default OpenAI endpoint filters to curated agentic models -------
def test_default_openai_endpoint_filters_to_curated(monkeypatch):
"""The 126-model /v1/models dump is intersected with the curated list."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
# Live catalog: every curated model PLUS a pile of non-agentic junk.
live = list(curated) + [
"text-embedding-3-large", "whisper-1", "tts-1", "dall-e-3",
"gpt-3.5-turbo", "davinci-002", "omni-moderation-latest",
]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
# Only curated models survive, in curated order, no junk.
assert result == list(curated)
for m in result:
assert m in curated
def test_default_openai_endpoint_intersects_account_access(monkeypatch):
"""Curated models the account can't access are dropped (intersection)."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
# Account only serves the first two curated models.
live = list(curated[:2]) + ["text-embedding-3-large", "whisper-1"]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
assert result == list(curated[:2])
def test_default_openai_endpoint_falls_back_when_no_curated_access(monkeypatch):
"""If the account serves none of the curated models, fall back to curated."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
live = ["text-embedding-3-large", "whisper-1", "tts-1"] # all junk
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
# No curated overlap -> serve the curated defaults so the picker isn't empty.
assert result == list(curated)
def test_custom_openai_compatible_endpoint_keeps_live_list(monkeypatch):
"""Custom OPENAI_BASE_URL endpoints keep the live catalog verbatim."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.setenv("OPENAI_BASE_URL", "https://my-proxy.example.com/v1")
live = ["custom-model-a", "custom-model-b", "some-embedding-model"]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
assert result == live
+36
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from subprocess import CalledProcessError
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -8,6 +9,41 @@ from hermes_cli import config as hermes_config
from hermes_cli import main as hermes_main
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
calls = []
+36
View File
@@ -20,6 +20,42 @@ from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
# ---------------------------------------------------------------------------
# is_uv_tool_install
# ---------------------------------------------------------------------------
+464 -7
View File
@@ -2,6 +2,7 @@
import os
import json
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
@@ -14,6 +15,97 @@ from hermes_cli.config import (
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
# Path to the test-only example-dashboard plugin. Lives under
# tests/fixtures/ so the bundled-plugins directory stays clean — stock
# installs no longer ship a dummy "Example" sidebar tab. Tests that
# depend on its routes opt in via the `_install_example_plugin` fixture
# below.
_EXAMPLE_PLUGIN_FIXTURE = (
Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard"
)
@pytest.fixture
def _install_example_plugin(_isolate_hermes_home):
"""Drop the example-dashboard fixture into the per-test HERMES_HOME
user-plugins directory and force the web_server's dashboard plugin
cache + API mount to rediscover it.
The plugin used to live under ``<repo>/plugins/example-dashboard/``
and was loaded for every install, putting an "Example" tab in every
user's sidebar. It is now a tests-only fixture: any test that needs
``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...``
requests this fixture so the plugin appears only for that test's
isolated ``HERMES_HOME``.
The user-plugin source is preferred over a transient
``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is
resolved per-call (other tests in the suite implicitly rely on the
real bundled plugins kanban, hermes-achievements, model providers
being available, and globally swapping that root would yank them
all). User plugins are first in the discovery search order, so
laying down the fixture here is enough.
"""
from hermes_constants import get_hermes_home
from hermes_cli import web_server
user_plugins_dir = get_hermes_home() / "plugins"
user_plugins_dir.mkdir(parents=True, exist_ok=True)
dst = user_plugins_dir / "example-dashboard"
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
# Snapshot the existing routes BEFORE mounting so we can:
# 1. Identify the routes the mount call appends.
# 2. Restore the original list on teardown — otherwise leftover
# ``/api/plugins/example/*`` routes leak into subsequent tests
# and start serving requests against a torn-down HERMES_HOME.
app = web_server.app
original_routes = list(app.router.routes)
# Bust the module-level cache and re-discover so the example plugin
# shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes`
# imports the plugin's `plugin_api.py` and ``include_router``s its
# FastAPI router under ``/api/plugins/example/*``. The static-asset
# route at ``/dashboard-plugins/<name>/<path>`` reads the plugins
# list dynamically per request, so the rescan alone is enough for
# the static-asset tests; the API auth tests additionally need the
# route reorder below.
web_server._dashboard_plugins_cache = None
web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# ``include_router`` appends the new routes to the END of
# ``app.router.routes``. That works fine at import time — the SPA
# catch-all ``mount_spa(app)`` registers AFTER the initial mount
# call — but when we mount mid-flight the catch-all is already in
# place, so the new ``/api/plugins/example/*`` route loses the
# match-order race and we get a 404. Move the newly-appended routes
# to the front of the list so FastAPI matches them first. They're
# path-prefixed to ``/api/plugins/example/`` and can't shadow
# anything else.
new_routes = [r for r in app.router.routes if r not in original_routes]
for route in new_routes:
app.router.routes.remove(route)
for offset, route in enumerate(new_routes):
app.router.routes.insert(offset, route)
try:
yield
finally:
# Restore the original route list — drops the example plugin's
# routes so the next test sees a clean app — and clear the
# cache for the same reason.
app.router.routes[:] = original_routes
web_server._dashboard_plugins_cache = None
# ---------------------------------------------------------------------------
# reload_env tests
# ---------------------------------------------------------------------------
@@ -293,6 +385,49 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
def test_get_sessions_rejects_unknown_order_value(self):
resp = self.client.get("/api/sessions?order=sideways")
assert resp.status_code == 400
def test_get_sessions_order_recent_surfaces_compression_tip(self):
"""A long-running conversation that auto-compresses must stay on the
first page by recency, listed under its live continuation id."""
import time as _time
from hermes_state import SessionDB
db = SessionDB()
try:
old = _time.time() - 86_400
# Old conversation that later compresses into a fresh continuation.
# The continuation must start at/after the parent's ended_at to be
# recognised as a compression tip (not a sub-agent/branch).
db.create_session(session_id="root-old", source="cli")
db.append_message(session_id="root-old", role="user", content="kickoff")
db.end_session("root-old", "compression")
db._conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?",
(old, old + 10, "root-old"),
)
db.create_session(session_id="tip-new", source="cli", parent_session_id="root-old")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old + 10, "tip-new"))
db.append_message(session_id="tip-new", role="user", content="continued just now")
# A brand-new unrelated session started after the root but before now.
db.create_session(session_id="mid", source="cli")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (_time.time() - 3600, "mid"))
db.append_message(session_id="mid", role="user", content="hello")
db._conn.commit()
finally:
db.close()
rows = self.client.get("/api/sessions?order=recent&limit=5").json()["sessions"]
ids = [r["id"] for r in rows]
# The compressed conversation surfaces under its live tip id...
assert "tip-new" in ids
# ...carrying the durable lineage root so the desktop can match pins.
tip = next(r for r in rows if r["id"] == "tip-new")
assert tip.get("_lineage_root_id") == "root-old"
def test_get_sessions_archived_is_boolean(self):
from hermes_state import SessionDB
@@ -1523,13 +1658,52 @@ class TestNewEndpoints:
assert data["has_category"] is True
assert isinstance(data["providers"], list)
assert data["providers"], "tts always has at least the built-in providers"
# active_provider is part of the contract so the GUI can highlight the
# provider actually written to config (else it falls back to the first
# keyless one). It's either None or the name of one listed provider.
assert "active_provider" in data
names = {p["name"] for p in data["providers"]}
assert data["active_provider"] is None or data["active_provider"] in names
for prov in data["providers"]:
assert "name" in prov
assert "is_active" in prov
assert "env_vars" in prov
assert isinstance(prov["env_vars"], list)
for ev in prov["env_vars"]:
assert "key" in ev
assert "is_set" in ev
# active_provider summarizes the first provider flagged is_active
# (some catalogs list two rows backed by the same config value, e.g.
# Firecrawl cloud + self-hosted both map to web.backend=firecrawl).
active = [p["name"] for p in data["providers"] if p["is_active"]]
if active:
assert data["active_provider"] == active[0]
else:
assert data["active_provider"] is None
def test_get_toolset_config_reflects_selected_provider(self):
"""Selecting a provider is reflected in the next /config read.
Regression: the GUI's provider panel highlighted the first keyless
provider on relaunch because /config never reported which provider was
actually active. After selecting one, is_active / active_provider must
point at it.
"""
sel = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted"},
)
assert sel.status_code == 200
resp = self.client.get("/api/tools/toolsets/web/config")
assert resp.status_code == 200
data = resp.json()
assert data["active_provider"] == "Firecrawl Self-Hosted"
active = [p["name"] for p in data["providers"] if p["is_active"]]
# The first active row is what the GUI highlights; it must be the
# selected provider.
assert active, "expected at least one provider flagged active"
assert active[0] == "Firecrawl Self-Hosted"
def test_get_toolset_config_no_category_toolset(self):
"""A toolset without a TOOL_CATEGORIES entry returns has_category False."""
@@ -2469,12 +2643,284 @@ class TestNormaliseThemeExtensions:
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
Locks in four things:
1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the
templated ``/api/sessions/{session_id}`` route below it (see
the block comment in ``hermes_cli/web_server.py``).
2. Behaviour parity with :meth:`SessionDB.delete_sessions` real
deleted count, archive/active sessions deleted on explicit
selection.
3. The 500-ID payload cap is enforced.
4. Auth gating (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def test_requires_auth(self):
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
assert resp.status_code == 401
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
self._seed(["a", "b", "c"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": ["a", "b"]}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("a") is None
assert db.get_session("b") is None
assert db.get_session("c") is not None
finally:
db.close()
def test_unknown_ids_silently_skipped(self):
"""The endpoint never 404s on a missing ID — it returns the
real deleted count so a UI selection that raced against
another tab still resolves cleanly."""
self._seed(["real"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": ["real", "ghost1", "ghost2"]},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 1}
def test_empty_list_is_noop(self):
"""``ids: []`` returns ``deleted: 0`` (200, not 400) — the UI
treats an empty selection as a no-op rather than an error."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_payload_cap_enforced(self):
"""501 IDs returns 400 — a hard cap stops a runaway selection
from holding the SQLite writer for an extended window."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(501)]},
)
assert resp.status_code == 400
# 500 exactly still succeeds (no rows actually present, so
# deleted=0 — but it's not the cap path).
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(500)]},
)
assert resp.status_code == 200
def test_route_order_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``POST /api/sessions/bulk-delete``
must hit the bulk handler, not be re-interpreted via the
templated ``/api/sessions/{session_id}`` family. Concretely the
response carries our ``ok`` + ``deleted`` keys."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
body = resp.json()
assert body.get("ok") is True
assert "deleted" in body, (
"If this assertion fails, /api/sessions/bulk-delete is "
"being shadowed by /api/sessions/{session_id} — check "
"registration order in hermes_cli/web_server.py."
)
class TestDeleteEmptySessionsEndpoint:
"""Tests for ``GET /api/sessions/empty/count`` and
``DELETE /api/sessions/empty`` the bulk-delete endpoints backing
the dashboard's "Delete empty" button.
Locks in three things the implementation has to get right:
1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths
must shadow the templated ``/api/sessions/{session_id}`` route
above them. A regression here would route ``DELETE /api/sessions/
empty`` to the single-session handler with ``session_id="empty"``
(which 404s instead of bulk-deleting).
2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`:
active sessions and archived sessions are both preserved.
3. Auth gating: both routes require the session token like every
other ``/api/*`` endpoint (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self):
"""Build the standard test corpus:
* ``empty1`` / ``empty2`` ended, no messages should delete
* ``hasmsg`` ended, has one message must survive
* ``live`` un-ended, empty must survive (active)
* ``archived`` ended, empty, archived must survive
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="hello")
db.end_session("hasmsg", end_reason="done")
db.create_session(session_id="live", source="cli")
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
finally:
db.close()
def test_count_endpoint_requires_auth(self):
"""GET /api/sessions/empty/count must 401 without the session token."""
resp = self.client.get("/api/sessions/empty/count")
assert resp.status_code == 401
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
Regression guard for issue #19533 — the bulk-delete is a strictly
destructive primitive, the middleware must gate it even if a
future refactor introduces a non-auth path."""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code == 401
def test_count_returns_only_empty_ended_unarchived(self):
"""With the standard corpus, the count is exactly 2 — only
``empty1`` and ``empty2`` qualify (``hasmsg`` has a message,
``live`` is active, ``archived`` is archived)."""
self._seed()
resp = self.auth_client.get("/api/sessions/empty/count")
assert resp.status_code == 200
assert resp.json() == {"count": 2}
def test_delete_returns_count_and_removes_only_empties(self):
"""DELETE returns the deleted count and removes only the
empty-ended-unarchived rows same shape contract as the
DB-level method's unit tests."""
from hermes_state import SessionDB
self._seed()
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
# Survivors: hasmsg has a message, live is active, archived
# is archived. All three must still be there.
assert db.get_session("hasmsg") is not None
assert db.get_session("live") is not None
assert db.get_session("archived") is not None
# And the count endpoint now reports 0.
assert db.count_empty_sessions() == 0
finally:
db.close()
def test_delete_with_no_empties_returns_zero(self):
"""No empty sessions → endpoint returns ``deleted: 0`` (200,
not 404). The dashboard relies on this no-op path to surface
a "Nothing to clean up" toast instead of an error."""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_route_order_empty_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``DELETE /api/sessions/empty``
must hit the bulk handler, not the templated single-session
handler (which would 404 because no session has id 'empty').
Concretely: a request against the bulk path on an EMPTY corpus
returns ``{ok: True, deleted: 0}``. If the templated route were
winning, we'd see 404 ("Session not found") instead.
"""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
body = resp.json()
assert "deleted" in body, (
"If this assertion fails, the literal /api/sessions/empty "
"route is being shadowed by the templated /api/sessions/"
"{session_id} route — check registration order in "
"hermes_cli/web_server.py."
)
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create a TestClient without the session token header."""
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
"""
try:
from starlette.testclient import TestClient
except ImportError:
@@ -2499,10 +2945,12 @@ class TestPluginAPIAuth:
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Use ``/api/plugins/example/hello`` from the example-dashboard plugin
a stable, side-effect-free GET that's always loaded in tests. With a
valid token the handler should run (200); without one the middleware
should 401 before the handler is reached.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) a stable, side-effect-free
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
@@ -3054,7 +3502,16 @@ class TestDashboardPluginStaticAssetAllowlist:
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient and install the example-dashboard fixture.
The static-asset allowlist tests need a plugin to point at
they verify that ``/dashboard-plugins/example/manifest.json``
is served while ``plugin_api.py`` and ``__pycache__/*.pyc``
from the same directory are not. Since the example plugin is
no longer bundled, ``_install_example_plugin`` lays it down in
the per-test ``HERMES_HOME`` user-plugins dir.
"""
try:
from starlette.testclient import TestClient
except ImportError:
@@ -542,7 +542,12 @@ class TestCompleteLogin:
def test_happy_path_returns_session(self, provider, rsa_keypair):
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "Bearer"}
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_initial_value",
},
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
@@ -555,11 +560,29 @@ class TestCompleteLogin:
assert session.user_id == "usr_abc"
assert session.provider == "nous"
assert session.access_token == access_token
assert session.refresh_token == "" # contract V1
# The dashboard auth-code grant now issues a refresh token (NAS #293);
# complete_login must surface it so the middleware persists it.
assert session.refresh_token == "rt_initial_value"
assert session.org_id == "org_xyz"
assert session.email == ""
assert session.display_name == ""
def test_happy_path_tolerates_missing_refresh_token(self, provider, rsa_keypair):
# If Portal omits refresh_token (older deploy), the session is still
# valid as access-token-only; refresh_token defaults to "".
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "Bearer"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="abc",
state="state-val",
code_verifier="vfy",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
assert session.refresh_token == ""
def test_400_raises_invalid_code(self, provider):
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
@@ -730,24 +753,90 @@ class TestVerifySession:
# ---------------------------------------------------------------------------
# refresh_session + revoke_session (V1 contract: trivial)
# refresh_session + revoke_session
# ---------------------------------------------------------------------------
class TestRefreshAndRevoke:
@pytest.fixture
def provider(self):
return nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst1", portal_url="https://portal.example.com"
def provider(self, rsa_keypair):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst123", portal_url="https://portal.example.com"
)
_patched_jwks(p, rsa_keypair)
return p
def test_refresh_always_raises(self, provider):
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="anything")
def _mock_post(self, status_code, body, *, ctype="application/json"):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if isinstance(body, dict):
resp.text = json.dumps(body)
resp.json = MagicMock(return_value=body)
else:
resp.text = body
resp.json = MagicMock(side_effect=ValueError("not json"))
resp.headers = {"content-type": ctype}
return resp
def test_refresh_raises_even_with_empty_token(self, provider):
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="")
def test_refresh_happy_path_returns_rotated_session(self, provider, rsa_keypair):
# Portal returns a fresh access token AND a rotated refresh token.
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_rotated_value",
},
)
with patch(
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
) as mock_post:
session = provider.refresh_session(refresh_token="rt_old_value")
assert isinstance(session, Session)
assert session.access_token == access_token
# The ROTATED refresh token must be surfaced so the middleware can
# persist it back to the cookie.
assert session.refresh_token == "rt_rotated_value"
assert session.provider == "nous"
# Posts grant_type=refresh_token with the RT in BOTH the body (Portal's
# schema requires it there) and the X-Refresh-Token header (log
# redaction). Verified against the live preview deploy.
_, kwargs = mock_post.call_args
assert kwargs["data"]["grant_type"] == "refresh_token"
assert kwargs["data"]["client_id"] == "agent:inst123"
assert kwargs["data"]["refresh_token"] == "rt_old_value"
assert kwargs["headers"]["x-nous-refresh-token"] == "rt_old_value"
def test_refresh_400_raises_refresh_expired(self, provider):
# Expired / revoked / reuse-detected RT → Portal 400 → force re-login.
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(RefreshExpiredError, match="invalid_grant"):
provider.refresh_session(refresh_token="rt_dead")
def test_refresh_empty_token_raises_refresh_expired_without_network(self, provider):
# No RT present — fail fast as a dead session, never hit the network.
with patch("plugins.dashboard_auth.nous.httpx.post") as mock_post:
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="")
mock_post.assert_not_called()
def test_refresh_network_error_raises_provider_error(self, provider):
with patch(
"plugins.dashboard_auth.nous.httpx.post",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(ProviderError, match="unreachable"):
provider.refresh_session(refresh_token="rt_x")
def test_refresh_500_raises_provider_error(self, provider):
mock_resp = self._mock_post(500, "oops", ctype="text/plain")
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError):
provider.refresh_session(refresh_token="rt_x")
def test_revoke_is_noop(self, provider):
# Must not raise; returns None implicitly.
+237
View File
@@ -1523,6 +1523,243 @@ class TestDeleteSessionOrphansChildren:
assert grandchild["parent_session_id"] == "child"
class TestBulkDeleteSessions:
"""``delete_sessions(ids)`` — the bulk-delete primitive backing the
sessions-page "Delete N selected" button. Per-row contract matches
:meth:`SessionDB.delete_session` (children orphaned, not cascade-
deleted), but applied across the whole list in one transaction.
Invariants this class locks in:
1. Returns the real deleted count (existing intersection), not
just ``len(session_ids)`` selection state in the UI can race
against another tab's delete.
2. Unknown IDs are silently skipped, never raise.
3. ``message_count > 0`` sessions are deleted too unlike
``delete_empty_sessions``, the user explicitly picked them, so
we trust the selection.
4. Live (un-ended) and archived sessions ARE deleted on explicit
selection (no bulk-sweep safety guards apply when the user
hand-picks the row).
5. Children of any deleted parent are orphaned, even when the
parent is mid-list.
6. ``[]`` / ``None``-laden lists are safe no-ops.
"""
def test_deletes_listed_sessions(self, db):
db.create_session(session_id="a", source="cli")
db.append_message("a", role="user", content="hi")
db.create_session(session_id="b", source="cli")
db.create_session(session_id="c", source="cli")
deleted = db.delete_sessions(["a", "b"])
assert deleted == 2
assert db.get_session("a") is None
assert db.get_session("b") is None
# Unlisted survives.
assert db.get_session("c") is not None
def test_returns_real_count_skipping_unknown_ids(self, db):
"""Unknown IDs are silently skipped — the return value reflects
what was *actually* deleted, so the UI can show an accurate
toast even if the selection raced against another tab."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "ghost1", "ghost2"])
assert deleted == 1
assert db.get_session("real") is None
def test_empty_list_is_noop(self, db):
"""``[]`` returns 0 without touching the DB. Guards against a
bulk endpoint with an empty payload triggering an
unconditional 'wipe everything' if the caller forgets the
WHERE clause."""
db.create_session(session_id="keep", source="cli")
assert db.delete_sessions([]) == 0
assert db.get_session("keep") is not None
def test_drops_non_string_entries(self, db):
"""Stray ``None`` / empty strings in the input list are
filtered out before hitting SQL. Callers may pull selection IDs
from a Set-like that occasionally contains noise; we don't want
a SQL parameter-type error to fail the whole batch."""
db.create_session(session_id="real", source="cli")
# noinspection PyTypeChecker
deleted = db.delete_sessions(["real", None, "", "ghost"]) # type: ignore[list-item]
assert deleted == 1
assert db.get_session("real") is None
def test_dedupes_duplicate_ids(self, db):
"""The same ID listed twice counts as one deletion. Defends
against a hand-crafted POST body or a UI bug that double-adds
the same selection."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "real"])
assert deleted == 1
def test_orphans_children_of_deleted_parents(self, db):
"""Bulk-deleting a parent leaves its children alive but
re-parented to NULL. Same contract as the single-session
:meth:`delete_session` path."""
db.create_session(session_id="parent", source="cli")
db.create_session(
session_id="child", source="cli", parent_session_id="parent"
)
deleted = db.delete_sessions(["parent"])
assert deleted == 1
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_deletes_archived_and_active_when_selected(self, db):
"""Unlike the safety-gated ``delete_empty_sessions`` sweep,
explicit bulk-select trusts the user archived sessions and
un-ended live sessions are both deleted when in the list.
Otherwise the selection UI would silently 'leak' rows the user
thought they'd removed."""
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
db.create_session(session_id="live", source="cli")
deleted = db.delete_sessions(["archived", "live"])
assert deleted == 2
assert db.get_session("archived") is None
assert db.get_session("live") is None
def test_cleans_up_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, on-disk transcripts are
swept as part of the bulk operation mirrors the per-row
:meth:`delete_session(sessions_dir=...)` behaviour so the
bulk-delete CLI / web flows don't leak files."""
db.create_session(session_id="s1", source="cli")
db.create_session(session_id="s2", source="cli")
(tmp_path / "s1.jsonl").write_text("")
(tmp_path / "s2.json").write_text("{}")
deleted = db.delete_sessions(["s1", "s2"], sessions_dir=tmp_path)
assert deleted == 2
assert not (tmp_path / "s1.jsonl").exists()
assert not (tmp_path / "s2.json").exists()
class TestDeleteEmptySessions:
"""``delete_empty_sessions`` sweeps every ended, non-archived session
whose ``message_count`` is 0. Backs the dashboard's "Delete empty"
button see ``SessionsPage.tsx`` + ``DELETE /api/sessions/empty``
in ``hermes_cli/web_server.py``.
Invariants this class locks in:
1. Only ``message_count = 0`` rows are touched.
2. Active (un-ended) sessions are skipped even if they're empty —
the agent might be mid-handshake, and yanking the row would
race the live runtime.
3. Archived sessions are skipped the user already filed them away.
4. Children of a deleted parent are orphaned (parent_session_id
NULL) rather than cascade-deleted, matching the
``delete_session`` / ``prune_sessions`` contract.
5. The pre-DB count matches the post-DB delete return value.
"""
def test_count_and_delete_empties_only(self, db):
# Two empty + ended sessions → both should be in the kill list.
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
# One non-empty + ended session → must survive.
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 2
deleted = db.delete_empty_sessions()
assert deleted == 2
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
assert db.get_session("hasmsg") is not None
assert db.count_empty_sessions() == 0
def test_skips_active_empty_sessions(self, db):
"""A live (un-ended) empty session is what you get during the
race between session-create and the first message landing. The
sweep must not delete it that would yank a session out from
under the agent before its first reply persists."""
db.create_session(session_id="live", source="cli")
# Deliberately no end_session() — session is "active".
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("live") is not None
def test_skips_archived_empty_sessions(self, db):
"""Archived = soft-hidden by the user. They explicitly chose to
keep the row around (even though it's empty), so the bulk sweep
must not surprise them by deleting it. Restoring an archived
session is one click; resurrecting one we deleted is impossible."""
db.create_session(session_id="archived_empty", source="cli")
db.end_session("archived_empty", end_reason="done")
db.set_session_archived("archived_empty", True)
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("archived_empty") is not None
def test_returns_zero_when_nothing_to_delete(self, db):
"""No-op path: no candidate rows → return 0, no error."""
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("hasmsg") is not None
def test_orphans_children_of_deleted_empty_parent(self, db):
"""Even an empty parent can have a child (e.g. a branch session
spawned before the parent received any messages). The sweep
must orphan that child, not cascade-delete it same contract
as ``delete_session`` and ``prune_sessions``."""
db.create_session(session_id="empty_parent", source="cli")
db.end_session("empty_parent", end_reason="done")
db.create_session(
session_id="child", source="cli", parent_session_id="empty_parent"
)
db.append_message("child", role="user", content="something")
db.end_session("child", end_reason="done")
deleted = db.delete_empty_sessions()
assert deleted == 1
assert db.get_session("empty_parent") is None
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_cleans_up_on_disk_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, transcript files left
behind by a crashed gateway (``request_dump_*.json``) are swept
too. Empty sessions rarely have ``{id}.json`` / ``.jsonl``
transcripts, but the request-dump path is real the gateway
writes one before the first reply lands, so a crash mid-reply
produces an empty session with a non-empty dump file."""
db.create_session(session_id="empty_with_dump", source="cli")
db.end_session("empty_with_dump", end_reason="done")
dump = tmp_path / "request_dump_empty_with_dump_0.json"
dump.write_text("{}")
transcript = tmp_path / "empty_with_dump.jsonl"
transcript.write_text("")
deleted = db.delete_empty_sessions(sessions_dir=tmp_path)
assert deleted == 1
assert not dump.exists()
assert not transcript.exists()
# =========================================================================
# Schema and WAL mode
# =========================================================================
+22 -5
View File
@@ -933,8 +933,27 @@ def test_session_create_does_not_persist_empty_row(monkeypatch):
server._sessions.pop(sid, None)
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
"""An explicitly chosen workspace is persisted as the session cwd."""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
]
def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
"""Without an explicit workspace, cwd is left null so the session groups
under "No workspace" rather than the gateway's launch directory."""
created = []
class _FakeDB:
@@ -946,9 +965,7 @@ def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
]
assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}]
def test_session_title_clears_pending_after_persist(monkeypatch):
+22 -3
View File
@@ -708,8 +708,14 @@ def _ensure_session_db_row(session: dict) -> None:
Called from prompt.submit so a row only exists once the user actually sends
a message abandoned drafts never leave an empty "Untitled" session behind.
Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own
lazy create) are no-ops. Captures cwd up front so workspace grouping works
without waiting for a separate cwd update.
lazy create) are no-ops.
Only an *explicitly chosen* workspace is persisted as the session's cwd.
The agent still runs in the auto-detected directory (session["cwd"]), but
we don't stamp that onto the row — otherwise every session the user never
picked a folder for gets grouped under whatever directory the desktop
happened to launch in (e.g. "desktop"). Leaving it null groups them under
"No workspace", which is the desired default.
"""
key = session.get("session_key")
if not key:
@@ -722,7 +728,7 @@ def _ensure_session_db_row(session: dict) -> None:
key,
source="tui",
model=_resolve_model(),
cwd=_session_cwd(session),
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
)
except Exception:
logger.debug("failed to persist desktop session row", exc_info=True)
@@ -733,6 +739,9 @@ def _set_session_cwd(session: dict, cwd: str) -> str:
if not os.path.isdir(resolved):
raise ValueError(f"working directory does not exist: {cwd}")
session["cwd"] = resolved
# An explicit user choice — persist it as the workspace (and let a later
# lazy row creation persist it too, not the launch-dir fallback).
session["explicit_cwd"] = True
_register_session_cwd(session)
db = _get_db()
if db is not None:
@@ -2746,6 +2755,15 @@ def _(rid, params: dict) -> dict:
cols = int(params.get("cols", 80))
history = _coerce_seed_history(params.get("messages"))
title = str(params.get("title") or "").strip()
# Did the client pick a workspace, or are we falling back to the gateway's
# launch directory? Only an explicit choice is persisted as the session's
# workspace (see _ensure_session_db_row); otherwise it lands in "No
# workspace" instead of whatever folder the desktop launched in.
raw_cwd = str(params.get("cwd") or "").strip()
try:
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
except Exception:
explicit_cwd = False
_enable_gateway_prompts()
ready = threading.Event()
@@ -2759,6 +2777,7 @@ def _(rid, params: dict) -> dict:
"cols": cols,
"created_at": now,
"edit_snapshots": {},
"explicit_cwd": explicit_cwd,
"history": history,
"history_lock": threading.Lock(),
"history_version": 0,
+52 -8
View File
@@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
* `--component-backdrop-bg-blend-mode` (default `difference`).
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
* light theme keep `difference` here the canvas is flipped by
* the z-200 FG inversion layer, not by changing this blend mode.
* The CSS var is exposed as a hook so future presets can override
* it (e.g. `multiply` to paint the bg as-is before inversion)
* without touching this component.
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) gated on GPU tier
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
* layer that flips the dashboard into "light mode" for inverted
* themes; for normal dark themes its alpha is 0 so it's a no-op.
* Deliberately placed above every UI overlay z-index (modals,
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
* elements get inverted along with the rest of the page instead
* of painting with pre-inversion colors on top of the lens.
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) gated on GPU tier. Sits above the inversion
* layer by design so the grain is not flipped.
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
@@ -31,10 +47,13 @@ export function Backdrop() {
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
style={
{
backgroundColor: "var(--background-base)",
mixBlendMode:
"var(--component-backdrop-bg-blend-mode, difference)",
} as unknown as React.CSSProperties
}
/>
<div
@@ -75,10 +94,35 @@ export function Backdrop() {
}}
/>
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
+ fgBlend: 'difference' in `design-language/src/ui/components/
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
the layer is fully transparent and contributes nothing; with
alpha 1 + opaque white it inverts the entire stack below it,
producing the LENS_5I "light mode" look without altering any
downstream component code.
z-200 (not 100) so it sits above every portaled UI overlay
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
z-[100], which is what the DS Lens picks too; portals append
at the end of <body>, so equal z-index + later DOM order means
they'd paint on top of the inversion and skip the flip. Inlined
z-index for the same reason the DS does it Tailwind's JIT
scan sometimes drops non-default z utilities. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0"
style={{
backgroundColor: "var(--foreground)",
mixBlendMode: "difference",
zIndex: 200,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[101]"
className="pointer-events-none fixed inset-0 z-[201]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
+1 -1
View File
@@ -86,7 +86,7 @@ export function ModelInfoCard({
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
+273
View File
@@ -0,0 +1,273 @@
import { useCallback } from "react";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Button } from "@nous-research/ui/ui/components/button";
import { useI18n } from "@/i18n";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
type IntervalUnit,
type ScheduleBuilderState,
type ScheduleMode,
type Weekday,
WEEKDAY_INDEXES,
} from "@/lib/schedule";
/**
* Human-readable schedule picker for cron job create/edit flows.
*
* Replaces the raw "type a cron expression" input that lived inline in
* ``CronPage``. The picker still emits a single backend-compatible
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
* fills out shape-appropriate inputs (time picker, weekday toggles,
* datetime-local field) per mode.
*
* Architecture:
*
* - The component is fully controlled. Parent owns the
* ``ScheduleBuilderState`` and the derived schedule string (built
* via ``buildScheduleString`` in render).
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
* preserved across mode switches so flipping back to a previous mode
* doesn't erase the user's work.
* - The "Custom" mode is an escape hatch surfacing it as a normal
* option (instead of hiding it behind an "advanced" toggle) keeps
* power-user workflows discoverable without making everyone scroll
* past it.
*/
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
const { t } = useI18n();
const cronStrings = t.cron;
const modeStrings = cronStrings.scheduleModes;
const update = useCallback(
(patch: Partial<ScheduleBuilderState>) => {
onChange({ ...value, ...patch });
},
[onChange, value],
);
const toggleWeekday = useCallback(
(day: Weekday) => {
const present = value.weekdays.includes(day);
update({
weekdays: present
? value.weekdays.filter((d) => d !== day)
: [...value.weekdays, day],
});
},
[update, value.weekdays],
);
return (
<div className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-schedule-mode">
{cronStrings.scheduleMode ?? "Schedule"}
</Label>
<Select
id="cron-schedule-mode"
value={value.mode}
onValueChange={(v) => update({ mode: v as ScheduleMode })}
>
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
<SelectOption value="once">{modeStrings.once}</SelectOption>
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
</Select>
</div>
{value.mode === "interval" && (
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-interval-value">
{modeStrings.intervalEvery}
</Label>
<Input
id="cron-interval-value"
type="number"
min={1}
max={9999}
value={String(value.intervalValue)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
});
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
<Select
id="cron-interval-unit"
value={value.intervalUnit}
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
>
<SelectOption value="minutes">
{modeStrings.unitMinutes}
</SelectOption>
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
</Select>
</div>
</div>
)}
{value.mode === "daily" && (
<TimeOfDayField
id="cron-daily-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
)}
{value.mode === "weekly" && (
<>
<div className="grid gap-2">
<Label>{modeStrings.weekdays}</Label>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={modeStrings.weekdays}
>
{WEEKDAY_INDEXES.map((d) => {
const isOn = value.weekdays.includes(d);
return (
<Button
key={d}
type="button"
size="sm"
outlined={!isOn}
aria-pressed={isOn}
onClick={() => toggleWeekday(d)}
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
>
{modeStrings.weekdaysShort[d]}
</Button>
);
})}
</div>
</div>
<TimeOfDayField
id="cron-weekly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</>
)}
{value.mode === "monthly" && (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
<Input
id="cron-month-day"
type="number"
min={1}
max={31}
value={String(value.dayOfMonth)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
dayOfMonth:
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
});
}}
/>
</div>
<TimeOfDayField
id="cron-monthly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</div>
)}
{value.mode === "once" && (
<div className="grid gap-2">
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
{/* Native datetime-local emits the exact "YYYY-MM-DDTHH:MM"
shape ``parse_schedule`` accepts on the backend. */}
<input
id="cron-once-at"
type="datetime-local"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value.onceAt}
onChange={(e) => update({ onceAt: e.target.value })}
/>
</div>
)}
{value.mode === "custom" && (
<div className="grid gap-2">
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
<Input
id="cron-custom-expr"
placeholder={modeStrings.customPlaceholder}
value={value.custom}
onChange={(e) => update({ custom: e.target.value })}
className="font-mono-ui"
/>
<p className="text-xs text-muted-foreground">
{modeStrings.customHint}
</p>
</div>
)}
{/* Inline preview of what we'll send to the backend. Helps users
eyeball the result before hitting Create, and keeps the
schedule grammar discoverable for the custom mode. */}
<p className="text-xs text-muted-foreground">
<span className="opacity-70">{modeStrings.preview}: </span>
<span className="font-mono-ui text-foreground">
{buildScheduleString(value) || modeStrings.previewEmpty}
</span>
</p>
</div>
);
}
function TimeOfDayField({
id,
label,
onChange,
value,
}: TimeOfDayFieldProps) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
{/* Native time picker is the right tool for "HH:MM" saves us
two separate hour/minute selects, respects user locale's
AM/PM preference, and round-trips with ``buildScheduleString``
without parsing. */}
<input
id={id}
type="time"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export { DEFAULT_SCHEDULE_STATE };
interface ScheduleBuilderProps {
onChange: (state: ScheduleBuilderState) => void;
value: ScheduleBuilderState;
}
interface TimeOfDayFieldProps {
id: string;
label: string;
onChange: (value: string) => void;
value: string;
}
+14 -4
View File
@@ -208,15 +208,25 @@ function ThemeSwitcherOptions({
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
// Inverted themes (Nous Blue / future lens themes) author their palette
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
// difference layer flips the page. The picker can't replay that math
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
// mirrors the on-screen result. Falls back to the raw palette hexes for
// every other theme so existing dark-theme swatches are untouched.
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,
theme.palette.warmGlow,
];
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
<span className="flex-1" style={{ background: c1 }} />
<span className="flex-1" style={{ background: c2 }} />
<span className="flex-1" style={{ background: c3 }} />
</div>
);
}
+1 -1
View File
@@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode {
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
return "text-success";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
+51
View File
@@ -141,6 +141,22 @@ export const af: Translations = {
"Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
sessionDeleted: "Sessie geskrap",
failedToDelete: "Kon nie sessie skrap nie",
deleteEmpty: "Skrap leë",
deleteEmptyConfirmTitle: "Skrap leë sessies?",
deleteEmptyConfirmMessage:
"Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.",
emptySessionsDeleted: "{count} leë sessies geskrap",
failedToDeleteEmpty: "Kon nie leë sessies skrap nie",
selectSession: "Kies sessie",
selectAllOnPage: "Kies alles op hierdie bladsy",
clearSelection: "Maak keuse skoon",
selectedCount: "{count} gekies",
deleteSelected: "Skrap {count}",
deleteSelectedConfirmTitle: "Skrap {count} sessies?",
deleteSelectedConfirmMessage:
"Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
selectedSessionsDeleted: "{count} sessies geskrap",
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
@@ -211,6 +227,41 @@ export const af: Translations = {
promptPlaceholder: "Wat moet die agent met elke uitvoering doen?",
schedule: "Skedule (cron-uitdrukking)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Skedule",
scheduleModes: {
interval: "Herhalende interval",
daily: "Daagliks",
weekly: "Weekliks",
monthly: "Maandeliks",
once: "Een keer",
custom: "Pasgemaak (cron-uitdrukking)",
intervalEvery: "Elke",
intervalUnit: "Eenheid",
unitMinutes: "minute",
unitHours: "ure",
unitDays: "dae",
timeOfDay: "Tyd van die dag",
weekdays: "Dae van die week",
weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"],
dayOfMonth: "Dag van die maand",
onceAt: "Hardloop op",
customLabel: "Cron-uitdrukking",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).",
preview: "Word gestuur as",
previewEmpty: "(onvolledig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Elke {n} min",
everyHours: "Elke {n} u",
everyDays: "Elke {n} d",
dailyAt: "Daagliks om {time}",
weeklyAt: "Weekliks op {days} om {time}",
monthlyAt: "Maandeliks op die {day} om {time}",
onceAt: "Een keer op {time}",
},
deliverTo: "Lewer aan",
scheduledJobs: "Geskeduleerde Take",
noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.",
+51
View File
@@ -141,6 +141,22 @@ export const de: Translations = {
"Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
sessionDeleted: "Sitzung gelöscht",
failedToDelete: "Sitzung konnte nicht gelöscht werden",
deleteEmpty: "Leere löschen",
deleteEmptyConfirmTitle: "Leere Sitzungen löschen?",
deleteEmptyConfirmMessage:
"Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.",
emptySessionsDeleted: "{count} leere Sitzungen gelöscht",
failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden",
selectSession: "Sitzung auswählen",
selectAllOnPage: "Alle auf dieser Seite auswählen",
clearSelection: "Auswahl aufheben",
selectedCount: "{count} ausgewählt",
deleteSelected: "{count} löschen",
deleteSelectedConfirmTitle: "{count} Sitzungen löschen?",
deleteSelectedConfirmMessage:
"Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
selectedSessionsDeleted: "{count} Sitzungen gelöscht",
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
@@ -211,6 +227,41 @@ export const de: Translations = {
promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?",
schedule: "Zeitplan (Cron-Ausdruck)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zeitplan",
scheduleModes: {
interval: "Wiederkehrendes Intervall",
daily: "Täglich",
weekly: "Wöchentlich",
monthly: "Monatlich",
once: "Einmalig",
custom: "Benutzerdefiniert (Cron-Ausdruck)",
intervalEvery: "Alle",
intervalUnit: "Einheit",
unitMinutes: "Minuten",
unitHours: "Stunden",
unitDays: "Tage",
timeOfDay: "Uhrzeit",
weekdays: "Wochentage",
weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
dayOfMonth: "Tag des Monats",
onceAt: "Ausführen am",
customLabel: "Cron-Ausdruck",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).",
preview: "Wird gesendet als",
previewEmpty: "(unvollständig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Alle {n} Min.",
everyHours: "Alle {n} Std.",
everyDays: "Alle {n} Tage",
dailyAt: "Täglich um {time}",
weeklyAt: "Wöchentlich am {days} um {time}",
monthlyAt: "Monatlich am {day} um {time}",
onceAt: "Einmal am {time}",
},
deliverTo: "Zustellen an",
scheduledJobs: "Geplante Aufgaben",
noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.",
+51
View File
@@ -141,6 +141,22 @@ export const en: Translations = {
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
deleteEmpty: "Delete empty",
deleteEmptyConfirmTitle: "Delete empty sessions?",
deleteEmptyConfirmMessage:
"This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.",
emptySessionsDeleted: "{count} empty sessions deleted",
failedToDeleteEmpty: "Failed to delete empty sessions",
selectSession: "Select session",
selectAllOnPage: "Select all on this page",
clearSelection: "Clear selection",
selectedCount: "{count} selected",
deleteSelected: "Delete {count}",
deleteSelectedConfirmTitle: "Delete {count} sessions?",
deleteSelectedConfirmMessage:
"This permanently removes {count} selected sessions and all their messages. This cannot be undone.",
selectedSessionsDeleted: "{count} sessions deleted",
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
previousPage: "Previous page",
nextPage: "Next page",
@@ -211,6 +227,41 @@ export const en: Translations = {
promptPlaceholder: "What should the agent do on each run?",
schedule: "Schedule (cron expression)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Schedule",
scheduleModes: {
interval: "Every interval",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
once: "Once",
custom: "Custom (cron expression)",
intervalEvery: "Every",
intervalUnit: "Unit",
unitMinutes: "minutes",
unitHours: "hours",
unitDays: "days",
timeOfDay: "Time of day",
weekdays: "Days of week",
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
dayOfMonth: "Day of month",
onceAt: "Run at",
customLabel: "Cron expression",
customPlaceholder: "0 9 * * *",
customHint:
"Five-field cron expression (minute, hour, day, month, weekday).",
preview: "Sends as",
previewEmpty: "(incomplete)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Every {n} min",
everyHours: "Every {n} h",
everyDays: "Every {n} d",
dailyAt: "Daily at {time}",
weeklyAt: "Weekly on {days} at {time}",
monthlyAt: "Monthly on the {day} at {time}",
onceAt: "Once at {time}",
},
deliverTo: "Deliver to",
scheduledJobs: "Scheduled Jobs",
noJobs: "No cron jobs configured. Create one above.",
+51
View File
@@ -141,6 +141,22 @@ export const es: Translations = {
"Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.",
sessionDeleted: "Sesión eliminada",
failedToDelete: "No se pudo eliminar la sesión",
deleteEmpty: "Eliminar vacías",
deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?",
deleteEmptyConfirmMessage:
"Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.",
emptySessionsDeleted: "{count} sesiones vacías eliminadas",
failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías",
selectSession: "Seleccionar sesión",
selectAllOnPage: "Seleccionar todas en esta página",
clearSelection: "Limpiar selección",
selectedCount: "{count} seleccionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?",
deleteSelectedConfirmMessage:
"Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.",
selectedSessionsDeleted: "{count} sesiones eliminadas",
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
previousPage: "Página anterior",
nextPage: "Página siguiente",
@@ -211,6 +227,41 @@ export const es: Translations = {
promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?",
schedule: "Programación (expresión cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Programación",
scheduleModes: {
interval: "Cada intervalo",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensualmente",
once: "Una vez",
custom: "Personalizado (expresión cron)",
intervalEvery: "Cada",
intervalUnit: "Unidad",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "días",
timeOfDay: "Hora del día",
weekdays: "Días de la semana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
dayOfMonth: "Día del mes",
onceAt: "Ejecutar el",
customLabel: "Expresión cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).",
preview: "Se envía como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Cada {n} min",
everyHours: "Cada {n} h",
everyDays: "Cada {n} d",
dailyAt: "Diariamente a las {time}",
weeklyAt: "Semanalmente los {days} a las {time}",
monthlyAt: "Mensualmente el {day} a las {time}",
onceAt: "Una vez el {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tareas programadas",
noJobs: "No hay tareas cron configuradas. Crea una arriba.",
+51
View File
@@ -141,6 +141,22 @@ export const fr: Translations = {
"Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.",
sessionDeleted: "Session supprimée",
failedToDelete: "Échec de la suppression de la session",
deleteEmpty: "Supprimer les vides",
deleteEmptyConfirmTitle: "Supprimer les sessions vides ?",
deleteEmptyConfirmMessage:
"Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.",
emptySessionsDeleted: "{count} sessions vides supprimées",
failedToDeleteEmpty: "Échec de la suppression des sessions vides",
selectSession: "Sélectionner la session",
selectAllOnPage: "Tout sélectionner sur cette page",
clearSelection: "Effacer la sélection",
selectedCount: "{count} sélectionnée(s)",
deleteSelected: "Supprimer {count}",
deleteSelectedConfirmTitle: "Supprimer {count} sessions ?",
deleteSelectedConfirmMessage:
"Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.",
selectedSessionsDeleted: "{count} sessions supprimées",
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
previousPage: "Page précédente",
nextPage: "Page suivante",
@@ -211,6 +227,41 @@ export const fr: Translations = {
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planning (expression cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Planification",
scheduleModes: {
interval: "Intervalle récurrent",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
once: "Une fois",
custom: "Personnalisé (expression cron)",
intervalEvery: "Toutes les",
intervalUnit: "Unité",
unitMinutes: "minutes",
unitHours: "heures",
unitDays: "jours",
timeOfDay: "Heure de la journée",
weekdays: "Jours de la semaine",
weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"],
dayOfMonth: "Jour du mois",
onceAt: "Exécuter le",
customLabel: "Expression cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).",
preview: "Envoyé sous la forme",
previewEmpty: "(incomplet)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Toutes les {n} min",
everyHours: "Toutes les {n} h",
everyDays: "Tous les {n} j",
dailyAt: "Tous les jours à {time}",
weeklyAt: "Chaque {days} à {time}",
monthlyAt: "Le {day} de chaque mois à {time}",
onceAt: "Une fois le {time}",
},
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",
+59
View File
@@ -141,6 +141,22 @@ export const ga: Translations = {
"Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.",
sessionDeleted: "Seisiún scriosta",
failedToDelete: "Theip ar scriosadh an tseisiúin",
deleteEmpty: "Scrios folamh",
deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?",
deleteEmptyConfirmMessage:
"Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.",
emptySessionsDeleted: "{count} seisiúin fholmha scriosta",
failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha",
selectSession: "Roghnaigh seisiún",
selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo",
clearSelection: "Glan an rogha",
selectedCount: "{count} roghnaithe",
deleteSelected: "Scrios {count}",
deleteSelectedConfirmTitle: "Scrios {count} seisiún?",
deleteSelectedConfirmMessage:
"Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.",
selectedSessionsDeleted: "Scriosadh {count} seisiún",
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
@@ -211,6 +227,49 @@ export const ga: Translations = {
promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?",
schedule: "Sceideal (slonn cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Sceideal",
scheduleModes: {
interval: "Eatramh athfhillteach",
daily: "Go laethúil",
weekly: "Go seachtainiúil",
monthly: "Go míosúil",
once: "Uair amháin",
custom: "Saincheaptha (slonn cron)",
intervalEvery: "Gach",
intervalUnit: "Aonad",
unitMinutes: "nóiméad",
unitHours: "uair",
unitDays: "lá",
timeOfDay: "Am an lae",
weekdays: "Laethanta na seachtaine",
weekdaysShort: [
"Domh",
"Luan",
"Máirt",
"Céad",
"Déar",
"Aoine",
"Sath",
],
dayOfMonth: "Lá den mhí",
onceAt: "Rith ag",
customLabel: "Slonn cron",
customPlaceholder: "0 9 * * *",
customHint:
"Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).",
preview: "Seoltar mar",
previewEmpty: "(neamhiomlán)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Gach {n} nóim",
everyHours: "Gach {n} u",
everyDays: "Gach {n} lá",
dailyAt: "Go laethúil ag {time}",
weeklyAt: "Gach {days} ag {time}",
monthlyAt: "An {day} de gach mí ag {time}",
onceAt: "Uair amháin ag {time}",
},
deliverTo: "Seachadadh chuig",
scheduledJobs: "Poist sceidealta",
noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.",
+51
View File
@@ -141,6 +141,22 @@ export const hu: Translations = {
"Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.",
sessionDeleted: "Munkamenet törölve",
failedToDelete: "Nem sikerült törölni a munkamenetet",
deleteEmpty: "Üresek törlése",
deleteEmptyConfirmTitle: "Üres munkamenetek törlése?",
deleteEmptyConfirmMessage:
"Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.",
emptySessionsDeleted: "{count} üres munkamenet törölve",
failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket",
selectSession: "Munkamenet kijelölése",
selectAllOnPage: "Az oldalon mindegyik kijelölése",
clearSelection: "Kijelölés törlése",
selectedCount: "{count} kijelölve",
deleteSelected: "{count} törlése",
deleteSelectedConfirmTitle: "{count} munkamenet törlése?",
deleteSelectedConfirmMessage:
"Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.",
selectedSessionsDeleted: "{count} munkamenet törölve",
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
@@ -211,6 +227,41 @@ export const hu: Translations = {
promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?",
schedule: "Ütemezés (cron-kifejezés)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Ütemezés",
scheduleModes: {
interval: "Ismétlődő intervallum",
daily: "Naponta",
weekly: "Hetente",
monthly: "Havonta",
once: "Egyszer",
custom: "Egyéni (cron kifejezés)",
intervalEvery: "Minden",
intervalUnit: "Egység",
unitMinutes: "perc",
unitHours: "óra",
unitDays: "nap",
timeOfDay: "Napszak",
weekdays: "Hét napjai",
weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
dayOfMonth: "Hónap napja",
onceAt: "Futtatás ekkor",
customLabel: "Cron kifejezés",
customPlaceholder: "0 9 * * *",
customHint:
"Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).",
preview: "Elküldve mint",
previewEmpty: "(hiányos)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n} percenként",
everyHours: "{n} óránként",
everyDays: "{n} naponta",
dailyAt: "Naponta {time}-kor",
weeklyAt: "Hetente {days} {time}-kor",
monthlyAt: "Havonta {day} {time}-kor",
onceAt: "Egyszer {time}-kor",
},
deliverTo: "Kézbesítés ide",
scheduledJobs: "Ütemezett feladatok",
noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.",
+51
View File
@@ -141,6 +141,22 @@ export const it: Translations = {
"Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.",
sessionDeleted: "Sessione eliminata",
failedToDelete: "Eliminazione della sessione non riuscita",
deleteEmpty: "Elimina vuote",
deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?",
deleteEmptyConfirmMessage:
"Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.",
emptySessionsDeleted: "{count} sessioni vuote eliminate",
failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote",
selectSession: "Seleziona sessione",
selectAllOnPage: "Seleziona tutte in questa pagina",
clearSelection: "Annulla selezione",
selectedCount: "{count} selezionate",
deleteSelected: "Elimina {count}",
deleteSelectedConfirmTitle: "Eliminare {count} sessioni?",
deleteSelectedConfirmMessage:
"Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.",
selectedSessionsDeleted: "{count} sessioni eliminate",
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
@@ -211,6 +227,41 @@ export const it: Translations = {
promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?",
schedule: "Pianificazione (espressione cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Pianificazione",
scheduleModes: {
interval: "Intervallo ricorrente",
daily: "Giornaliero",
weekly: "Settimanale",
monthly: "Mensile",
once: "Una volta",
custom: "Personalizzato (espressione cron)",
intervalEvery: "Ogni",
intervalUnit: "Unità",
unitMinutes: "minuti",
unitHours: "ore",
unitDays: "giorni",
timeOfDay: "Ora del giorno",
weekdays: "Giorni della settimana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"],
dayOfMonth: "Giorno del mese",
onceAt: "Esegui il",
customLabel: "Espressione cron",
customPlaceholder: "0 9 * * *",
customHint:
"Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).",
preview: "Inviato come",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Ogni {n} min",
everyHours: "Ogni {n} h",
everyDays: "Ogni {n} g",
dailyAt: "Tutti i giorni alle {time}",
weeklyAt: "Ogni {days} alle {time}",
monthlyAt: "Il {day} di ogni mese alle {time}",
onceAt: "Una volta il {time}",
},
deliverTo: "Consegna a",
scheduledJobs: "Attività pianificate",
noJobs: "Nessuna attività cron configurata. Creane una sopra.",
+50
View File
@@ -141,6 +141,22 @@ export const ja: Translations = {
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
deleteEmpty: "空を削除",
deleteEmptyConfirmTitle: "空のセッションを削除しますか?",
deleteEmptyConfirmMessage:
"メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。",
emptySessionsDeleted: "{count} 件の空のセッションを削除しました",
failedToDeleteEmpty: "空のセッションの削除に失敗しました",
selectSession: "セッションを選択",
selectAllOnPage: "このページの全てを選択",
clearSelection: "選択を解除",
selectedCount: "{count}件選択中",
deleteSelected: "{count}件削除",
deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?",
deleteSelectedConfirmMessage:
"選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
selectedSessionsDeleted: "{count}件のセッションを削除しました",
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
previousPage: "前のページ",
nextPage: "次のページ",
@@ -211,6 +227,40 @@ export const ja: Translations = {
promptPlaceholder: "実行ごとにエージェントが行う内容は?",
schedule: "スケジュール (cron 式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "スケジュール",
scheduleModes: {
interval: "繰り返し間隔",
daily: "毎日",
weekly: "毎週",
monthly: "毎月",
once: "1回のみ",
custom: "カスタム(cron式)",
intervalEvery: "実行間隔",
intervalUnit: "単位",
unitMinutes: "分",
unitHours: "時間",
unitDays: "日",
timeOfDay: "時刻",
weekdays: "曜日",
weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"],
dayOfMonth: "日付",
onceAt: "実行日時",
customLabel: "cron式",
customPlaceholder: "0 9 * * *",
customHint: "5フィールドのcron式(分、時、日、月、曜日)。",
preview: "送信形式",
previewEmpty: "(未入力)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}分ごと",
everyHours: "{n}時間ごと",
everyDays: "{n}日ごと",
dailyAt: "毎日 {time}",
weeklyAt: "毎週 {days} {time}",
monthlyAt: "毎月{day} {time}",
onceAt: "{time} に1回",
},
deliverTo: "配信先",
scheduledJobs: "スケジュール済みジョブ",
noJobs: "Cron ジョブが設定されていません。上で作成してください。",
+50
View File
@@ -141,6 +141,22 @@ export const ko: Translations = {
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
deleteEmpty: "빈 세션 삭제",
deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?",
deleteEmptyConfirmMessage:
"메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.",
emptySessionsDeleted: "빈 세션 {count}개 삭제됨",
failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다",
selectSession: "세션 선택",
selectAllOnPage: "이 페이지 전체 선택",
clearSelection: "선택 해제",
selectedCount: "{count}개 선택됨",
deleteSelected: "{count}개 삭제",
deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?",
deleteSelectedConfirmMessage:
"선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.",
selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다",
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
@@ -211,6 +227,40 @@ export const ko: Translations = {
promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?",
schedule: "스케줄 (cron 표현식)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "일정",
scheduleModes: {
interval: "반복 간격",
daily: "매일",
weekly: "매주",
monthly: "매월",
once: "한 번",
custom: "사용자 지정 (cron 표현식)",
intervalEvery: "실행 간격",
intervalUnit: "단위",
unitMinutes: "분",
unitHours: "시간",
unitDays: "일",
timeOfDay: "시각",
weekdays: "요일",
weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"],
dayOfMonth: "날짜",
onceAt: "실행 시각",
customLabel: "cron 표현식",
customPlaceholder: "0 9 * * *",
customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).",
preview: "전송 형식",
previewEmpty: "(미완성)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}분마다",
everyHours: "{n}시간마다",
everyDays: "{n}일마다",
dailyAt: "매일 {time}",
weeklyAt: "매주 {days} {time}",
monthlyAt: "매월 {day} {time}",
onceAt: "{time}에 한 번",
},
deliverTo: "전달 대상",
scheduledJobs: "예약된 작업",
noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.",
+51
View File
@@ -141,6 +141,22 @@ export const pt: Translations = {
"Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.",
sessionDeleted: "Sessão eliminada",
failedToDelete: "Falha ao eliminar a sessão",
deleteEmpty: "Eliminar vazias",
deleteEmptyConfirmTitle: "Eliminar sessões vazias?",
deleteEmptyConfirmMessage:
"Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.",
emptySessionsDeleted: "{count} sessões vazias eliminadas",
failedToDeleteEmpty: "Falha ao eliminar sessões vazias",
selectSession: "Selecionar sessão",
selectAllOnPage: "Selecionar todas nesta página",
clearSelection: "Limpar seleção",
selectedCount: "{count} selecionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "Eliminar {count} sessões?",
deleteSelectedConfirmMessage:
"Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.",
selectedSessionsDeleted: "{count} sessões eliminadas",
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
previousPage: "Página anterior",
nextPage: "Página seguinte",
@@ -211,6 +227,41 @@ export const pt: Translations = {
promptPlaceholder: "O que deve o agente fazer em cada execução?",
schedule: "Agendamento (expressão cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Agendamento",
scheduleModes: {
interval: "Intervalo recorrente",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensalmente",
once: "Uma vez",
custom: "Personalizado (expressão cron)",
intervalEvery: "A cada",
intervalUnit: "Unidade",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "dias",
timeOfDay: "Hora do dia",
weekdays: "Dias da semana",
weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"],
dayOfMonth: "Dia do mês",
onceAt: "Executar em",
customLabel: "Expressão cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).",
preview: "Enviado como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "A cada {n} min",
everyHours: "A cada {n} h",
everyDays: "A cada {n} d",
dailyAt: "Diariamente às {time}",
weeklyAt: "Semanalmente {days} às {time}",
monthlyAt: "Mensalmente no dia {day} às {time}",
onceAt: "Uma vez em {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tarefas agendadas",
noJobs: "Sem tarefas cron configuradas. Crie uma acima.",
+51
View File
@@ -141,6 +141,22 @@ export const ru: Translations = {
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
deleteEmpty: "Удалить пустые",
deleteEmptyConfirmTitle: "Удалить пустые сессии?",
deleteEmptyConfirmMessage:
"Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.",
emptySessionsDeleted: "Удалено пустых сессий: {count}",
failedToDeleteEmpty: "Не удалось удалить пустые сессии",
selectSession: "Выбрать сессию",
selectAllOnPage: "Выбрать все на этой странице",
clearSelection: "Снять выделение",
selectedCount: "Выбрано: {count}",
deleteSelected: "Удалить {count}",
deleteSelectedConfirmTitle: "Удалить {count} сессий?",
deleteSelectedConfirmMessage:
"Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.",
selectedSessionsDeleted: "Удалено сессий: {count}",
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
@@ -211,6 +227,41 @@ export const ru: Translations = {
promptPlaceholder: "Что должен делать агент при каждом запуске?",
schedule: "Расписание (cron-выражение)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Расписание",
scheduleModes: {
interval: "Повторяющийся интервал",
daily: "Ежедневно",
weekly: "Еженедельно",
monthly: "Ежемесячно",
once: "Один раз",
custom: "Произвольное (cron-выражение)",
intervalEvery: "Каждые",
intervalUnit: "Единицы",
unitMinutes: "минут",
unitHours: "часов",
unitDays: "дней",
timeOfDay: "Время суток",
weekdays: "Дни недели",
weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День месяца",
onceAt: "Выполнить в",
customLabel: "Cron-выражение",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-выражение из пяти полей (минута, час, день, месяц, день недели).",
preview: "Отправляется как",
previewEmpty: "(не заполнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Каждые {n} мин",
everyHours: "Каждые {n} ч",
everyDays: "Каждые {n} дн",
dailyAt: "Ежедневно в {time}",
weeklyAt: "Еженедельно в {days} в {time}",
monthlyAt: "Ежемесячно {day} числа в {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Доставить в",
scheduledJobs: "Запланированные задачи",
noJobs: "Cron-задачи не настроены. Создайте задачу выше.",
+51
View File
@@ -141,6 +141,22 @@ export const tr: Translations = {
"Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
sessionDeleted: "Oturum silindi",
failedToDelete: "Oturum silinemedi",
deleteEmpty: "Boşları sil",
deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?",
deleteEmptyConfirmMessage:
"Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.",
emptySessionsDeleted: "{count} boş oturum silindi",
failedToDeleteEmpty: "Boş oturumlar silinemedi",
selectSession: "Oturumu seç",
selectAllOnPage: "Bu sayfadakilerin tümünü seç",
clearSelection: "Seçimi temizle",
selectedCount: "{count} seçildi",
deleteSelected: "{count} sil",
deleteSelectedConfirmTitle: "{count} oturum silinsin mi?",
deleteSelectedConfirmMessage:
"Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
selectedSessionsDeleted: "{count} oturum silindi",
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
@@ -211,6 +227,41 @@ export const tr: Translations = {
promptPlaceholder: "Agent her çalıştırmada ne yapmalı?",
schedule: "Zamanlama (cron ifadesi)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zamanlama",
scheduleModes: {
interval: "Tekrarlanan aralık",
daily: "Günlük",
weekly: "Haftalık",
monthly: "Aylık",
once: "Bir kez",
custom: "Özel (cron ifadesi)",
intervalEvery: "Her",
intervalUnit: "Birim",
unitMinutes: "dakika",
unitHours: "saat",
unitDays: "gün",
timeOfDay: "Günün saati",
weekdays: "Haftanın günleri",
weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
dayOfMonth: "Ayın günü",
onceAt: "Çalıştırma zamanı",
customLabel: "Cron ifadesi",
customPlaceholder: "0 9 * * *",
customHint:
"Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).",
preview: "Gönderilecek olan",
previewEmpty: "(eksik)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Her {n} dk",
everyHours: "Her {n} sa",
everyDays: "Her {n} gün",
dailyAt: "Her gün {time}",
weeklyAt: "Her hafta {days} {time}",
monthlyAt: "Her ayın {day} günü {time}",
onceAt: "{time} bir kez",
},
deliverTo: "Şuraya teslim et",
scheduledJobs: "Zamanlanmış Görevler",
noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.",
+48
View File
@@ -158,6 +158,20 @@ export interface Translations {
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
deleteEmpty: string;
deleteEmptyConfirmTitle: string;
deleteEmptyConfirmMessage: string;
emptySessionsDeleted: string;
failedToDeleteEmpty: string;
selectSession: string;
selectAllOnPage: string;
clearSelection: string;
selectedCount: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmMessage: string;
selectedSessionsDeleted: string;
failedToDeleteSelected: string;
resumeInChat: string;
previousPage: string;
nextPage: string;
@@ -231,6 +245,40 @@ export interface Translations {
promptPlaceholder: string;
schedule: string;
schedulePlaceholder: string;
scheduleMode: string;
scheduleModes: {
interval: string;
daily: string;
weekly: string;
monthly: string;
once: string;
custom: string;
intervalEvery: string;
intervalUnit: string;
unitMinutes: string;
unitHours: string;
unitDays: string;
timeOfDay: string;
weekdays: string;
weekdaysShort: [string, string, string, string, string, string, string];
dayOfMonth: string;
onceAt: string;
customLabel: string;
customPlaceholder: string;
customHint: string;
preview: string;
previewEmpty: string;
};
scheduleDescribe: {
none: string;
everyMinutes: string;
everyHours: string;
everyDays: string;
dailyAt: string;
weeklyAt: string;
monthlyAt: string;
onceAt: string;
};
deliverTo: string;
scheduledJobs: string;
noJobs: string;
+51
View File
@@ -141,6 +141,22 @@ export const uk: Translations = {
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
deleteEmpty: "Видалити порожні",
deleteEmptyConfirmTitle: "Видалити порожні сесії?",
deleteEmptyConfirmMessage:
"Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.",
emptySessionsDeleted: "Видалено порожніх сесій: {count}",
failedToDeleteEmpty: "Не вдалося видалити порожні сесії",
selectSession: "Вибрати сесію",
selectAllOnPage: "Вибрати всі на цій сторінці",
clearSelection: "Скинути вибір",
selectedCount: "Вибрано: {count}",
deleteSelected: "Видалити {count}",
deleteSelectedConfirmTitle: "Видалити {count} сесій?",
deleteSelectedConfirmMessage:
"Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.",
selectedSessionsDeleted: "Видалено сесій: {count}",
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
@@ -211,6 +227,41 @@ export const uk: Translations = {
promptPlaceholder: "Що агент має робити при кожному запуску?",
schedule: "Розклад (cron-вираз)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Розклад",
scheduleModes: {
interval: "Повторюваний інтервал",
daily: "Щодня",
weekly: "Щотижня",
monthly: "Щомісяця",
once: "Один раз",
custom: "Користувацьке (cron-вираз)",
intervalEvery: "Кожні",
intervalUnit: "Одиниці",
unitMinutes: "хвилин",
unitHours: "годин",
unitDays: "днів",
timeOfDay: "Час доби",
weekdays: "Дні тижня",
weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День місяця",
onceAt: "Виконати о",
customLabel: "Cron-вираз",
customPlaceholder: "0 9 * * *",
customHint:
"П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).",
preview: "Надсилається як",
previewEmpty: "(не заповнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Кожні {n} хв",
everyHours: "Кожні {n} год",
everyDays: "Кожні {n} дн",
dailyAt: "Щодня о {time}",
weeklyAt: "Щотижня у {days} о {time}",
monthlyAt: "Щомісяця {day} числа о {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Надіслати на",
scheduledJobs: "Заплановані завдання",
noJobs: "Cron-завдань не налаштовано. Створіть одне вище.",
+50
View File
@@ -141,6 +141,22 @@ export const zhHant: Translations = {
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
deleteEmpty: "刪除空工作階段",
deleteEmptyConfirmTitle: "刪除空工作階段?",
deleteEmptyConfirmMessage:
"這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。",
emptySessionsDeleted: "已刪除 {count} 個空工作階段",
failedToDeleteEmpty: "刪除空工作階段失敗",
selectSession: "選擇工作階段",
selectAllOnPage: "全選本頁",
clearSelection: "清除選擇",
selectedCount: "已選擇 {count} 個",
deleteSelected: "刪除 {count} 個",
deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?",
deleteSelectedConfirmMessage:
"此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。",
selectedSessionsDeleted: "已刪除 {count} 個工作階段",
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
previousPage: "上一頁",
nextPage: "下一頁",
@@ -211,6 +227,40 @@ export const zhHant: Translations = {
promptPlaceholder: "代理每次執行時應做什麼?",
schedule: "排程(cron 運算式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "排程",
scheduleModes: {
interval: "重複間隔",
daily: "每日",
weekly: "每週",
monthly: "每月",
once: "僅一次",
custom: "自訂(cron 運算式)",
intervalEvery: "每",
intervalUnit: "單位",
unitMinutes: "分鐘",
unitHours: "小時",
unitDays: "天",
timeOfDay: "時間",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "執行時間",
customLabel: "cron 運算式",
customPlaceholder: "0 9 * * *",
customHint: "五欄位 cron 運算式(分、時、日、月、星期)。",
preview: "傳送為",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分鐘",
everyHours: "每 {n} 小時",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每週 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 執行一次",
},
deliverTo: "傳送至",
scheduledJobs: "已排程任務",
noJobs: "尚未設定排程任務。請於上方建立。",
+50
View File
@@ -139,6 +139,22 @@ export const zh: Translations = {
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
deleteEmpty: "删除空会话",
deleteEmptyConfirmTitle: "删除空会话?",
deleteEmptyConfirmMessage:
"这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。",
emptySessionsDeleted: "已删除 {count} 个空会话",
failedToDeleteEmpty: "删除空会话失败",
selectSession: "选择会话",
selectAllOnPage: "全选本页",
clearSelection: "清除选择",
selectedCount: "已选择 {count} 个",
deleteSelected: "删除 {count} 个",
deleteSelectedConfirmTitle: "删除 {count} 个会话?",
deleteSelectedConfirmMessage:
"此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。",
selectedSessionsDeleted: "已删除 {count} 个会话",
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
@@ -208,6 +224,40 @@ export const zh: Translations = {
promptPlaceholder: "代理每次运行时应执行什么操作?",
schedule: "调度表达式(cron",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "调度",
scheduleModes: {
interval: "重复间隔",
daily: "每天",
weekly: "每周",
monthly: "每月",
once: "仅一次",
custom: "自定义(cron 表达式)",
intervalEvery: "每",
intervalUnit: "单位",
unitMinutes: "分钟",
unitHours: "小时",
unitDays: "天",
timeOfDay: "时间",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "执行时间",
customLabel: "cron 表达式",
customPlaceholder: "0 9 * * *",
customHint: "五字段 cron 表达式(分、时、日、月、星期)。",
preview: "发送为",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分钟",
everyHours: "每 {n} 小时",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每周 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 执行一次",
},
deliverTo: "投递至",
scheduledJobs: "已调度任务",
noJobs: "暂无定时任务。在上方创建一个。",
+9
View File
@@ -83,6 +83,15 @@
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
/* Data-series accents consumed by Analytics + Models pages for the
input-vs-output token visualisations (chart bars, table values,
legend swatches). Defaults are tuned for the Hermes-teal LENS_0
look: cream input + emerald-400 output read as warm/cool against
the dark canvas. Themes override via ThemeProvider, which emits
these as `--series-input-token` / `--series-output-token`. */
--series-input-token: #ffe6cb;
--series-output-token: #34d399;
}
/* Theme tokens cascade into the document root so every descendant inherits
+12
View File
@@ -238,6 +238,18 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
+382
View File
@@ -0,0 +1,382 @@
/**
* Schedule builder helpers for the cron page.
*
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
* surprisingly broad set of string formats:
*
* - Duration (one-shot): "30m", "2h", "1d"
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
*
* Power users can hand-type any of those, but for everyone else the
* dashboard now offers a human-readable picker. This module is the
* pure logic layer behind that picker:
*
* - {@link buildScheduleString} turns the picker's structured state
* into one of the strings above.
* - {@link describeSchedule} goes the other way: takes the structured
* schedule shape the API returns (``CronJob.schedule``) and produces
* a human-readable sentence for the job list. It recognises common
* cron-expression shapes (daily/weekly/monthly) so users don't have
* to parse "30 14 * * 1,3,5" by eye.
*
* Kept dependency-free and locale-string-driven so it tree-shakes
* cleanly and is testable in isolation if we ever wire up vitest here.
*/
/** Picker modes each renders a different set of inputs in the UI but
* all funnel through {@link buildScheduleString} to a backend-compatible
* string. ``custom`` is the escape hatch for power users who still want
* to type a raw cron expression. */
export type ScheduleMode =
| "interval"
| "daily"
| "weekly"
| "monthly"
| "once"
| "custom";
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
export type IntervalUnit = "minutes" | "hours" | "days";
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
* croniter expects on the backend (no need to remap on submit). */
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
export interface ScheduleBuilderState {
/** Index of which "custom" radio is selected. */
mode: ScheduleMode;
/** Interval mode: positive integer, paired with ``intervalUnit``. */
intervalValue: number;
intervalUnit: IntervalUnit;
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
timeOfDay: string;
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
* still valid we send "*" for the day-of-week cron field. */
weekdays: Weekday[];
/** Monthly mode: 1..31 (no support for "last day of month" sugar the
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
dayOfMonth: number;
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
onceAt: string;
/** Custom mode: raw user-typed cron expression. Stored separately so
* flipping between modes doesn't erase the user's work. */
custom: string;
}
/** Default state "every 30 minutes" is the most-common-cron-pattern
* starting point and avoids forcing the user to pick everything from
* scratch. */
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
dayOfMonth: 1,
onceAt: "",
custom: "",
};
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
minutes: "m",
hours: "h",
days: "d",
};
/** Build the schedule string from picker state. Returns ``""`` when the
* state is incomplete enough that the backend would 400 the caller
* uses that to disable the Submit button.
*
* Why we lean on the broad parse_schedule grammar instead of always
* emitting cron expressions: interval syntax ("every 30m") survives a
* backend without ``croniter`` installed and renders more readably in
* the job list. We only emit raw cron when the picker truly needs the
* cron field expressiveness (specific weekdays, specific day-of-month). */
export function buildScheduleString(state: ScheduleBuilderState): string {
switch (state.mode) {
case "interval": {
const n = Math.floor(state.intervalValue);
if (!Number.isFinite(n) || n < 1) return "";
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
}
case "daily": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
return `${parsed.minute} ${parsed.hour} * * *`;
}
case "weekly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
// Empty weekday selection → "*" (every day) rather than a backend
// 400. The Daily mode is the cleaner choice for that, but if the
// user toggles all days off in Weekly mode we still emit a valid
// expression instead of breaking the submit.
const days =
state.weekdays.length === 0
? "*"
: [...state.weekdays].sort((a, b) => a - b).join(",");
return `${parsed.minute} ${parsed.hour} * * ${days}`;
}
case "monthly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
const dom = Math.floor(state.dayOfMonth);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
}
case "once": {
const v = state.onceAt.trim();
if (!v) return "";
// <input type=datetime-local> already emits the
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
// Append ":00" so the backend's regex hits the "T" branch and
// the seconds component lines up with isoformat() output.
return v.length === 16 ? `${v}:00` : v;
}
case "custom":
return state.custom.trim();
}
}
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":");
const hour = parseInt(hh, 10);
const minute = parseInt(mm, 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return { hour, minute };
}
/** Translation surface the human-readable describer needs. Passing it
* in (instead of importing ``useI18n``) keeps the helper pure and
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
export interface ScheduleDescribeStrings {
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
none: string;
/** "Every {n} minute(s)" — caller pluralises via {n}. */
everyMinutes: string;
everyHours: string;
everyDays: string;
/** "Daily at {time}" */
dailyAt: string;
/** "Weekly on {days} at {time}" */
weeklyAt: string;
/** "Monthly on the {day} at {time}" */
monthlyAt: string;
/** "Once at {time}" */
onceAt: string;
/** Weekday short names indexed 0..6 (Sunday-first). */
weekdaysShort: [string, string, string, string, string, string, string];
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
* don't use English ordinals, just return ``String(day)``. */
ordinal: (day: number) => string;
}
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
export interface ScheduleLike {
kind?: string;
expr?: string;
minutes?: number;
run_at?: string;
display?: string;
}
/** Human-readable description of a stored schedule.
*
* Prefers a structured render over the raw ``display`` string so cron
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
* macros that we'd misrepresent if we tried to "humanize"). */
export function describeSchedule(
schedule: ScheduleLike | undefined,
fallbackDisplay: string | undefined,
strings: ScheduleDescribeStrings,
): string {
if (!schedule) return fallbackDisplay || strings.none;
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
return describeInterval(schedule.minutes, strings);
}
if (schedule.kind === "once" && schedule.run_at) {
return strings.onceAt.replace(
"{time}",
formatIsoLocal(schedule.run_at, false),
);
}
if (schedule.kind === "cron" && schedule.expr) {
const cronDesc = describeCronExpression(schedule.expr, strings);
if (cronDesc) return cronDesc;
}
// Try the raw expression as a last attempt — for legacy jobs stored
// without ``kind``, the ``schedule_display`` field often *is* the cron
// expression.
if (fallbackDisplay) {
const cronDesc = describeCronExpression(fallbackDisplay, strings);
if (cronDesc) return cronDesc;
return fallbackDisplay;
}
if (schedule.display) return schedule.display;
if (schedule.expr) return schedule.expr;
return strings.none;
}
function describeInterval(
minutes: number,
strings: ScheduleDescribeStrings,
): string {
if (minutes <= 0) return strings.none;
if (minutes % 1440 === 0) {
return strings.everyDays.replace("{n}", String(minutes / 1440));
}
if (minutes % 60 === 0) {
return strings.everyHours.replace("{n}", String(minutes / 60));
}
return strings.everyMinutes.replace("{n}", String(minutes));
}
/** Recognise the common, well-shaped cron patterns and return a
* human sentence for them. Returns ``null`` when the expression has any
* ranges, steps, or other complexity that would be misleading to
* "humanize" caller falls back to displaying the raw expression so
* the user sees what's actually scheduled.
*
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
* 6-field ``minute hour dom month dow year`` form, but humanising those
* by destructuring only the first five fields would silently drop the
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
* the raw-string fallback in {@link describeSchedule}. */
function describeCronExpression(
expr: string,
strings: ScheduleDescribeStrings,
): string | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minField, hourField, domField, monField, dowField] = parts;
const month = monField === "*";
if (!month) return null; // we don't try to humanize per-month rules
const isLiteralOrList = (f: string) =>
/^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
// Star minutes/hours would mean "every minute" / "every hour" — we'd
// need a step-value handler ("*/15") to describe that cleanly, and
// that path is power-user territory. Bail to raw display.
if (minField === "*" || hourField === "*") return null;
const minutes = minField.split(",").map((n) => parseInt(n, 10));
const hours = hourField.split(",").map((n) => parseInt(n, 10));
if (minutes.length !== 1 || hours.length !== 1) return null;
if (
!Number.isFinite(minutes[0]) ||
!Number.isFinite(hours[0]) ||
hours[0] < 0 ||
hours[0] > 23 ||
minutes[0] < 0 ||
minutes[0] > 59
) {
return null;
}
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
const domAll = domField === "*";
const dowAll = dowField === "*";
if (domAll && dowAll) {
return strings.dailyAt.replace("{time}", time);
}
if (domAll && !dowAll) {
const days = dowField
.split(",")
.map((n) => parseInt(n, 10))
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
if (days.length === 0) return null;
const labels = days
.map((d) => strings.weekdaysShort[d])
.filter(Boolean)
.join(", ");
return strings.weeklyAt
.replace("{days}", labels)
.replace("{time}", time);
}
if (!domAll && dowAll) {
const dom = parseInt(domField, 10);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
return strings.monthlyAt
.replace("{day}", strings.ordinal(dom))
.replace("{time}", time);
}
// Both day-of-month AND day-of-week set is unusual and cron's
// OR-semantics for that combo are confusing — fall back to raw.
return null;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** Format an ISO date for inline display. Drops the seconds + TZ
* suffix so the cron list stays compact. Falls back to the raw string
* if Date parsing fails. */
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const yyyy = d.getFullYear();
const mm = pad2(d.getMonth() + 1);
const dd = pad2(d.getDate());
const hh = pad2(d.getHours());
const mi = pad2(d.getMinutes());
if (includeSeconds) {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
* Most non-English locales should just return ``String(day)`` from
* their ``ordinal`` override. */
export function englishOrdinal(day: number): string {
const d = Math.floor(day);
if (!Number.isFinite(d) || d < 1) return String(day);
const lastTwo = d % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
}
}
+39 -36
View File
@@ -21,7 +21,6 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@@ -148,11 +147,17 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-input-token)" }}
/>
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-output-token)" }}
/>
{t.analytics.output}
</div>
</div>
@@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
height: Math.max(inputH, total > 0 ? 1 : 0),
}}
/>
<div
className="w-full bg-emerald-500/70"
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
@@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(d.output_tokens)}
</span>
</td>
@@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(m.output_tokens)}
</span>
</td>
@@ -427,14 +438,24 @@ export default function AnalyticsPage() {
}, [days, showTokens]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
showTokens === false ? null : (
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@@ -446,28 +467,10 @@ export default function AnalyticsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
</div>
),
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+84 -46
View File
@@ -8,6 +8,17 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
ScheduleBuilder,
} from "@/components/ScheduleBuilder";
import {
buildScheduleString,
describeSchedule,
englishOrdinal,
type ScheduleBuilderState,
type ScheduleDescribeStrings,
} from "@/lib/schedule";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
@@ -57,12 +68,20 @@ function getJobTitle(job: CronJob): string {
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
function getJobScheduleDisplay(
job: CronJob,
strings: ScheduleDescribeStrings,
): string {
// Prefer a structured render so cron expressions like
// ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30"
// in the list instead of the raw five-field gibberish. Falls back
// through the existing chain (``schedule_display`` from the backend,
// then the structured ``display`` field, then the raw ``expr``) so
// legacy job rows still render *something* meaningful.
return describeSchedule(
job.schedule,
asText(job.schedule_display) || asText(job.schedule?.display),
strings,
);
}
@@ -102,13 +121,35 @@ export default function CronPage() {
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { t, locale } = useI18n();
const { setEnd } = usePageHeader();
// Translation surface for the human-readable schedule describer.
// English ordinals are a special case ("1st", "2nd", "23rd"); every
// other locale falls back to the plain numeric form, which avoids
// shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that
// don't exist in most languages).
//
// Built inline (not memoized) — the cron page renders a small job
// list, this is single-digit microseconds, and a useMemo here would
// just add boilerplate.
const scheduleDescribeStrings: ScheduleDescribeStrings = {
...t.cron.scheduleDescribe,
weekdaysShort: t.cron.scheduleModes.weekdaysShort,
ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n),
};
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
// The schedule is now constructed via the ScheduleBuilder; we keep
// the full builder state so flipping between modes during edit
// doesn't erase the user's intermediate inputs. The actual string
// sent to the backend is derived via ``buildScheduleString`` at
// submit time.
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
DEFAULT_SCHEDULE_STATE,
);
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
@@ -161,8 +202,10 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
const scheduleString = buildScheduleString(scheduleState);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
if (!prompt.trim() || !scheduleString) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
@@ -171,7 +214,7 @@ export default function CronPage() {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: schedule.trim(),
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
},
@@ -179,7 +222,7 @@ export default function CronPage() {
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setCreateModalOpen(false);
@@ -392,41 +435,34 @@ export default function CronPage() {
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<ScheduleBuilder
value={scheduleState}
onChange={setScheduleState}
/>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="flex justify-end">
@@ -617,7 +653,9 @@ export default function CronPage() {
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span className="font-mono-ui">
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
+39 -33
View File
@@ -95,11 +95,17 @@ function TokenBar({
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
// Segments carry a CSS color value (hex or `var(--token)`) rather than
// a Tailwind class so the input/output series can pick up the active
// theme's `--series-*-token` vars — see `themes/types.ts`
// `ThemeSeriesColors`. The /60/70 fade on the bar is applied via
// color-mix on the same value so themes don't need to ship two
// separate hex literals.
const segments: Array<{ color: string; label: string; value: number }> = [
{ value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400
{ value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400
{ value: input, color: "var(--series-input-token)", label: "Input" },
{ value: output, color: "var(--series-output-token)", label: "Output" },
].filter((s) => s.value > 0);
return (
@@ -109,8 +115,11 @@ function TokenBar({
{segments.map((s, i) => (
<div
key={i}
className={`${s.color} relative flex items-center transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
className="relative flex items-center transition-all duration-300"
style={{
backgroundColor: `color-mix(in srgb, ${s.color} 70%, transparent)`,
width: `${(s.value / total) * 100}%`,
}}
>
{/* Stepped fill pattern overlay */}
<div
@@ -128,7 +137,10 @@ function TokenBar({
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.label} {formatTokens(s.value)}
</span>
))}
@@ -152,7 +164,7 @@ function CapabilityBadges({
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
@@ -818,13 +830,24 @@ export default function ModelsPage() {
}, []);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@@ -836,26 +859,9 @@ export default function ModelsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
</div>,
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+384 -23
View File
@@ -23,6 +23,7 @@ import {
Hash,
X,
Play,
Eraser,
Download,
Pencil,
Check,
@@ -41,6 +42,7 @@ import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@@ -273,22 +275,14 @@ function SessionRow({
snippet,
searchQuery,
isExpanded,
isSelected,
onToggle,
onSelectClick,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
}: SessionRowProps) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -400,18 +394,44 @@ function SessionRow({
</>
);
// Selected rows get a stronger left-edge accent + tinted background so the
// selection state is unambiguous even when scrolling past the bulk-action
// bar at the top. Beat the is_active styling — explicit user selection
// takes priority over "this session is live".
const containerClasses = isSelected
? "border-primary/40 bg-primary/[0.06]"
: session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border";
// Clicking the checkbox must NOT toggle row expansion; selection and
// expansion are independent gestures. We bind ``onClick`` directly on
// the Checkbox (which Radix forwards to its underlying ``<button
// role=checkbox>``) so the event carries the real ``shiftKey`` state
// for range-select AND so keyboard activation (Space on the focused
// checkbox) toggles selection via the same code path — the browser
// synthesises a click on <button> for Space, so one handler covers
// mouse + keyboard cleanly.
const handleSelectClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectClick(e);
};
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<span className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={isSelected}
onClick={handleSelectClick}
aria-label={t.sessions.selectSession}
/>
</span>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
@@ -606,6 +626,30 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
// Count of empty (no-message, ended, non-archived) sessions across the
// entire DB, populated by /api/sessions/empty/count. Used to:
// • hide the "Delete empty" button when there's nothing to clean up
// • show "(N)" alongside the label
// • surface the count in the confirm dialog body
// Refreshed on mount, after single-session deletes, and after the bulk
// delete itself — none of those code paths can update the global empty
// count from local state alone (per-page list != global DB count).
const [emptyCount, setEmptyCount] = useState(0);
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
const [deletingEmpty, setDeletingEmpty] = useState(false);
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
// in a fresh Set so React notices the change (mutating in place
// wouldn't trigger a re-render).
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Index of the last row whose checkbox was clicked WITHOUT shift,
// resolved against the currently visible (post-search) ``filtered``
// list. Used as the anchor for shift-click range select — matches the
// Gmail / Notion / file-explorer convention. ``null`` means "no
// anchor yet", in which case shift-click degrades to a plain toggle.
const lastClickedIndexRef = useRef<number | null>(null);
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
@@ -616,6 +660,18 @@ export default function SessionsPage() {
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
const refreshEmptyCount = useCallback(() => {
api
.getEmptySessionsCount()
.then((r) => setEmptyCount(r.count))
.catch(() => {});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastClickedIndexRef.current = null;
}, []);
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
@@ -673,7 +729,8 @@ export default function SessionsPage() {
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
refreshEmptyCount();
}, [loadSessions, page, refreshEmptyCount]);
useEffect(() => {
const loadOverview = () => {
@@ -696,6 +753,36 @@ export default function SessionsPage() {
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Wrapped setters that ALSO clear the bulk selection. The user's
// mental model is "I'm selecting what I can see" — carrying a
// selection across a page change, search input, or view switch
// would arm invisible rows for deletion, which is the exact footgun
// the confirm dialog can't catch. Doing this at the call sites
// instead of in a ``useEffect`` keeps us out of the
// react-hooks/set-state-in-effect lint trap and the cascading
// re-render it warns about.
const goToPage = useCallback(
(p: number) => {
setPage(p);
clearSelection();
},
[clearSelection],
);
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
clearSelection();
},
[clearSelection],
);
const switchView = useCallback(
(next: SessionsView) => {
setView(next);
clearSelection();
},
[clearSelection],
);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -728,6 +815,18 @@ export default function SessionsPage() {
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
// Drop the deleted ID from any active bulk-select set — it
// can't bulk-delete a row that's already gone.
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// A single-session delete might have been an empty one — re-fetch
// the global empty count so the button hides itself / its badge
// ticks down without waiting for the next page navigation.
refreshEmptyCount();
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
@@ -737,6 +836,7 @@ export default function SessionsPage() {
},
[
expandedId,
refreshEmptyCount,
showToast,
loadStats,
t.sessions.sessionDeleted,
@@ -745,6 +845,140 @@ export default function SessionsPage() {
),
});
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
* have a previous anchor, every row between the anchor and the
* current index (inclusive) is set to the current row's NEW state
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
* be the currently rendered list (post-search), since indices are
* resolved against what the user is actually looking at.
*/
const handleSelectClick = useCallback(
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
const id = visibleList[index]?.id;
if (!id) return;
setSelectedIds((prev) => {
const next = new Set(prev);
const wasSelected = next.has(id);
const willSelect = !wasSelected;
const anchor = lastClickedIndexRef.current;
// Shift-click extends the selection from the anchor to here.
// Skip if there's no anchor or the anchor is outside the
// visible list — in those cases fall through to a plain toggle
// (the click also resets the anchor below).
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
const [lo, hi] =
anchor <= index ? [anchor, index] : [index, anchor];
for (let i = lo; i <= hi; i++) {
const rowId = visibleList[i]?.id;
if (!rowId) continue;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
// Always update the anchor to the most recent click — even when
// it was a shift-click that extended a range, the user's next
// shift-click should anchor from here, not from two steps back.
lastClickedIndexRef.current = index;
},
[],
);
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const s of visibleList) next.add(s.id);
return next;
});
}, []);
const handleDeleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) {
setDeleteSelectedOpen(false);
return;
}
setDeletingSelected(true);
try {
const resp = await api.bulkDeleteSessions(ids);
showToast(
t.sessions.selectedSessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteSelectedOpen(false);
// Drop deleted rows out of the visible list immediately rather
// than waiting for the reload. The reload still runs so total /
// pagination stays correct, and so any rows the reload pulls in
// from later pages render in place.
const deletedSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
setTotal((prev) => Math.max(0, prev - resp.deleted));
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
clearSelection();
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteSelected, "error");
} finally {
setDeletingSelected(false);
}
}, [
clearSelection,
expandedId,
loadSessions,
page,
refreshEmptyCount,
selectedIds,
showToast,
t.sessions.failedToDeleteSelected,
t.sessions.selectedSessionsDeleted,
]);
const handleDeleteEmpty = useCallback(async () => {
setDeletingEmpty(true);
try {
const resp = await api.deleteEmptySessions();
// Show count in the toast so users get confirmation of the actual
// number removed (which may differ slightly from `emptyCount` if a
// session entered/left the "empty" set between the count fetch and
// the delete — e.g. an active session just ended without sending
// any messages).
showToast(
t.sessions.emptySessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteEmptyOpen(false);
// Reload the current page so any newly-vanished empty sessions
// drop out of the visible list, and re-fetch the empty count so
// the button hides itself.
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteEmpty, "error");
} finally {
setDeletingEmpty(false);
}
}, [
loadSessions,
page,
refreshEmptyCount,
showToast,
t.sessions.emptySessionsDeleted,
t.sessions.failedToDeleteEmpty,
]);
const handleRename = useCallback(
async (id: string, title: string) => {
try {
@@ -898,6 +1132,33 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<DeleteConfirmDialog
open={deleteEmptyOpen}
onCancel={() => setDeleteEmptyOpen(false)}
onConfirm={handleDeleteEmpty}
title={t.sessions.deleteEmptyConfirmTitle}
description={t.sessions.deleteEmptyConfirmMessage.replace(
"{count}",
String(emptyCount),
)}
loading={deletingEmpty}
/>
<DeleteConfirmDialog
open={deleteSelectedOpen}
onCancel={() => setDeleteSelectedOpen(false)}
onConfirm={handleDeleteSelected}
title={t.sessions.deleteSelectedConfirmTitle.replace(
"{count}",
String(selectedIds.size),
)}
description={t.sessions.deleteSelectedConfirmMessage.replace(
"{count}",
String(selectedIds.size),
)}
loading={deletingSelected}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
@@ -1084,7 +1345,7 @@ export default function SessionsPage() {
className="w-fit shrink-0"
size="md"
value={view}
onChange={setView}
onChange={switchView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
@@ -1102,7 +1363,7 @@ export default function SessionsPage() {
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => updateSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
@@ -1110,7 +1371,7 @@ export default function SessionsPage() {
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
onClick={() => updateSearch("")}
aria-label={t.common.clear}
>
<X />
@@ -1118,6 +1379,23 @@ export default function SessionsPage() {
)}
</div>
)}
{showList && emptyCount > 0 && !isSearching && (
<Button
outlined
destructive
size="sm"
className="shrink-0"
onClick={() => setDeleteEmptyOpen(true)}
aria-label={t.sessions.deleteEmpty}
title={t.sessions.deleteEmpty}
>
<Eraser className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteEmpty} ({emptyCount})
</span>
</Button>
)}
</div>
{showPagination && (
@@ -1126,12 +1404,77 @@ export default function SessionsPage() {
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</div>
) : null}
{showList && selectedIds.size > 0 && (
<div
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
role="region"
aria-label={t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
>
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
{t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
</span>
{filtered.some((s) => !selectedIds.has(s.id)) && (
<Button
ghost
size="sm"
onClick={() => selectAllOnPage(filtered)}
aria-label={t.sessions.selectAllOnPage}
title={t.sessions.selectAllOnPage}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.selectAllOnPage}
</span>
</Button>
)}
<Button
ghost
size="sm"
onClick={clearSelection}
aria-label={t.sessions.clearSelection}
title={t.sessions.clearSelection}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.clearSelection}
</span>
</Button>
<Button
outlined
destructive
size="sm"
className="ml-auto"
onClick={() => setDeleteSelectedOpen(true)}
aria-label={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
title={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
</span>
</Button>
</div>
)}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@@ -1148,16 +1491,20 @@ export default function SessionsPage() {
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
{filtered.map((s, index) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
isSelected={selectedIds.has(s.id)}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onSelectClick={(event) =>
handleSelectClick(event, index, filtered)
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
@@ -1170,7 +1517,7 @@ export default function SessionsPage() {
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</>
@@ -1238,6 +1585,20 @@ export default function SessionsPage() {
);
}
interface SessionRowProps {
isExpanded: boolean;
isSelected: boolean;
onDelete: () => void;
onExport: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onSelectClick: (event: React.MouseEvent) => void;
onToggle: () => void;
resumeInChatEnabled: boolean;
searchQuery?: string;
session: SessionInfo;
snippet?: string;
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
+20 -30
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
Activity,
Brain,
@@ -21,12 +22,12 @@ import {
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@@ -236,16 +237,9 @@ export default function SystemPage() {
};
// ── Memory ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
}
};
// Memory provider selection lives on the /plugins page now (see the
// read-only display + link below); the dropdown was intentionally
// dropped from this card during the admin-panel refresh.
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
@@ -748,26 +742,22 @@ export default function SystemPage() {
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid gap-2 max-w-sm">
<Label htmlFor="mem-provider">External provider</Label>
<Select
id="mem-provider"
value={memory?.active || ""}
onValueChange={setMemoryProvider}
>
<SelectOption value="">Built-in only</SelectOption>
{(memory?.providers ?? []).map((p) => (
<SelectOption key={p.name} value={p.name}>
{p.name}
{p.configured ? " (configured)" : ""}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Set up a new provider's credentials with{" "}
<span className="font-mono">hermes memory setup</span>.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
External provider:{" "}
<span className="font-mono text-foreground">
{memory?.active || "built-in only"}
</span>
</span>
<Link to="/plugins" className="underline">
Change in Plugins
</Link>
<span className="ml-auto">
New credentials:{" "}
<span className="font-mono">hermes memory setup</span>
</span>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
+67 -4
View File
@@ -19,6 +19,7 @@ import type {
ThemeLayoutVariant,
ThemeListEntry,
ThemePalette,
ThemeSeriesColors,
ThemeTypography,
} from "./types";
import { api } from "@/lib/api";
@@ -27,6 +28,21 @@ import { api } from "@/lib/api";
* a visible flash of the default palette on theme-overridden installs. */
const STORAGE_KEY = "hermes-dashboard-theme";
/** Renames of built-in theme keys we've shipped previously. Without this,
* users who saved one of the old names in localStorage (or had it
* persisted server-side) would silently fall back to `defaultTheme`
* because the lookup in `resolveTheme` no longer finds the stale key.
* Keep entries here until enough release cycles have passed that we can
* reasonably assume nobody still has the old value persisted. */
const THEME_NAME_ALIASES: Record<string, string> = {
// Renamed during the LENS_5I port + Nous-blue rebrand.
"lens-5i": "nous-blue",
};
function migrateThemeName(name: string): string {
return THEME_NAME_ALIASES[name] ?? name;
}
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
@@ -126,6 +142,30 @@ function overrideVars(
return out;
}
/** Map data-series accents to their CSS vars. Themes omit either field to
* inherit the `:root` default from `index.css`; when omitted we also
* proactively clear any leftover value from a previous theme so switches
* don't carry stale colors. */
const SERIES_KEY_TO_VAR: Record<keyof ThemeSeriesColors, string> = {
inputTokenAccent: "--series-input-token",
outputTokenAccent: "--series-output-token",
};
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
function seriesColorVars(
series: ThemeSeriesColors | undefined,
): Record<string, string> {
if (!series) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(series)) {
if (!value) continue;
const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
@@ -268,6 +308,12 @@ function applyTheme(theme: DashboardTheme) {
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Same clear-then-set for series colors so a theme that defines them
// (e.g. Nous Blue) doesn't leave its values behind when the user
// switches to a theme that inherits the `:root` defaults.
for (const cssVar of ALL_SERIES_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
@@ -287,6 +333,7 @@ function applyTheme(theme: DashboardTheme) {
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...seriesColorVars(theme.seriesColors),
...assetMap,
...componentMap,
};
@@ -313,7 +360,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default";
const migrated = migrateThemeName(stored);
// Write the migrated name back so future reads converge on the new
// key and we eventually retire the alias entry.
if (migrated !== stored) {
window.localStorage.setItem(STORAGE_KEY, migrated);
}
return migrated;
});
/** All selectable themes (shown in the picker). Starts with just the
@@ -377,9 +431,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
if (resp.active) {
const migratedActive = migrateThemeName(resp.active);
if (migratedActive !== themeName) {
setThemeName(migratedActive);
window.localStorage.setItem(STORAGE_KEY, migratedActive);
}
// If the server is still persisting the stale key, push the
// migrated value back so it converges too — otherwise every
// future page load would re-trigger this branch.
if (migratedActive !== resp.active) {
api.setTheme(migratedActive).catch(() => {});
}
}
})
.catch(() => {});
+95
View File
@@ -184,6 +184,100 @@ export const roseTheme: DashboardTheme = {
},
};
/**
* Nous Blue the inverted "light mode" Hermes look, ported from the
* LENS_5I overlay preset in `@nous-research/ui`.
*
* Unlike the other built-ins (which paint dark color directly on the
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
* that flips the entire stack below it. Authoring colors stay dark
* (`#170d02` brown background, `#FFAC02` orange midground), and the
* inversion converts them to their visual complements at paint time
* the orange midground reads as #0053FD Nous-blue on screen, against a
* cream `#E8F2FD` canvas.
*
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
* so we leave the bg layer's blend mode at the `difference` default
* `difference(#170d02, #000)` passes the bg through unchanged, and the
* subsequent FG-difference layer then inverts it to cream. Using
* `multiply` here would collapse the bg to pure black against the
* `bg-black` root and produce a plain-white canvas instead of the
* intended cream-blue.
*
* Source of truth for the palette: `design-language/src/ui/components/
* overlays/lens.ts` (LENS_5I export).
*/
export const nousBlueTheme: DashboardTheme = {
name: "nous-blue",
label: "Nous Blue",
description: "Light mode — vivid Nous-blue accents on cream canvas",
palette: {
background: { hex: "#170d02", alpha: 1 },
midground: { hex: "#FFAC02", alpha: 1 },
foreground: { hex: "#FFFFFF", alpha: 1 },
// Same warm-amber as nousnet-web's overlay glow; after the FG
// inversion it reads as a cool ultraviolet vignette in the top-left.
warmGlow: "rgba(255, 172, 2, 0.18)",
// Noise sits above the FG inversion and is NOT flipped, so a softer
// multiplier keeps it from speckling over the bright post-inversion
// canvas.
noiseOpacity: 0.4,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
// Inverted page: the embedded terminal is below the FG layer too, so
// a `#000000` source paints as visual white — i.e. a proper light-mode
// terminal pane. xterm picks lighter palette colors against the "black"
// canvas, which then read as dark text on screen post-inversion.
terminalBackground: "#000000",
componentStyles: {
backdrop: {
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
// gets amplified post-inversion: small variations against the deep
// `#170d02` source bg are barely visible, but those same variations
// against the bright `#E8F2FD` post-inversion canvas read as a
// heavy cloud/marble pattern — especially on near-empty pages
// (loading spinners, blank states). 0.02 keeps subtle grain
// without overwhelming the canvas.
fillerOpacity: "0.02",
},
},
// Pre-invert absolute-hex tokens so they read as their familiar colors
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
// Without these, the default destructive/success/warning tokens would
// appear as their unintuitive complements.
colorOverrides: {
destructive: "#04d3c9",
destructiveForeground: "#000000",
success: "#b5217f",
warning: "#0042c7",
},
// Pre-inverted data-series accents for the Analytics/Models token
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
// through the FG difference layer as dark navy + hot-coral on the
// bright Nous-blue canvas — the coral is the "red" users see for
// Output values without these overrides. Source → on-screen:
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
// Input keeps the cream source so it stays a neutral, low-contrast
// dark-blue against the cream canvas; output paints as the brand
// Nous-blue so the "primary" series in token-flow charts reads as
// the highlight color, matching the rest of the inverted UI chrome.
seriesColors: {
inputTokenAccent: "#ffe6cb",
outputTokenAccent: "#ffac02",
},
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
// amber rgba) doesn't reflect what users see after the FG inversion,
// so we paint the post-inversion visual triplet directly:
// white → vivid Nous-blue → cream/light-blue
// matching the actual on-screen rendering of the theme.
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
};
/**
* Same look as ``defaultTheme`` but with a larger root font size, looser
* line-height, and ``spacious`` density so every rem-based size in the
@@ -208,6 +302,7 @@ export const defaultLargeTheme: DashboardTheme = {
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
"default-large": defaultLargeTheme,
"nous-blue": nousBlueTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
+28
View File
@@ -119,6 +119,25 @@ export interface ThemeComponentStyles {
page?: Record<string, string>;
}
/** Data-series accent colors for chart + table visualisations (Analytics,
* Models, etc.). Themes provide hex strings; the provider emits them as
* `--series-input-token` / `--series-output-token` CSS vars consumed
* inline by pages that render input-vs-output token flows. Themes can
* omit either field to inherit the default token defined in
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
*
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
* values so they read as their intended visual color after the FG
* difference layer flips them (`out = 255 channel`). E.g. to make
* output paint as Nous-blue `#0053FD` on screen, set
* `outputTokenAccent: "#FFAC02"` the difference math reverses it. */
export interface ThemeSeriesColors {
/** Input-tokens series accent (Analytics chart bars + table values). */
inputTokenAccent?: string;
/** Output-tokens series accent. */
outputTokenAccent?: string;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
@@ -162,6 +181,15 @@ export interface DashboardTheme {
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
/** Data-series accent colors for Analytics/Models token charts.
* See `ThemeSeriesColors` for inversion-aware values. */
seriesColors?: ThemeSeriesColors;
/** Explicit 3-color swatch override for the theme picker. Use when the
* palette's raw hex values don't reflect what users see on screen
* e.g. inverted "lens" themes whose foreground-difference layer flips
* the authored colors to their visual complements. Order matches the
* default swatch cells: [background, midground, warmGlow]. */
swatchColors?: [string, string, string];
/** Background color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#000000"` when absent. */
terminalBackground?: string;

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