Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 237807ad3a Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 19:39:58 -05:00
Brooklyn Nicholson d95c76aa37 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 19:38:32 -05:00
kshitij 66a6b9c930 Merge pull request #39482 from liuhao1024/fix/rich-markup-error-on-session-resume
fix(cli): use Rich [dim] tag instead of ANSI escape in session resume messages
2026-06-05 13:12:17 -07:00
kshitij e6f7e217ce Merge pull request #40093 from kshitijk4poor/feat/named-custom-discover-models-18726
feat(model): honor discover_models in terminal hermes model named-custom flow (closes #18726)
2026-06-05 13:08:33 -07:00
kshitij b5d42daa53 Merge pull request #40080 from kshitijk4poor/salvage/discover-models-section4-29810
feat(model_switch): honor discover_models in custom_providers section 4 (salvage #29810)
2026-06-05 13:05:34 -07:00
kshitijk4poor 7ae8aac3b9 feat(model): honor discover_models in terminal hermes model named-custom flow
The terminal `hermes model` wizard (_model_flow_named_custom) always
live-probed a custom provider's /models endpoint, ignoring the configured
`models:` list. For plans whose endpoint exposes a large catalog (e.g. Baidu
Qianfan Coding Plan returns 100+ models for a 2-3 model plan) the picker
flooded with models the user can't use.

This wires `discover_models` (and the `models:` list) through
_named_custom_provider_map into the flow and honors `discover_models: false`
the same way the slash-command picker (model_switch.py sections 3 & 4) does:
- Default stays True — live probe, no behaviour change.
- discover_models: false → use the configured `models:` list verbatim,
  skip the probe (string 'false'/'no'/'0' normalised to False).
- If the probe is on but returns empty, fall back to the configured list
  instead of forcing manual entry.

Closes #18726
2026-06-06 01:29:41 +05:30
kshitijk4poor 53bba70854 chore: add ohMyJason to AUTHOR_MAP 2026-06-06 01:04:25 +05:30
ohMyJason 4b2d00f845 feat(model_switch): honor discover_models in custom_providers section 4
Section 3 (user `providers:`) already honors `discover_models: false` to
skip live /models discovery and keep the explicit `models:` list. Section 4
(`custom_providers:` list) did not — `should_probe` ignored the field, so any
grouped custom provider with an api_key always had its configured subset
replaced by the full live /models catalog.

This adds the same `discover_models` support to section 4:
- Default True — no behaviour change for existing configs.
- `discover_models: false` keeps the explicit `models:` list even when an
  api_key is present.
- String values ("false"/"no"/"0") are normalised to False, matching
  section 3.
- If any entry in a grouped endpoint opts out, the whole group opts out.

Use case: endpoints that expose a full aggregator catalog via /models but
only serve a configured subset.

Salvaged from #29810 — rebased onto current main. The PR's other change
(`key_env` resolution in section 4) landed independently in commit aa283d1e4
(custom provider picker credential isolation), so only the discover_models
portion is carried here.

Co-authored-by: ohMyJason <42903577+ohMyJason@users.noreply.github.com>
2026-06-06 01:04:13 +05:30
brooklyn! 6f6eb871d8 fix(gateway): new chats honor their profile in global-remote mode (#39993)
Follow-up to #39921. That PR scoped session.resume + prompt.submit to a
session's profile, but a BRAND-NEW chat (session.create) under a non-launch
profile was still built and persisted against the dashboard's launch profile.
Two visible symptoms in app-global remote mode (one dashboard, many profiles):

  1. "who are you" in profile S replied as the launch (default) profile/agent —
     the agent was built with the launch HERMES_HOME, so config/SOUL/identity
     came from the wrong profile.
  2. "session not found" on later resume — _ensure_session_db_row persisted the
     row into the launch profile's state.db via _get_db(), so the session lived
     in the wrong db, the unified list mis-tagged it (it showed up under BOTH
     profiles), and resume routed to the wrong one.

Fix — carry the owning profile through the create path too:

- session.create accepts an optional `profile`; resolves its home and stores
  `profile_home` on the session (alongside what resume already set).
- _start_agent_build binds that profile's HERMES_HOME while building the agent
  (config/skills/model/identity resolve to it) and hands the agent the profile's
  state.db so turns persist there.
- _ensure_session_db_row writes the row into the profile's state.db, not the
  launch db — fixing the duplicate row + mis-tag + resume 404.
- desktop sends the new-chat profile on session.create.

None/launch profile → unchanged (single-profile and per-profile-remote setups
take the same path). Verified live against a one-dashboard / multi-profile
remote: a new chat under `work` builds as work's agent (correct SOUL identity),
persists ONLY to work's state.db (launch db stays empty), the unified list tags
it `work` exactly once, and it resumes cleanly.

tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db
param added in #39921's build path.
2026-06-05 17:44:45 +00:00
Jim Liu 宝玉 1d9c3ebae0 feat(desktop): persist i18n language in config 2026-06-05 10:32:26 -07:00
Jim Liu 宝玉 4a1907bd10 feat(desktop): add i18n with Simplified Chinese (zh-Hans) support
Introduce a lightweight React context-based i18n layer for the desktop
app and translate the UI into Simplified Chinese.

- New apps/desktop/src/i18n module: typed Translations interface, en + zh
  locale tables, I18nProvider/useI18n, localStorage-persisted locale
  (defaults to English), and language endonym metadata for the picker.
- Wire I18nProvider at the app root in main.tsx.
- Refactor 24 desktop screens/components to read strings from the `t`
  object instead of hard-coded English.
- Add a unit test for the i18n context.
2026-06-05 10:32:26 -07:00
brooklyn! 02d6bf1c39 fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)
* fix(desktop): cross-profile session history in app-global remote mode

#39894 made remote-profile sessions first-class for PER-PROFILE remote
overrides. But the common setup — Settings → Gateway → "All profiles" → Remote
— writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty
profiles map), which the intercept didn't recognize. Switching to a non-launch
profile then 404'd every session read, so no history showed for it.

In global remote mode a SINGLE backend serves every profile via ?profile= (it
reads each profile's state.db off the remote host's own disk — verified: one
dashboard returns /api/profiles and /api/profiles/sessions?profile=all across
all profiles). The fix: when no per-profile override matches but global remote
mode is active, route per-session reads/mutations to that one backend and KEEP
the ?profile= param so it opens the right state.db (instead of bailing to the
local path and dropping the profile scope).

- new globalRemoteActive() — true for connection.json mode:'remote' or the
  HERMES_DESKTOP_REMOTE_URL env override.
- per-session branch: per-profile override → route sans profile (own db);
  global mode → route to the single backend WITH ?profile= preserved.
- unified list is unchanged in global mode: it already passes through to the one
  backend, which aggregates all profiles natively.

Verified live against a one-dashboard / multi-profile remote (Austin's topology):
cross-profile transcript reads load (was 404), rename/delete route to the right
profile, unified list spans both profiles.

Known limitation (architectural, not fixed here): LIVE chat as a non-launch
profile still needs a per-profile dashboard on the remote — the dashboard binds
HERMES_HOME once at process start, so one global backend can't run an agent
turn as another profile. Session history/read/mutate now work regardless.

* fix(gateway): resume + chat any profile over one global-remote dashboard

The REST half of this branch made cross-profile session history visible in
app-global remote mode, but resume + chat still went over the WebSocket gateway,
which was hard-bound to the dashboard's launch profile. Resuming a non-launch
profile's session 404'd ("session not found") and sending spawned a new session
— because session.resume/prompt.submit had no profile concept and the live
agent + state.db were process-global to the launch profile's HERMES_HOME.

Make the WS gateway per-session profile-aware so ONE dashboard can serve every
local profile on its host (the app-global remote topology):

- session.resume accepts an optional `profile`. _profile_home() resolves that
  profile's home on this host; resume opens THAT profile's state.db, binds its
  HERMES_HOME (ContextVar override) while building the agent so config/skills/
  model resolve to it, and passes the profile db to the agent so turns persist
  to the right state.db. The owning profile_home is stored on the session.
- prompt.submit re-binds the stored profile_home for the turn thread (mid-turn
  home reads — memory, skills — resolve to the resumed profile), reset in finally.
- _make_agent gains an optional session_db param (defaults to _get_db()).
- _load_cfg honors the home override (falls back to _hermes_home) so a resumed
  profile loads its own config; cache keyed on resolved path.
- desktop: session.resume now sends the owning profile.

Omitted/launch profile → unchanged (single-profile and per-profile-remote setups
are byte-for-byte the same path). Verified live against a one-dashboard /
multi-profile remote: resuming a non-launch profile's session loads its history,
runs a real turn against THAT profile's home/env, and persists to its state.db.

tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param.
2026-06-05 12:22:55 -05:00
teknium1 e837856ecd chore(release): map ViewWay author email for AUTHOR_MAP 2026-06-05 09:10:26 -07:00
teknium1 2dda393f9f test(gateway): regression tests for max_tokens propagation chain (#20741) 2026-06-05 09:10:26 -07:00
teknium1 14275d7baa fix(gateway): honor per-provider max_output_tokens in max_tokens chain
Widens ViewWay's #20741 fix to the sibling config surface: a
custom_providers entry can pin its own output cap via max_output_tokens
(or max_tokens). _get_named_custom_provider now lifts it onto the
resolved runtime at all three return sites, and the gateway uses it as a
fallback only when the documented global model.max_tokens isn't set, so
the global key always wins.

Precedence: HERMES_MAX_TOKENS > model.max_tokens > provider
max_output_tokens > None. Closes the same #20741 truncation for users who
configure the cap per-provider rather than globally.

Picks up the intent of #19782 (alexcam1901), reimplemented to feed
ViewWay's max_tokens pipeline.
2026-06-05 09:10:26 -07:00
ViewWay 1c909e75e1 fix(cli,gateway): complete max_tokens propagation — CLI path + env var override
Previous commit only covered the gateway runtime path. This adds:
- CLI __init__: read max_tokens from model config with HERMES_MAX_TOKENS env override
- CLI AIAgent() calls (interactive + background): pass max_tokens
- Gateway _resolve_runtime_agent_kwargs: add HERMES_MAX_TOKENS env override

All three code paths (CLI, gateway runtime, session override) now
consistently propagate max_tokens to AIAgent.
2026-06-05 09:10:26 -07:00
ViewWay cf786593cd fix(gateway): propagate max_tokens from config.yaml to AIAgent
max_tokens set under model: in config.yaml was silently ignored.
The value was never read from config, never passed through
_resolve_runtime_agent_kwargs(), _resolve_turn_agent_config(),
or the session override path.  Added it to all three code paths
so custom/Ollama endpoints receive the correct output cap.

Closes #20741
2026-06-05 09:10:26 -07:00
brooklyn! 9af54b2f8c fix(desktop): make remote-profile sessions first-class (resume, read, rename/archive/delete) (#39894)
* fix(desktop): route remote-profile session reads to the owning remote backend

Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.

Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):

  GET /api/profiles/sessions        -> splice each remote profile's real rows in
  GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles

No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.

Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).

* fix(desktop): route remote-profile session mutations + fix unified-list pagination

Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.

Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.

- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
  profile and pass it as request.profile so Electron routes to that profile's
  backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
  DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
  param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
  GET/PATCH (local cross-profile delete).

Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.

Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
2026-06-05 10:13:10 -05:00
Brooklyn Nicholson 3045d54547 fix(desktop): route remote-profile session mutations + fix unified-list pagination
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.

Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.

- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
  profile and pass it as request.profile so Electron routes to that profile's
  backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
  DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
  param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
  GET/PATCH (local cross-profile delete).

Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.

Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
2026-06-05 10:08:26 -05:00
Brooklyn Nicholson 83c13862f1 fix(desktop): route remote-profile session reads to the owning remote backend
Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.

Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):

  GET /api/profiles/sessions        -> splice each remote profile's real rows in
  GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles

No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.

Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).
2026-06-05 09:52:52 -05:00
adybag14-cyber af8b917dab fix(termux): scope frontend npm installs 2026-06-05 06:56:51 -07:00
Teknium 9ca11b35d5 perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* perf(/model): prewarm picker provider-models cache in background

The no-args /model picker calls list_authenticated_providers(), which
fetches each authenticated provider's live /v1/models list serially. On a
cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path
the first time /model is opened in a session.

Warm that exact path off-thread during the idle window right after the CLI
banner is shown: a once-per-process daemon thread runs
list_authenticated_providers() to populate provider_models_cache.json for
every authed provider. By the time the user types /model, the picker hits
the warm disk cache (~136ms vs ~1500ms).

Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done)
ensures at most one thread per process; fully exception-isolated so an
offline/no-creds provider can never affect the session.
2026-06-05 06:55:09 -07:00
Teknium ca1fb32c26 docs: remove --include-desktop install instructions (#39762)
* docs: remove --include-desktop install instructions

Drop the --include-desktop curl one-liner from the desktop app docs.
The flag remains in scripts/install.sh; these docs now point to the
desktop installer / website and the 'hermes desktop' path instead.

* docs: remove --include-desktop from install docs

Drop the redundant 'Hermes Desktop installer on Linux' block (which
used --include-desktop) from quickstart, installation, and index docs.
The website installer covers macOS/Windows desktop; the CLI-only path
covers Linux. Removes the flag from all user-facing docs.
2026-06-05 06:53:58 -07:00
Teknium 7583aedacd fix(completion): remove /model <arg> autocomplete from CLI/TUI (#39727)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(completion): remove /model <arg> autocomplete from CLI/TUI

The TUI frontend already suppressed /model argument completion in favor of
the two-step ModelPicker (useCompletion.ts), but the CLI prompt_toolkit
completer and the gateway-backed complete.slash RPC (TUI + desktop) still
emitted model aliases and probed LM Studio on every keystroke.

Drops the /model branch in SlashCommandCompleter.get_completions, the
_model_completions method, and the LM Studio probe/cache helper that only
fed it. Command-name completion (/mod -> model) and sibling arg completers
(/skin, /personality) are untouched. Removes the now-dead TestModelTabCompletion
tests.
2026-06-05 06:43:51 -07:00
brooklyn! 14fee4f112 fix(update/windows): retry handoff hermes update once on first-run crash (#39831)
The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily
imports the freshly-pulled modules — but the dependency-install step runs the
already-in-memory PRE-pull code for one invocation. When a release changes an
updater-path contract across that boundary, the FIRST update on the parked
population crashes even though the fix is already on disk.

Concretely this is #39780's `_UvResult`: its `__iter__` yields (path, bool), so
Windows `subprocess.list2cmdline([uv_bin, "pip", ...])` injects the bool and
dies with `TypeError: sequence item 1: expected str instance, bool found`
(fixed in #39820). A parked Windows user clicking Update pulls #39820 to disk,
then still crashes on the in-memory pre-merge module; only the SECOND click runs
clean. Field repro: ryanc's bootstrap.log (2026-06-05 12:41:41).

Fix: when the first `hermes update` exits non-zero (and it isn't the
concurrent-instance guard, exit 2, which a retry can't fix), retry once
automatically. The retry loads the now-current module from the start and
succeeds — so the parked user gets a working one-click update instead of a
scary crash + manual second attempt.

Verified: cargo check clean.
2026-06-05 08:37:16 -05:00
brooklyn! 98528c78c1 fix(desktop/windows): stop racing our own backend during in-app update (#39828)
* fix(desktop/windows): stop racing our own backend during in-app update

The Windows in-app update (Update button -> hermes-setup.exe --update handoff)
bricked because it raced a still-locked hermes.exe: the desktop quit
fire-and-forget without reaping its backend child + grandchildren, so when
the updater ran `hermes update`, the venv shim was still open. The quarantine
rename then failed, uv's `pip install -e .` hit "Access is denied", the git
path bailed to a full ZIP re-download, and the deps still couldn't write the
locked shim -- leaving a half-applied install. macOS is fine because it never
blocks REPLACE on a running executable.

Three coordinated fixes restore Mac-style parity (click Update -> progress ->
relaunch, no terminal):

A. Desktop (main.cjs): before spawning the updater, releaseBackendLockForUpdate()
   tree-kills the primary + pool backends (taskkill /T /F on Windows, to catch
   REPL/pty/gateway grandchildren that SIGTERM misses) and polls the venv shim
   until it is actually writable (bounded 15s) -- so the lock is gone before we
   hand off. Also fixes resolveHermesCliBinary to use venv\Scripts\hermes.exe on
   Windows.

B. Updater (update.rs): wait_for_venv_free no longer "proceeds anyway" on
   timeout -- it force-kills any lingering hermes.exe (excluding itself) and
   re-checks, so a straggler can't doom the install.

C. Updater (update.rs): pass --force to `hermes update`. By contract the desktop
   has exited + waited, and the wait force-kills stragglers, so the running-exe
   guard would only produce a false "Hermes is still running" dead-end.

Verified: node --check on main.cjs, cargo check on the updater (clean), and the
Windows-gated taskkill body type-checks standalone. Field repro: ryanc's
update.log (manual + handoff both hit the same lock cascade).

* review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill
2026-06-05 08:33:53 -05:00
brooklyn! d880b5be09 fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820)
PR #39780 made ensure_uv() return a _UvResult — a str subclass whose
__iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()`
call sites survive the update boundary. That trick is unsafe on Windows.

The dependency installer passes uv straight into the command list
(`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv
via subprocess.list2cmdline, which iterates every entry *as a string*
(`for c in arg`). Because _UvResult overrides __iter__, that iteration yields
(path, fresh_bootstrap) instead of characters, injecting the bool into the
command line and crashing the first update with:

    TypeError: sequence item 1: expected str instance, bool found

This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on
its first update after #39780: the freshly pulled _UvResult flows into the
old in-memory call site and into the argv. Reported in the field on a
~10-commits-behind Windows install.

A single return value cannot satisfy both legacy 2-target unpacking and
Windows char-iteration — both use the iterator protocol with contradictory
results. So gate the wrapper to POSIX: Windows returns a plain str/None
(the historical, subprocess-safe contract). POSIX keeps _UvResult and the
#39780 update-boundary fix.

Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows
returns-plain-str and POSIX dual-contract coverage.
2026-06-05 07:54:08 -05:00
brooklyn! ca8c78e588 fix(desktop): heal stale runtime-id cache + model on profile switch (#39819)
Two switch-time regressions from the multi-profile rail work:

- "Session not found" (4007): pruneSecondaryGateways idle-reaps a
  non-active profile's backend; switching back respawns a *fresh*
  backend that mints new runtime ids, but runtimeIdByStoredSessionId is
  never pruned. resumeSession's cache fast-path then makes a dead runtime
  id active and returns, so session.usage + the next prompt 404. Probe
  the cached id; on rejection drop the stale mapping and fall through to
  a full resume that rebinds a live id.

- "Forgets the LLM setting": $currentModel is a nanostore set only by
  refreshCurrentModel (gatewayState->open, etc). A swap fires
  invalidateQueries() (react-query only) and keeps the socket 'open', so
  the model/pill kept showing the previous profile. Re-pull both when
  $activeGatewayProfile changes.
2026-06-05 12:52:44 +00:00
brooklyn! 1a3e608524 feat(desktop): per-profile remote gateway hosts (#39778)
* feat(desktop): per-profile remote gateway hosts

Profile switching silently failed whenever the desktop was connected to a
remote backend: the rail routed non-active profiles to a local pool backend,
but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a
remote Hermes backend", and the renderer swallowed the error into an infinite
reconnect backoff while still marking the profile active. Remote was also a
single app-global setting, so there was no way to give a profile its own host.

Add per-profile remote hosts so each profile can point at its own backend:

- connection.json gains a validated `profiles` map; profileRemoteOverride()
  (pure, unit-tested) selects an explicit per-profile remote.
- resolveRemoteBackend(profile) precedence: per-profile override → env override
  → global remote → local spawn. spawnPoolBackend now connects to a profile's
  remote (no local child) instead of throwing; startHermes resolves the primary
  profile's remote.
- coerce/sanitize connection config are scope-aware (global vs named profile)
  and preserve each other's entries; IPC get/save/apply/test thread an optional
  profile. Per-profile apply drops only that profile's pool backend.
- Settings → Gateway adds an "Applies to" scope selector reusing the existing
  URL/token/OAuth/test UX per profile.

Tests: connection-config pure suite (+6) and desktop platform suite pass;
tsc/eslint/vitest clean.

* refactor(desktop): DRY per-profile remote helpers

Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
2026-06-05 12:14:18 +00:00
brooklyn! db204ae203 fix(update): make ensure_uv() survive the update boundary (no first-run crash) (#39780)
* fix(update): make ensure_uv() survive the update boundary (no first-run crash)

`hermes update` runs the `ensure_uv()` call site from the old, already-imported
`hermes_cli.main` against the *freshly pulled* `managed_uv` (managed_uv is only
ever lazily imported, so it loads from disk post-pull). `ensure_uv()`'s return
arity flipped from a single path string to `(path, fresh_bootstrap)` (4df280d51)
and back to a single string (fb853a178). Installs parked on a 2-tuple release
unpack `uv_bin, fresh_bootstrap = ensure_uv()` against the new single-value
module and crash the first update with
`ValueError: not enough values to unpack (expected 2, got 1)` — inside the
dependency-install step, *before* the PR #39763 subprocess hand-off can run.

Return a `_UvResult` (a `str` subclass) that is usable as the bare path AND
unpackable as `(path|None, fresh_bootstrap)`. Missing uv is `""` (falsy) instead
of `None` so legacy 2-target call sites can unpack a failure without raising,
while `if not uv_bin` keeps working for single-value callers. fresh_bootstrap is
always False (the rebuild-venv path it gated was scrapped in fb853a178).

* docs(update): correct the verified error string + mechanism for ensure_uv()

A hermetic repro (old 2-target call site vs the freshly-pulled single-value
module) shows the first-update crash is exactly the string from PR #39763's
report: `ValueError: too many values to unpack (expected 2)` — not "not enough".
The returned path is a plain `str`, which is iterable, so `uv_bin, fresh =
ensure_uv()` walks its characters; the failure path's `None` return raises
`TypeError: cannot unpack non-iterable NoneType`. Both are fixed by `_UvResult`.
Comment/test wording updated to match; no behavior change.
2026-06-05 07:08:43 -05:00
Tekniumandalt-glitch 72eb42d9ec feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean"

This reverts commit c8e80cd0bf.

* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"

This reverts commit 8a19884bf3.

* chore(install): keep npm ci desktop-build fix after stash revert

The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.

* feat(update): settable stash-or-discard for non-interactive local changes

Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.

- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
  reads the setting; new _discard_stashed_changes() drops the stash
  (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
  Post-pull restore site branches on it; the bail-out and up-to-date
  restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
  select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
  missing section defaults to stash).

* fix(install.ps1): stash/restore instead of reset --hard on Windows update

The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).

- Replace `git reset --hard HEAD` with stash-before-checkout +
  restore-after-checkout, mirroring install.sh. Untracked files are
  included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
  the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
  `git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
  + non-redirected stdin/stdout + ConsoleHost); the desktop Update button
  and bootstrap have no usable console, so they default to restore and
  never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
  recovery instructions — work is never silently dropped.

Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-06-05 17:30:10 +05:30
Teknium 947e21b3d6 fix(gateway): log silent file-delivery drops (#39767)
When the agent's reply references a deliverable file path that does not
exist on disk, extract_local_files dropped it from native delivery with
no log line — the most common reason a promised file never arrives over
a messaging platform. Add an INFO log at that drop point so the gap is
visible in gateway.log instead of vanishing.

Also convert the two print() calls in Telegram's send_document /
send_video exception handlers to logger.warning(exc_info=True). print()
writes to stdout, which 'hermes logs' never captures, so outbound upload
failures (oversized files, Bot API rejections) were invisible.
2026-06-05 04:50:04 -07:00
Teknium d41427504e feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* feat(delegation): uncap max_spawn_depth to match max_concurrent_children

Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has
a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost
(each level multiplies API spend) is the practical limiter, not a constant.

- delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors
  at 1 instead of clamping to [1,3]; depth-limit error string reworded
- config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling
- docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1
- tests: convert clamp-above-3 change-detector into a no-ceiling invariant,
  drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert
2026-06-05 04:46:02 -07:00
Teknium 06268f11cc feat(gateway): explain /voice usage when toggled bare (#39766)
A bare /voice silently toggled on/off with a one-line result, leaving
users with no idea what the modes mean or that Discord also supports
TTS-all and live voice-channel join/leave. Bare /voice now still
toggles but appends a usage explainer covering on/off/tts/status, with
the Discord voice-channel lines shown only on adapters that support
them.

Adds gateway.voice.help + gateway.voice.help_channels across all 16
locales (placeholders {toggle}/{channels}).
2026-06-05 04:21:13 -07:00
Frowtek 3cd1bd971f fix(cli): require Chromium for local browser readiness in setup/status surfaces 2026-06-05 04:06:17 -07:00
Teknium ec46f5912e fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints

Two distinct failures hit users on the gemini provider with only Google
AI Studio keys set.

1. Truncation loop: build_gemini_request() only set maxOutputTokens when
   max_tokens was non-None. Hermes passes None to mean "unlimited", but
   Gemini's native generateContent does NOT treat an absent maxOutputTokens
   as full budget — it applies a low internal default and stops early with
   finishReason=MAX_TOKENS, truncating tool calls. The agent then retries
   3x and refuses the incomplete call. Now default to the published 65,535
   ceiling (shared by all current Gemini text models) when max_tokens=None.

2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles
   profile extra_body (Nous portal 'tags', reasoning, provider prefs) and
   sends it via the OpenAI client to whatever base_url is resolved. When a
   profile that emits extra_body (e.g. Nous) is active but the endpoint is a
   native Gemini base_url — typical when only Google creds exist and a
   fallback/aux call lands on Gemini — Google rejects the unknown 'tags'
   field with a non-retryable 400. Strip all non-thinking_config extra_body
   keys when the resolved endpoint is native Gemini.

Verified E2E against real transport code: tags stripped on native Gemini,
preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535
on None, explicit values respected.
2026-06-05 03:53:59 -07:00
Shannon SandsandTeknium 6bf55a473e Add CLI Telegram QR onboarding
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 03:20:10 -07:00
Teknium 8a9ded5b21 feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS (#39659)
* feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS

Discord voice mode can now feel conversational: the bot speaks a short
acknowledgement before it starts working, and a subtle ambient 'thinking' bed
plays underneath while tools run, ducking under speech and swelling back — the
Grok-voice-mode feel.

discord.py plays only one audio stream per voice connection, so this adds a
software mixer (VoiceMixer, a discord.AudioSource) installed once per guild on
join. It sums an ambient loop, verbal acks, and TTS replies into that single
20ms/48kHz/stereo stream (numpy int16 add + clip), so they overlap instead of
stop-and-swap. Speech ducks the ambient gain down and releases it smoothly.

- plugins/platforms/discord/voice_mixer.py: VoiceMixer + MixerChild (gain,
  loop, fade, duck/release), decode_to_pcm (ffmpeg), synth_ambient_pcm (no
  asset needed — synthesised pad).
- adapter: install mixer on join, tear down on leave, route
  play_in_voice_channel through the mixer (legacy one-shot path kept as
  fallback), play_ack_in_voice, voice_mixer_active. Defensive getattr for the
  object.__new__ test helpers.
- gateway/run.py: tool_start_callback fires a one-time verbal ack on the first
  tool call of a turn when in a voice channel (independent of the text
  tool-progress gate). No system-prompt or message-flow changes.
- config: discord.voice_fx.* (OFF by default; ambient/duck/speech gains, ack
  phrases). All in config.yaml, not .env.
- docs + tests (mixer unit + adapter integration).

Verified: 19 new tests pass, existing voice suite green (2 pre-existing
davey-module env failures unchanged), and a real-mixer E2E confirms ambient
streams, TTS overlaps it, acks layer in, and teardown is clean.

* fix(discord): make voice mixer numpy import lazy (numpy is voice-extra-only)

numpy ships in the optional 'voice' extra, not [all,dev], so a module-level
'import numpy' broke CI test collection (and would break the always-imported
Discord adapter on any install without the voice extra). Defer numpy to the
functions that actually mix audio via _require_numpy(); guard the test module
with pytest.importorskip('numpy').
2026-06-05 03:10:40 -07:00
teknium1 3da44dbda7 fix(models): use deepseek-v4-flash as Nous silent default
Follow-up on the salvaged fix: point the Nous silent-default override at
deepseek/deepseek-v4-flash (a cheap chat model) instead of the nvidia
nemotron entry. Keeps the no-model-configured fallback off the priciest
flagship while landing on a low-cost, broadly-capable default.
2026-06-05 02:54:34 -07:00
xxxigm ef5e48f3fd test(models): guard Nous silent default against expensive-flagship escalation
Assert get_default_model_for_provider("nous") never returns the priciest
catalog entry (anthropic/claude-opus-4.8) and that an override pointing at a
model absent from the catalog falls back to catalog order. Regression for the
silent flagship-billing footgun.
2026-06-05 02:54:34 -07:00
xxxigm 2a82519b0d fix(models): don't silently default Nous to the most expensive flagship
When a provider is configured but no model is selected (e.g. a profile sets
provider: nous with no model), the gateway/CLI fall back to
get_default_model_for_provider(), which returned the first curated catalog
entry. The Nous Portal list is ordered most-capable-first, so entry [0] is
anthropic/claude-opus-4.8 — the single most expensive model ($5/$25 per Mtok).
A misconfigured profile therefore silently routed every call to the flagship
and billed it for traffic the user never opted into.

Pin the silent (non-interactive) default for metered aggregators to the cheapest
curated tier via _PROVIDER_SILENT_DEFAULT_OVERRIDES so a missing model can never
auto-escalate to the flagship. The interactive default (GUI onboarding /
`hermes model`) keeps using the richer free/paid-tier-aware resolver.

Fixes the unexpected anthropic/claude-opus-4.8 charges reported for a
free-tier Nous account whose new profile had no default model.
2026-06-05 02:54:34 -07:00
teknium1 397d492b3e chore(release): map harjoth.khara@gmail.com → harjothkhara for #38550 salvage 2026-06-05 02:54:32 -07:00
harjoth b459bac02c fix(cli): gitignore Desktop bootstrap marker so hermes update stops autostashing it
The Desktop bootstrap installer writes `.hermes-bootstrap-complete` into the
managed git checkout root. Because it wasn't gitignored, `hermes update`'s
`git stash push --include-untracked` treated it as a local change and created an
autostash on every run — prompting the user to restore "local changes" that were
really Hermes-managed runtime state (and risking the marker getting stranded in a
stash, which re-triggers Desktop bootstrap).

Add the marker to .gitignore; `git stash -u` and `git status --porcelain` both
skip ignored files, so the updater now sees a clean tree.

Fixes #38529
2026-06-05 02:54:32 -07:00
Coy Geek 3278b423d5 fix(dashboard): strip session token from subprocess env
Add HERMES_DASHBOARD_SESSION_TOKEN to the Hermes-managed subprocess environment blocklist so dashboard authorization material does not propagate into shell, PTY, or background process launches.

Extend the local environment blocklist regression coverage to prove the dashboard session token is stripped like other Hermes-managed secrets.
2026-06-05 02:31:19 -07:00
Ben Barclay 9ab9c923da docs(dashboard): clarify auth provider suitability + registration across dashboard/Docker/Desktop docs (#39633)
* docs(dashboard): clarify auth provider suitability + document dashboard registration

- Add a 'Registering a dashboard' subsection under the Nous Research
  provider covering both the 'hermes dashboard register' CLI command
  and the Portal /local-dashboards GUI page.
- Note that the Nous provider is the one suitable for public-internet
  exposure (logins verified against your Nous account).
- Add a warning that the username/password provider is for trusted
  networks / VPN only and is not suitable for direct public-internet
  exposure; point readers to the Nous / OIDC / custom OAuth providers.
- Surface the same distinction in the two-provider intro list.

* docs(dashboard): count three bundled auth providers, add self-hosted OIDC to intro

'Two providers ship in the box' undercounted — the bundled
plugins/dashboard_auth/self_hosted (generic OpenID Connect) is a third.
List all three in the gated-mode intro and link each to its section.

* docs(dashboard): extend auth provider updates to Docker and Desktop pages

- docker.md: list all three bundled gate providers (was username/password
  + OAuth only), adding the self-hosted OIDC provider and its env vars,
  and note username/password is not for public-internet exposure.
- desktop.md: reframe the remote-backend connection so OAuth (Nous Portal)
  is the preferred option for any backend reachable beyond the local
  machine, with username/password positioned for local / trusted-network
  use only. Cover the 'Sign in with <provider>' OAuth flow in the in-app
  steps and scope the VPN warning to the password path.

* docs(dashboard): align env-var, CLI, and remote-Desktop recipe with provider changes

- environment-variables.md: reframe the Web Dashboard & Hermes Desktop
  intro (OAuth preferred for remote/public, username/password for
  trusted networks), add the self-hosted OIDC env vars
  (HERMES_DASHBOARD_OIDC_*) that were missing from the table, and note
  hermes dashboard register provisions the OAuth client_id.
- cli-commands.md: document the 'hermes dashboard register' subcommand
  (flags, behavior, /local-dashboards GUI alternative).
- web-dashboard.md: apply the OAuth-preferred reframe to the bottom
  'Connecting Hermes Desktop to a remote backend' recipe and scope its
  VPN warning to the username/password path, matching desktop.md.

* docs(dashboard): move 'recommended remote Desktop path' framing from username/password to OAuth

The gated-mode intro list claimed the username/password provider was the
recommended path for a remote Hermes Desktop connection, contradicting the
OAuth-preferred framing established elsewhere. Move that recommendation onto
the OAuth (Nous Portal) item so the docs are consistent: OAuth is the
recommended provider for any remote/internet-facing backend; username/password
is for trusted networks only.

* docs(dashboard): drop unreleased managed/hosted-install provisioning notes

Remove the 'not available in managed/hosted installs, where the client id is
provisioned by the hosting platform' line from the dashboard register docs
(web-dashboard.md, cli-commands.md) and the 'provisioned by the Nous Portal for
hosted deploys' clause from the HERMES_DASHBOARD_OAUTH_CLIENT_ID env-var row —
that platform-provisioning path is unreleased.

* docs(dashboard): drop --portal-url / HERMES_DASHBOARD_PORTAL_URL from user docs

The portal-URL override targets a non-production Nous Portal and only works
for internal Nous usage — it won't function for end users (the access token
must be issued by the same portal). Remove it from the register CLI flags,
the Nous-provider config/env tables, and the verify-the-gate example so users
aren't pointed at an option that can't work for them.

* docs(dashboard): add worked examples for Nous and username/password providers

The self-hosted OIDC provider already had a full 'Worked example: Keycloak'
walkthrough; the Nous and username/password providers only had scattered
config snippets. Add parallel '#### Worked example' sections for both
(register/run/login + /api/status verification), mirroring the Keycloak
example's structure so all three bundled providers read consistently.

* docs(env): move HERMES_DESKTOP_REMOTE_URL to end of the dashboard auth table

It was sitting between the HERMES_DASHBOARD_BASIC_AUTH_* block and the
HERMES_DASHBOARD_OAUTH/OIDC block, splitting the dashboard-side vars. As the
only desktop-side var in the table, it belongs at the end so the dashboard
provider vars (basic, OAuth, OIDC) stay grouped together.

* docs(dashboard): remove Fly.io references from dashboard auth docs

Fly.io is the internal hosting implementation for hosted Hermes — it shouldn't
leak into user-facing dashboard auth docs. Reword the OAuth provider intro,
the env-var-path rationale, the public-URL-override section, the cookie Secure
note, and the verify-the-gate example to generic 'hosting platform' / 'reverse
proxy' / 'TLS terminator' phrasing.

Left the legitimate user-facing Fly.io mentions in telegram.md (a deliberate
cloud-deployment walkthrough) and work-with-skills.md (a generic example)
untouched.
2026-06-05 18:34:19 +10:00
AceanandTeknium b0d234f068 fix(cron): don't crash on cron list when a job's repeat is null
`cron_list` read `job.get("repeat", {})`, but the dict-default only
applies to a MISSING key. A one-shot job persisted with `"repeat": null`
returns None, and the next `.get("times")` raised AttributeError, taking
down the whole `cron list` output. Coalesce with `or {}` so a
present-but-null repeat renders as ∞ like the other cron readers already
do. Adds a regression test.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 00:19:45 -07:00
helix4u c8e80cd0bf fix(update): require managed marker before destructive clean 2026-06-05 00:05:30 -07:00
Baris SencanandTeknium ad69d3edc7 fix(terminal): guard os.getcwd() against a deleted CWD
`os.getcwd()` raises FileNotFoundError when the process's working
directory was removed out from under it (e.g. a scratch workspace
cleaned up mid-session), crashing terminal env setup.

Extract a `_safe_getcwd()` helper that falls back to TERMINAL_CWD, then
the user's home, on FileNotFoundError, and route all three `os.getcwd()`
call sites in terminal_tool.py through it (local default_cwd, the Docker
cwd-passthrough source, and the debug-config print) so the same crash
can't resurface at a sibling site. Adds unit tests for the real-cwd path
and both fallback branches.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 23:39:34 -07:00
Ben Barclay b1e399de95 fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559)
Inside the published Docker image, both the `--tui` banner and the
dashboard-embedded TUI report `1 commit behind — run docker pull
nousresearch/hermes-agent:latest to update` even though the container
has no git repo and no way to compute a commit delta.

Root cause: two independent update-detection paths, only one of which
knows it's running in Docker.

- `recommended_update_command()` → `detect_install_method()` reads the
  `.install_method` stamp that `docker/stage2-hook.sh` writes at boot →
  returns "docker", so the *command string* correctly says `docker pull`.
- `banner.check_for_updates()` (the source of the "N commits behind"
  *count*) has no notion of the docker install method. It only detects a
  build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git`
  dir (excluded from the image by .dockerignore). Neither matches, so it
  silently falls through to `check_via_pypi()`, whose PyPI-version
  mismatch flag (1) is then rendered verbatim by the CLI banner
  (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes
  version` as "1 commit behind" — a phantom count, no commit math
  involved. `hermes update` already refuses to run in-place in the
  container.

The dashboard's REST `/api/hermes/update/check` endpoint already
short-circuits docker (returns behind=None + the docker guidance). This
mirrors that guard inside `check_for_updates()` so the banner/TUI/version
surfaces agree: when `detect_install_method() == "docker"`, return None
before any git/pypi probe (and before writing a cache entry). None makes
the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`)
stay false, so the badge/line disappears entirely — matching the System
page.

Fix is in one place (check_for_updates) because all three consumers route
through it via get_update_result()/_update_result.

Tests: test_check_for_updates_docker_returns_none asserts None + no
git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks
guards against over-broadening (pip still version-checks). Mutation-tested:
removing the guard fails the docker test.

Verified against a real `docker build` of the image — see PR description.
2026-06-05 15:37:19 +10:00
Ben 439f53cab8 fix(desktop): gate OAuth remote connect on AT-or-RT, not access token alone
The desktop OAuth remote-gateway path gated connectivity on
hasOauthSessionCookie(), which checks only the access-token cookie
(hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age
lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend()
and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing
a full IDP re-login every ~15 min — even though a valid 24h refresh-token
cookie (hermes_session_rt) was sitting in the same jar.

The desktop OAuth code (2026-06-04) was written against the obsolete
"contract v1 issues no refresh token" model, two days after #37247
re-introduced server-side transparent refresh: Portal now issues a 24h
rotating, reuse-detected refresh token, and the gateway middleware
(_attempt_refresh) rotates a fresh AT from the RT on the next
authenticated request. So an expired-AT/live-RT session is fully
connectable — the desktop just never let the request through.

Fix:
- connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession()
  (true when EITHER a live AT or RT cookie is present). Keep
  cookiesHaveSession() AT-only for callers that need that specific signal.
- main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth
  branch now early-outs only when NEITHER cookie is present, otherwise
  uses the ws-ticket mint as the authoritative liveness probe (that POST
  carries the RT cookie and triggers the server-side AT rotation). A real
  401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout
  report against the same AT-or-RT notion.
- Remove the stale "contract v1 / NO refresh token" docstrings in
  cookies.py and the verify_session comments in the Nous provider that
  contradicted #37247.

Tests: +57 lines in connection-config.test.cjs covering the RT-only
"still connectable" case. node --test: 32/32. dashboard-auth +
nous-provider Python suites: 223/223.

Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/)
are comment/docstring-only here, but this touches outside apps/desktop/ so
it needs Teknium review.
2026-06-04 22:18:46 -07:00
Brian DohertyandTeknium 899ee8c23d fix(gateway): tolerate non-UTF-8 status/pid files in gateway status reads
`_read_json_file` caught OSError but not UnicodeDecodeError, so a status
file holding binary/non-UTF-8 bytes (truncated or clobbered write) would
crash the gateway status path instead of being treated as unreadable.
UnicodeDecodeError is a ValueError subclass, not an OSError, so it
escaped the existing guard.

Widen the catch to (OSError, UnicodeDecodeError) at both read sites in
gateway/status.py — `_read_json_file` and the sibling `_read_pid_record`,
which had the identical gap. Adds tests covering binary input (returns
None) and valid input (still parses) for both.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 22:05:23 -07:00
TekniumandSahibzada Allahyar 7309f3bef7 fix(line): map inbound message types to the correct MessageType
The LINE adapter classified every non-text inbound message as
`MessageType.IMAGE`, which doesn't exist on the enum — so any image,
video, audio, file, sticker, or location message raised AttributeError
the moment it was constructed.

Beyond fixing the crash, every non-text message was being collapsed onto
a single type. The gateway routes on MessageType (voice → STT, files →
document handling, etc.), so misclassification silently mishandled media.
Replace the inline ternary with a `_LINE_MESSAGE_TYPES` lookup that maps
each LINE webhook type to its proper enum member (audio → VOICE to match
how Telegram/WhatsApp treat voice notes), falling back to TEXT for
unknown types. Adds regression tests covering the mapping and the old
AttributeError.

Co-authored-by: Sahibzada Allahyar <94376830+sahibzada-allahyar@users.noreply.github.com>
2026-06-04 21:55:20 -07:00
Ben 736dc0fd86 fix(nix): use fetchNpmDeps hash for npmDepsHash, not prefetch-npm-deps
The previous fix committed the hash from `prefetch-npm-deps`
(sha256-hgnqc...), but the actual `fetchNpmDeps` FOD (fetcherVersion 2)
that `nix flake check` builds wants sha256-cY+gM... . These two tools
disagree for this lockfile, so the build's npm-deps derivation failed
with a hash mismatch even though `fix-lockfiles --check` reported "ok".

Corrected to the build-verified value. Confirmed `nix build .#tui`,
`.#web`, and `.#desktop` all build cleanly with the new hash.
2026-06-04 21:30:23 -07:00
teknium1 6b77fd2a0f fix(nix): bump npmDepsHash for react-router 7.17.0 lockfile
The react-router-dom 7.14->7.17 lockfile change stales the pinned
npm-deps hash in nix/lib.nix, turning the nix flake checks red. Bump
to the hash CI's prefetch diagnostic computed for the new lockfile.
2026-06-04 21:30:23 -07:00
Ben 46c16b9288 fix(deps): bump react-router-dom to 7.17.0 (GHSA-8x6r-g9mw-2r78)
Clears the npm-audit React Router advisory CVE-2026-42342 in the web
and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0
(patched in 7.15.0; both react-router and react-router-dom now resolve
to 7.17.0 in the root lockfile).

Note: the advisory's DoS only affects React Router *Framework Mode*
(the __manifest server endpoint). Both workspaces use Declarative Mode
(web: <BrowserRouter>, desktop: <HashRouter>) as pure client-side SPAs,
so we were never actually exploitable -- this is audit-hygiene only.

npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
2026-06-04 21:30:23 -07:00
liuhao1024 391b594752 fix(cli): use Rich [dim] tag instead of ANSI escape in _restore_session_cwd
Replace [{_DIM}] with [dim] in all _restore_session_cwd and
_preload_resumed_session messages that go through _console_print (Rich
Console.print).  _DIM is an ANSI escape (\x1b[2;3m) that Rich cannot
parse as a markup tag, causing MarkupError on session resume when the
stored cwd is missing or inaccessible.

Also uses [/dim] closing tag for explicit tag matching.

Fixes #39469
2026-06-05 10:00:21 +08:00
164 changed files with 10503 additions and 2527 deletions
+6
View File
@@ -108,6 +108,12 @@ docs/superpowers/*
# logs, and per-session caches are never artifacts of the codebase.
.hermes/
# Desktop/bootstrap install marker written into the managed checkout root by the
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+55
View File
@@ -2720,6 +2720,61 @@ def run_conversation(
# compress history and retry, not abort immediately.
status_code = getattr(api_error, "status_code", None)
# ── Respect disabled auto-compaction on overflow ──────
# Ported from anomalyco/opencode#30749. When the user has
# turned auto-compaction off (``compression.enabled: false``),
# NO automatic compaction trigger may fire — including the
# provider/request-size overflow recovery paths below
# (long-context-tier 429, 413 payload-too-large, and
# context-overflow). Without this guard the proactive
# threshold path correctly honours the setting (see the
# preflight check and the post-response ``should_compress``
# gate) but a provider overflow error would still silently
# compress + rotate the session, bypassing the user's
# explicit choice. Surface a terminal error instead so the
# user can compact manually (``/compress``), start fresh
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
):
agent._flush_status_buffer()
agent._vprint(
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
f"(compression.enabled: false).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
f"switch to a larger-context model, or reduce attachments.",
force=True,
)
logger.error(
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
f"auto-compaction disabled — not compressing."
)
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": (
"Context overflow and auto-compaction is disabled "
"(compression.enabled: false). Run /compress to compact manually, "
"/new to start fresh, or switch to a larger-context model."
),
"partial": True,
"failed": True,
"compaction_disabled": True,
}
# ── Anthropic Sonnet long-context tier gate ───────────
# Anthropic returns HTTP 429 "Extra usage is required for
# long context requests" when a Claude Max (or similar)
+19
View File
@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
# passes max_tokens=None, because Gemini's native API otherwise applies a low
# internal default and truncates output (unlike OpenAI-compat endpoints where
# an omitted limit means full budget).
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
@@ -414,6 +421,18 @@ def build_gemini_request(
generation_config["temperature"] = temperature
if max_tokens is not None:
generation_config["maxOutputTokens"] = max_tokens
else:
# Gemini's native generateContent does NOT treat an omitted
# maxOutputTokens as "use the model's full output budget" — it applies
# a low internal default and the model stops early with
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
# then retries 3× and refuses the incomplete call). Every current
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
# 65,535 output tokens, so default to that ceiling when the caller
# passes None ("unlimited"). See the OpenAI-compat path where omitting
# the field genuinely means full budget — that assumption does not
# hold on the native API.
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
if top_p is not None:
generation_config["topP"] = top_p
if stop:
+22 -1
View File
@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs[k] = v
if extra_body:
api_kwargs["extra_body"] = extra_body
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
# keys (tags, reasoning, provider, plugins, …) are unknown fields
# there and Gemini rejects the whole request with a non-retryable
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
# This happens when a profile that emits extra_body (e.g. the Nous
# profile's portal `tags`) is active but the resolved endpoint is a
# Gemini base_url — typical when only Google credentials are set and
# a fallback/aux call lands on Gemini. The native client only reads
# thinking_config from extra_body, so drop everything else here.
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
except Exception:
_native_gemini = False
if _native_gemini:
extra_body = {
k: v for k, v in extra_body.items()
if k in ("thinking_config", "thinkingConfig")
}
if extra_body:
api_kwargs["extra_body"] = extra_body
return api_kwargs
@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
let child_env = update_child_env(&install_root);
let mut update_args: Vec<String> =
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
let mut update = run_streamed(
&app,
&hermes,
&update_args,
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("update"),
)
.await?;
// Retry-once for the update-boundary crash. `hermes update` lazily imports
// the FRESHLY PULLED modules, but the dependency-install step still runs the
// already-in-memory pre-pull code for one invocation. A release that changed
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
// whose `__iter__` injected a bool into the argv and crashed Windows
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
// bool found`, fixed in #39820) therefore kills the FIRST update on the
// parked population — even though the fix is already on disk by then. A
// second `hermes update` runs clean because the now-current module is loaded
// from the start. Rather than make the parked user click Update twice (and
// stare at a scary crash first), retry once automatically. Skip the retry
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
// a retry can't fix.
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
emit_log(
&app,
Some("update"),
LogStream::Stdout,
"[update] first update attempt failed; retrying once (the fix it just \
pulled loads on the second run)…",
);
update = run_streamed(
&app,
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
}
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
"[update] Hermes still holding the venv shim; force-killing stragglers…",
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
);
}
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
/// ne <self>` also spares this Tauri process, though it isn't named
/// hermes.exe.)
fn force_kill_other_hermes() {
if !cfg!(target_os = "windows") {
return;
}
#[cfg(target_os = "windows")]
{
let my_pid = std::process::id();
// /FI excludes our own PID; /T kills the tree; /F forces.
let _ = std::process::Command::new("taskkill")
.args([
"/F",
"/T",
"/IM",
"hermes.exe",
"/FI",
&format!("PID ne {my_pid}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
-6
View File
@@ -24,12 +24,6 @@
### Install with Hermes (recommended)
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
```bash
+84 -2
View File
@@ -18,11 +18,24 @@
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// Bare + prefixed variants of the session cookies the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
//
// Two cookies are in play (see that module):
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
// instant the AT expires.
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
// has lapsed but the RT cookie is still present, the gateway middleware
// transparently rotates a fresh AT on the next authenticated request
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
// AT cookie. A liveness check that looked only at the AT cookie would
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -118,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
return buildGatewayWsUrl(baseUrl, token)
}
// Normalize a profile name to a connection scope key, or null for the global
// (default) connection. Shared by the resolver and the IPC layer.
function connectionScopeKey(profile) {
return String(profile ?? '').trim() || null
}
// Coerce a remote auth mode to one of the two supported values ('token' default).
function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -150,22 +198,56 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
* check. `cookiesHaveSession` remains exported for callers that specifically
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
/**
* True if the cookie jar holds a credential that can yield an authenticated
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
* transparently rotates a fresh AT from the RT on the next authenticated
* request. Gating connectivity on the AT alone would force a full IDP
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
*
* This answers "should we even attempt to connect / show as signed in?", not
* "is the access token unexpired?". The authoritative liveness check is still
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -15,16 +15,81 @@ const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
test('connectionScopeKey trims to a name or null for the global scope', () => {
assert.equal(connectionScopeKey(' coder '), 'coder')
assert.equal(connectionScopeKey(''), null)
assert.equal(connectionScopeKey(null), null)
assert.equal(connectionScopeKey(undefined), null)
})
test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('oauth'), 'oauth')
assert.equal(normAuthMode('token'), 'token')
assert.equal(normAuthMode(undefined), 'token')
assert.equal(normAuthMode('weird'), 'token')
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, ''), null)
assert.equal(profileRemoteOverride(config, null), null)
assert.equal(profileRemoteOverride(config, undefined), null)
})
test('profileRemoteOverride returns null when the profile has no entry', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, 'writer'), null)
})
test('profileRemoteOverride ignores local or url-less profile entries', () => {
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
})
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
const config = {
profiles: {
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
token: { value: 'sek' }
})
})
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
@@ -131,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
// is NOT an access token, so this returns false. Connectivity callers must
// use cookiesHaveLiveSession instead (see below).
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
@@ -146,6 +214,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
})
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
// but the 24h RT cookie is still alive. The session is still connectable —
// the gateway rotates a fresh AT from the RT on the next request.
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: 'a' },
{ name: 'hermes_session_rt', value: 'r' }
]),
true
)
})
test('cookiesHaveLiveSession is false for empty values', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: '' },
{ name: 'hermes_session_rt', value: '' }
]),
false
)
})
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHaveLiveSession(null), false)
assert.equal(cookiesHaveLiveSession(undefined), false)
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
+536 -93
View File
@@ -32,8 +32,12 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -1309,6 +1313,111 @@ function resolveUpdaterBinary() {
return fileExists(candidate) ? candidate : null
}
// Path to the venv shim whose lock decides whether `hermes update` can write
// fresh entry points. On Windows this is the file the running backend
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
function venvHermesShimPath(updateRoot) {
return IS_WINDOWS
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
: path.join(updateRoot, 'venv', 'bin', 'hermes')
}
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
// this practically always succeeds (no mandatory locking), so it returns false
// — correct, since the shim-contention brick is Windows-only.
function isShimLocked(shimPath) {
if (!IS_WINDOWS) return false
let fd
try {
fd = fs.openSync(shimPath, 'r+')
return false
} catch (err) {
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
// EACCES) on Windows means a live handle holds it.
return err && err.code !== 'ENOENT'
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd)
} catch {
void 0
}
}
}
}
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
// only signals the direct child, so on Windows a backend `hermes.exe` that
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
// the whole tree synchronously. Windows-only: this is called solely from the
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
}
}
// Before handing off the update on Windows, the desktop MUST stop every backend
// it spawned and WAIT for the venv shim to actually unlock. The old code did
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
// rename failed, uv's `pip install` hit "Access is denied", and the git path
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
// pool backends and poll the shim until it's writable (or a bounded timeout),
// so by the time we spawn the updater the lock is genuinely gone.
//
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
// aggressively SIGKILL-ing the backend here would be an untested behavior change
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
async function releaseBackendLockForUpdate(updateRoot) {
if (!IS_WINDOWS) return { unlocked: true }
// Collect every backend PID the desktop owns: primary window backend + pool.
const pids = []
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
for (const entry of backendPool.values()) {
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
}
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
if (hermesProcess && !hermesProcess.killed) {
try {
hermesProcess.kill('SIGTERM')
} catch {
void 0
}
}
stopAllPoolBackends()
for (const pid of pids) forceKillProcessTree(pid)
const shim = venvHermesShimPath(updateRoot)
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
return { unlocked: false }
}
// applyUpdates — hand off to the installer's --update flow, then exit.
//
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
@@ -1375,6 +1484,12 @@ async function applyUpdates(opts = {}) {
}
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
await releaseBackendLockForUpdate(updateRoot)
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawn(updater, updaterArgs, {
@@ -3167,8 +3282,16 @@ function installMediaPermissions() {
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
// path is unconditionally rejected by gated gateways.
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
// a ~15-min TTL. On 401 we must re-run the login round trip.
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
// is still alive, the gateway middleware transparently rotates a fresh AT
// on the next authenticated request — so connectivity must NOT be gated on
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
// (which triggers that server-side refresh) and treat a real 401 as
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
// "is the user signed in at all?" gate / display signal.
// ---------------------------------------------------------------------------
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
@@ -3179,8 +3302,9 @@ function getOauthSession() {
return oauthSession
}
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
// Bare + prefixed variants of the session cookies live in
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
// that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
@@ -3201,6 +3325,30 @@ async function hasOauthSessionCookie(baseUrl) {
}
}
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
// "is the user signed in at all?" check: an expired AT with a live RT is still
// a connectable session because the gateway rotates a fresh AT server-side on
// the next authenticated request. Gating on the AT alone forces a needless full
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
async function hasLiveOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
const parsed = new URL(baseUrl)
try {
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveLiveSession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveLiveSession(cookies)
} catch {
return false
}
}
}
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
@@ -3447,6 +3595,38 @@ function decryptDesktopSecret(secret) {
return value
}
// Validate + normalize the per-profile remote overrides map read from disk.
// Drops malformed names/entries and keeps only the recognized fields so a
// hand-edited or stale connection.json can't inject junk into resolution.
function sanitizeConnectionProfiles(raw) {
if (!raw || typeof raw !== 'object') {
return {}
}
const out = {}
for (const [name, entry] of Object.entries(raw)) {
if (!entry || typeof entry !== 'object') {
continue
}
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
cleaned.url = url
}
cleaned.authMode = normAuthMode(entry.authMode)
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
out[name] = cleaned
}
return out
}
function readDesktopConnectionConfig() {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
@@ -3462,7 +3642,7 @@ function readDesktopConnectionConfig() {
return connectionConfigCache
}
let config = { mode: 'local', remote: {} }
let config = { mode: 'local', remote: {}, profiles: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
@@ -3476,7 +3656,11 @@ function readDesktopConnectionConfig() {
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
// profileRemoteOverride() can resolve them; normalized lazily on save.
profiles: sanitizeConnectionProfiles(parsed.profiles)
}
}
} catch {
@@ -3528,104 +3712,114 @@ function writeActiveDesktopProfile(name) {
return value || null
}
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
const remoteToken = decryptDesktopSecret(config.remote?.token)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
const remoteUrl = String(config.remote?.url || '')
// Sanitize a connection config into the renderer-facing shape. With no
// `profile` this describes the global/default connection (the existing
// behavior); with a `profile` it describes that profile's per-profile remote
// override (or an empty "local/inherit" view when the profile has none).
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
const key = connectionScopeKey(profile)
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
// Display signal: treat a live RT cookie as "connected" even if the AT
// cookie has lapsed — the gateway refreshes the AT on the next request,
// so the session is still usable. The authoritative liveness check is
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
mode,
// Echo the scope back so the UI knows which profile (if any) this reflects.
profile: key,
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
authMode,
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
const nextToken = incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingBlock.token
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
// OAuth gateways authenticate via the session cookie established by the
// login window, NOT a static token — so no token is required here. The
// cookie presence is verified at connect time (resolveRemoteBackend).
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
throw new Error('Remote gateway session token is required.')
if (key) {
// Per-profile scope: a remote entry pins this profile to its own backend; a
// local entry clears the override so the profile inherits the default.
const profiles = { ...(existing.profiles || {}) }
if (mode === 'remote') {
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
} else {
delete profiles[key]
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
return { mode, remote: nextRemote }
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
return {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
}
const config = readDesktopConnectionConfig()
if (config.mode !== 'remote') {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
// Verify the cookie is present, then mint a single-use WS ticket (the
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
// user needs to (re-)log in via Settings → Gateway.
if (!(await hasOauthSessionCookie(baseUrl))) {
// OAuth gateway: auth comes from the session cookies in the OAuth
// partition. Liveness is NOT "is the access-token cookie present?" —
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable. We
// early-out only when neither cookie is present, then mint a ws-ticket as
// the authoritative liveness check.
if (!(await hasLiveOauthSession(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
@@ -3649,7 +3843,7 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
@@ -3657,8 +3851,6 @@ async function resolveRemoteBackend() {
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
@@ -3669,13 +3861,92 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
// 3. global remote (connection.json `mode: 'remote'`)
// A null/empty profile resolves the env/global remote, so legacy callers and
// the connection test (which pass no profile) are unchanged.
async function resolveRemoteBackend(profile) {
const config = readDesktopConnectionConfig()
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
}
// 2. Env override (global, token-auth only).
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode !== 'remote') {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
}
// A remote profile's sessions live on its remote host's state.db, not on a local
// file the primary can open — so reads for it must route to the remote backend,
// not the local-disk fast path. These three helpers drive that (see
// interceptSessionReadForRemote).
function profileHasRemoteOverride(profile) {
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
}
function configuredRemoteProfileNames() {
const config = readDesktopConnectionConfig()
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
return readDesktopConnectionConfig().mode === 'remote'
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
async function fetchJsonForProfile(profile, path) {
return requestJsonForProfile(profile, path, 'GET')
}
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
async function requestJsonForProfile(profile, path, method, body) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
return conn.authMode === 'oauth'
? fetchJsonViaOauthSession(url, opts)
: fetchJson(url, conn.token, opts)
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
@@ -3743,6 +4014,11 @@ async function probeRemoteAuthMode(rawUrl) {
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
@@ -3750,17 +4026,17 @@ async function testDesktopConnectionConfig(input = {}) {
let baseUrl
let token = null
let authMode = 'token'
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
if (wantRemote && block?.url) {
baseUrl = normalizeRemoteBaseUrl(block.url)
authMode = normAuthMode(block.authMode)
if (authMode !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
token = decryptDesktopSecret(block.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
authMode = normAuthMode(remote.authMode)
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
@@ -3932,10 +4208,21 @@ function startPoolIdleReaper() {
// local-spawn portion of startHermes() but without the boot-progress UI,
// bootstrap, or remote handling (those belong to the primary backend only).
async function spawnPoolBackend(profile, entry) {
// Remote deployments are single-tenant; profiles only apply to local backends.
const remote = await resolveRemoteBackend()
// A profile may point at its OWN remote backend (connection.json
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
// In either case there is no local child to spawn — we just verify the
// remote is reachable and hand back its connection descriptor. The pool
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
// tolerate.
const remote = await resolveRemoteBackend(profile)
if (remote) {
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
await waitForHermes(remote.baseUrl, remote.token)
return {
...remote,
profile,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
const port = await pickPort()
@@ -4036,7 +4323,9 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = await resolveRemoteBackend()
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@@ -4373,7 +4662,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
@@ -4388,22 +4679,36 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT) so a logout that left any session cookie behind is reflected
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
await teardownPrimaryBackendAndWait()
const key = connectionScopeKey(payload?.profile)
mainWindow?.reload()
return sanitizeDesktopConnectionConfig(config)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
// re-resolves against the new remote/local target.
stopPoolBackend(key)
} else {
// Global connection, or the primary profile's connection: re-home the
// window backend by tearing it down and reloading the renderer.
await teardownPrimaryBackendAndWait()
mainWindow?.reload()
}
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
@@ -4431,7 +4736,145 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
return systemPreferences.askForMediaAccess('microphone')
})
// Re-route remote-profile session requests to the owning remote backend. Returns
// `undefined` when not interceptable (caller takes the normal local path), else
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
// request.profile. Either way, a remote profile's session lives only on its
// remote host, so the request must go there (where it serves its own state.db).
// GET /api/profiles/sessions → splice each remote profile's rows in
// GET /api/sessions/{id}[/messages] → read from remote
// DELETE /api/sessions/{id} → delete on remote
// PATCH /api/sessions/{id} → rename/archive on remote
async function interceptSessionRequestForRemote(request) {
if (typeof request?.path !== 'string') {
return undefined
}
const method = (request.method || 'GET').toUpperCase()
let parsed
try {
parsed = new URL(request.path, 'http://x')
} catch {
return undefined
}
const { pathname, searchParams } = parsed
if (method === 'GET' && pathname === '/api/profiles/sessions') {
const remoteProfiles = configuredRemoteProfileNames()
if (remoteProfiles.length === 0) {
return undefined // no remote profiles → local fast path
}
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
if (requested !== 'all') {
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
}
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
// param (it serves its own state.db natively).
// - global remote mode: ONE backend serves every profile via ?profile=, so
// route there and KEEP the profile param so it opens the right state.db.
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
const profile = (searchParams.get('profile') || request.profile || '').trim()
if (!profile) {
return undefined
}
if (profileHasRemoteOverride(profile)) {
if (method === 'GET') {
return fetchJsonForProfile(profile, pathname)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
if (body) delete body.profile
return requestJsonForProfile(profile, pathname, method, body)
}
if (globalRemoteActive()) {
// Single global backend: keep ?profile= so it opens the right state.db.
const sep = pathname.includes('?') ? '&' : '?'
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
if (method === 'GET') {
return fetchJsonForProfile(null, path)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
return requestJsonForProfile(null, path, method, body)
}
return undefined
}
return undefined
}
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
// A remote profile's session list, read from its remote host and tagged with the
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
async function remoteSessionList(profile, searchParams) {
const qs = new URLSearchParams(searchParams)
qs.delete('profile') // remote serves its own db; no cross-profile read there
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
for (const s of rowsOf(data)) {
s.profile = profile
s.is_default_profile = false
}
return { ...data, sessions: rowsOf(data) }
}
// Unified list: primary's local aggregate, with each remote profile's stale local
// rows/totals swapped for the remote's real ones, re-sorted by recency and
// re-windowed to the requested page. A dead remote contributes nothing rather
// than breaking the sidebar.
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
const primary = await ensureBackend(null)
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
// is correct for this page — mirrors the primary's per-profile over-fetch.
const remoteParams = new URLSearchParams(searchParams)
remoteParams.set('limit', String(limit + offset))
remoteParams.set('offset', '0')
const remoteSet = new Set(remoteProfiles)
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
const profileTotals = { ...(base.profile_totals || {}) }
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
// Swap each remote profile's stale local rows/total for the remote's real ones.
await Promise.all(remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
}))
const recency = s => s?.[order] ?? s?.started_at ?? 0
merged.sort((a, b) => recency(b) - recency(a))
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
}
ipcMain.handle('hermes:api', async (_event, request) => {
// Remote-profile session requests would otherwise hit the local primary off
// each profile's on-disk state.db — fine for local profiles, but a remote
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
// no-op) the moment they run there. Route reads + mutations to the remote.
const rerouted = await interceptSessionRequestForRemote(request)
if (rerouted !== undefined) {
return rerouted
}
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
+1 -1
View File
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
+1 -1
View File
@@ -84,7 +84,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",
+46 -40
View File
@@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { FadeText } from '@/components/ui/fade-text'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view'
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
// same visual vocabulary as the chat tool blocks.
function statusGlyph(status: SubagentStatus): ReactNode {
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
if (status === 'running' || status === 'queued') {
return (
<BrailleSpinner
ariaLabel="Running"
ariaLabel={a.running}
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe"
/>
@@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode {
}
if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
}
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
@@ -75,6 +76,7 @@ interface AgentsViewProps {
}
export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
@@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
return (
<OverlayView
closeLabel="Close agents"
closeLabel={t.agents.close}
contentClassName="px-5 pt-5 pb-4 sm:px-6"
onClose={onClose}
rootClassName="mx-auto max-w-3xl"
>
<header className="mb-3 shrink-0">
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
</header>
<SubagentTree tree={tree} />
</OverlayView>
)
}
const fmtDuration = (seconds?: number) => {
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
if (!seconds || seconds <= 0) {
return ''
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
return a.durationSeconds(seconds.toFixed(1))
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return `${m}m ${s}s`
return a.durationMinutes(m, s)
}
const fmtTokens = (value?: number) => {
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
if (!value) {
return ''
}
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
}
const fmtAge = (updatedAt: number, nowMs: number) => {
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
if (s < 2) {
return 'now'
return a.ageNow
}
if (s < 60) {
return `${s}s ago`
return a.ageSeconds(s)
}
const m = Math.floor(s / 60)
if (m < 60) {
return `${m}m ago`
return a.ageMinutes(m)
}
return `${Math.floor(m / 60)}h ago`
return a.ageHours(Math.floor(m / 60))
}
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
@@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
interface RootGroup {
id: string
label: string
delegationIndex: number
nodes: SubagentNode[]
taskCount: number
}
@@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
if (node.taskCount > 1) {
n += 1
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
continue
}
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
}
return groups
}
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
const { t } = useI18n()
const flat = useMemo(() => flatten(tree), [tree])
const groups = useMemo(() => groupDelegations(tree), [tree])
const [nowMs, setNowMs] = useState(() => Date.now())
@@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
When a turn delegates work, child agents stream their progress here.
</p>
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>
)
}
const summary = [
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
active > 0 ? `${active} active` : '',
failed > 0 ? `${failed} failed` : '',
tools > 0 ? `${tools} tools` : '',
files > 0 ? `${files} files` : '',
tokens > 0 ? fmtTokens(tokens) : '',
t.agents.agentsCount(flat.length),
active > 0 ? t.agents.activeCount(active) : '',
failed > 0 ? t.agents.failedCount(failed) : '',
tools > 0 ? t.agents.toolsCount(tools) : '',
files > 0 ? t.agents.filesCount(files) : '',
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
cost > 0 ? `$${cost.toFixed(2)}` : ''
].filter(Boolean)
@@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
}
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
const { t } = useI18n()
if (group.nodes.length === 1 && group.taskCount <= 1) {
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
}
@@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
return (
<section className="grid min-w-0 gap-3">
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
</p>
<div className="grid min-w-0 gap-4">
{group.nodes.map(node => (
@@ -275,6 +279,7 @@ function StreamLine({
parentRunning: boolean
rowKey: string
}) {
const { t } = useI18n()
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
const isMono = entry.kind === 'tool'
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
@@ -286,7 +291,7 @@ function StreamLine({
{entry.text}
{active ? (
<BrailleSpinner
ariaLabel="Streaming"
ariaLabel={t.agents.streaming}
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe"
/>
@@ -297,6 +302,7 @@ function StreamLine({
}
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
const { t } = useI18n()
const running = node.status === 'running' || node.status === 'queued'
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
@@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
const subtitle = [
node.model,
fmtDuration(durationSeconds),
node.toolCount ? `${node.toolCount} tools` : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
`updated ${fmtAge(node.updatedAt, nowMs)}`
fmtDuration(durationSeconds, t.agents),
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
].filter(Boolean)
return (
@@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
onClick={() => setOpen(v => !v)}
type="button"
>
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn(
@@ -366,7 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
{fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line}
@@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
))}
{fileLines.length > 8 ? (
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
+{fileLines.length - 8} more files
{t.agents.moreFiles(fileLines.length - 8)}
</p>
) : null}
</div>
+63 -31
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@@ -18,6 +19,7 @@ import {
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
@@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number): string {
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
if (total === 0) {
return '0'
return a.zero
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return `${start}-${end} of ${total}`
return a.rangeOf(start, end, total)
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
@@ -356,21 +358,25 @@ type CellCtx = {
interface ArtifactColumn {
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
bodyClassName: string
header: (filter: ArtifactFilter) => string
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
id: 'location' | 'primary' | 'session'
width: (filter: ArtifactFilter) => string
}
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const { t } = useI18n()
const a = t.artifacts
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
@@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
@@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
})
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
} catch (err) {
notifyError(err, 'Artifacts failed to load')
notifyError(err, a.failedLoad)
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
}, [a])
useRefreshHotkey(refreshArtifacts)
@@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, 'Open failed')
notifyError(err, a.openFailed)
}
}, [])
}, [a])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..."
searchPlaceholder={a.search}
searchTrailingAction={
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? a.refreshing : a.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
Images <TextTabMeta>({counts.image})</TextTabMeta>
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
Files <TextTabMeta>({counts.file})</TextTabMeta>
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
Links <TextTabMeta>({counts.link})</TextTabMeta>
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
<PageLoader label={a.indexing} />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
</div>
</div>
) : (
@@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
itemLabel={a.itemsImage}
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
@@ -579,7 +601,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
itemLabel={itemsLabel(kindFilter, a)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
@@ -608,12 +630,14 @@ interface ArtifactsPaginationProps {
}
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
const { t } = useI18n()
const a = t.artifacts
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize)} {itemLabel}
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
@@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={`Go to ${itemLabel} page ${item}`}
aria-label={a.goToPage(itemLabel, item)}
isActive={page === item}
onClick={() => onPageChange(item)}
>
@@ -657,6 +681,10 @@ interface ArtifactImageCardProps {
}
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
const { t } = useI18n()
const a = t.artifacts
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div
@@ -683,7 +711,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<FileImage className="size-3" />
{artifact.kind}
{kindLabel}
</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label}
@@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" />
Chat
{a.chat}
</Button>
</div>
</div>
@@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
}
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const { t } = useI18n()
const isLink = artifact.kind === 'link'
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
@@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
{
Cell: LocationCell,
bodyClassName: 'px-2.5 py-1.5',
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
header: (filter, a) =>
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
id: 'location',
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
},
{
Cell: SessionCell,
bodyClassName: 'p-0',
header: () => 'Session',
header: (_filter, a) => a.colSession,
id: 'session',
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
}
@@ -843,13 +873,15 @@ function ArtifactTable({
ctx: CellCtx
filter: ArtifactFilter
}) {
const { t } = useI18n()
return (
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<tr>
{ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
{col.header(filter)}
{col.header(filter, t.artifacts)}
</th>
))}
</tr>
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
@@ -26,6 +27,8 @@ export function AttachmentList({
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
@@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${attachment.label}`)
throw new Error(c.couldNotPreview(attachment.label))
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, 'Preview unavailable')
notifyError(error, c.previewUnavailable)
}
}
@@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
@@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
</button>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
@@ -11,29 +11,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
{
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
description: 'Outline an approach before touching code so the diff stays focused.',
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
description: 'Walk through how the selected code works and link to the key files.',
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
export function ContextMenu({
state,
@@ -44,6 +29,8 @@ export function ContextMenu({
onPickFolders,
onPickImages
}: ContextMenuProps) {
const { t } = useI18n()
const c = t.composer
// Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog —
@@ -71,78 +58,81 @@ export function ContextMenu({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
{c.attachLabel}
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
{c.files}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
{c.folder}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
{c.images}
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
{c.pasteImage}
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
{c.url}
</ContextMenuItem>
<DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets
{c.promptSnippets}
</ContextMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
{c.tipPre}
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
{c.tipPost}
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
</>
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
const { t } = useI18n()
const c = t.composer
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
<DialogHeader>
<DialogTitle>Prompt snippets</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
<DialogTitle>{c.snippetsTitle}</DialogTitle>
<DialogDescription>{c.snippetsDesc}</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{snippets.map(snippet => (
<li key={snippet.label}>
<button
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
{SNIPPET_KEYS.map(key => {
const snippet = c.snippets[key]
return (
<li key={key}>
<button
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span>
</span>
</button>
</li>
))}
</button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
@@ -175,15 +165,8 @@ interface ContextMenuProps {
state: ChatBarState
}
interface PromptSnippet {
description: string
label: string
text: string
}
interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void
open: boolean
snippets: readonly PromptSnippet[]
}
+26 -16
View File
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -55,6 +56,9 @@ export function ComposerControls({
voiceStatus: VoiceStatus
onDictate: () => void
}) {
const { t } = useI18n()
const c = t.composer
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
@@ -65,9 +69,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Tip label="Start voice conversation">
<Tip label={c.startVoice}>
<Button
aria-label="Start voice conversation"
aria-label={c.startVoice}
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
@@ -81,9 +85,9 @@ export function ComposerControls({
</Button>
</Tip>
) : (
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
@@ -113,25 +117,27 @@ function ConversationPill({
onToggleMute,
status
}: ConversationProps & { disabled: boolean }) {
const { t } = useI18n()
const c = t.composer
const speaking = status === 'speaking'
const listening = status === 'listening' && !muted
const label =
status === 'speaking'
? 'Speaking'
? c.speaking
: status === 'transcribing'
? 'Transcribing'
? c.transcribing
: status === 'thinking'
? 'Thinking'
? c.thinking
: muted
? 'Muted'
: 'Listening'
? c.muted
: c.listening
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-label={muted ? c.unmuteMic : c.muteMic}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
@@ -148,32 +154,34 @@ function ConversationPill({
</Tip>
{listening && (
<Button
aria-label="Stop listening and send"
aria-label={c.stopListening}
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled}
onClick={() => {
triggerHaptic('submit')
onStopTurn()
}}
title={c.stopListening}
type="button"
variant="ghost"
>
<Square className="fill-current" size={11} />
<span>Stop</span>
<span>{c.stopShort}</span>
</Button>
)}
<Button
aria-label="End voice conversation"
aria-label={c.endConversation}
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
onEnd()
}}
title={c.endConversation}
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>End</span>
<span>{c.endShort}</span>
</Button>
<span className="sr-only" role="status">
{label}
@@ -220,10 +228,12 @@ function DictationButton({
status: VoiceStatus
onToggle: () => void
}) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
return (
<Tip label={aria}>
@@ -1,44 +1,32 @@
import type { ReactNode } from 'react'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
['/clear', 'start a new session'],
['/resume', 'resume a prior session'],
['/details', 'control transcript detail level'],
['/copy', 'copy selection or last assistant message'],
['/quit', 'exit hermes']
]
const HOTKEYS: [string, string][] = [
['@', 'reference files, folders, urls, git'],
['/', 'slash command palette'],
['?', 'this quick help (delete to dismiss)'],
['Enter', 'send · Shift+Enter for newline'],
['Cmd/Ctrl+K', 'send next queued turn'],
['Cmd/Ctrl+L', 'redraw'],
['Esc', 'close popover · cancel run'],
['↑ / ↓', 'cycle popover / history']
]
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
export function HelpHint() {
const { t } = useI18n()
const c = t.composer
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title="Common commands">
{COMMON_COMMANDS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} mono />
<Section title={c.commonCommands}>
{COMMON_COMMAND_KEYS.map(key => (
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
))}
</Section>
<Section title="Hotkeys">
{HOTKEYS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} />
<Section title={c.hotkeys}>
{HOTKEY_KEYS.map(key => (
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
))}
</Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
</p>
</div>
)
+10 -29
View File
@@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
@@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
// Resting composer placeholders. New sessions get open-ended starters; an
// existing chat gets phrasings that read as a continuation of the thread.
// One is picked at random per session (stable until the session changes).
const NEW_SESSION_PLACEHOLDERS = [
'What are we building?',
'Give Hermes a task',
"What's on your mind?",
'Describe what you need',
'What should we tackle?',
'Ask anything',
'Start with a goal'
]
const FOLLOW_UP_PLACEHOLDERS = [
'Send a follow-up',
'Add more context',
'Refine the request',
"What's next?",
'Keep it going',
'Push it further',
'Adjust or continue'
]
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
interface QueueEditState {
@@ -190,7 +168,10 @@ export function ChatBar({
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
// Resting placeholder: a starter for brand-new sessions, a continuation for
// existing ones. Picked once and only re-rolled when we genuinely move to a
@@ -198,7 +179,7 @@ export function ChatBar({
// started session (null → id, on the first send) is treated as the same
// conversation so the placeholder doesn't visibly flip mid-stream.
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
)
const prevSessionIdRef = useRef(sessionId)
@@ -217,16 +198,16 @@ export function ChatBar({
return
}
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
}, [sessionId])
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the bar is disabled it's because the gateway isn't open. Distinguish a
// cold start ("Starting Hermes...") from a dropped connection we're trying to
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
const placeholder = disabled
? gatewayState === 'closed' || gatewayState === 'error'
? 'Reconnecting to Hermes…'
: 'Starting Hermes...'
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
const focusInput = useCallback(() => {
@@ -1213,7 +1194,7 @@ export function ChatBar({
const input = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
aria-label={t.composer.message}
autoCapitalize="off"
autoCorrect="off"
className={cn(
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -16,10 +17,12 @@ interface QueuePanelProps {
onSendNow: (id: string) => void
}
const entryPreview = (entry: QueuedPromptEntry) =>
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(false)
if (entries.length === 0) {
@@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{entries.length} Queued</span>
<span className="truncate">{c.queued(entries.length)}</span>
</button>
{!collapsed && (
@@ -57,17 +60,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && (
<span>
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
{c.attachments(attachmentsCount)}
</span>
)}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
Editing in composer
{c.editingInComposer}
</span>
)}
</div>
@@ -81,9 +84,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
>
<Tip label="Edit queued turn">
<Tip label={c.editQueued}>
<Button
aria-label="Edit queued turn"
aria-label={c.editQueued}
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
@@ -94,9 +97,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label="Send queued turn now">
<Tip label={c.sendQueuedNow}>
<Button
aria-label="Send queued turn now"
aria-label={c.sendQueuedNow}
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
@@ -107,9 +110,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label="Delete queued turn">
<Tip label={c.deleteQueued}>
<Button
aria-label="Delete queued turn"
aria-label={c.deleteQueued}
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
@@ -1,3 +1,4 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
@@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
@@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
return (
<div
aria-label="Desktop theme suggestions"
aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
@@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title="No matching themes.">
Try <span className="font-mono text-foreground/80">/skin list</span>.
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty>
) : (
items.map(item => (
@@ -10,6 +10,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { Globe } from '@/lib/icons'
const URL_HINT = /^https?:\/\//i
@@ -29,6 +30,8 @@ export function UrlDialog({
open: boolean
value: string
}) {
const { t } = useI18n()
const c = t.composer
const trimmed = value.trim()
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
@@ -43,8 +46,8 @@ export function UrlDialog({
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>Attach a URL</DialogTitle>
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div>
</DialogHeader>
<form
@@ -60,23 +63,24 @@ export function UrlDialog({
autoCorrect="off"
inputMode="url"
onChange={e => onChange(e.target.value)}
placeholder="https://example.com/post"
placeholder={c.urlPlaceholder}
ref={inputRef}
spellCheck={false}
value={value}
/>
{trimmed.length > 0 && !looksLikeUrl && (
<p className="text-xs text-muted-foreground/85">
Include the full URL, e.g. <span className="font-mono">https://…</span>
{c.urlHintPre}
<span className="font-mono">https://…</span>
</p>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={!looksLikeUrl} type="submit">
Attach
{c.attach}
</Button>
</DialogFooter>
</form>
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { stopVoicePlayback } from '@/lib/voice-playback'
@@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
}
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
const { t } = useI18n()
if (state.status === 'idle') {
return null
}
const recording = state.status === 'recording'
const title = recording ? 'Dictating' : 'Transcribing'
const title = recording ? t.composer.dictating : t.composer.transcribing
return (
<div
@@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
}
export function VoicePlaybackActivity() {
const { t } = useI18n()
const playback = useStore($voicePlayback)
if (playback.status === 'idle') {
@@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
const preparing = playback.status === 'preparing'
const title = preparing
? 'Preparing audio'
? t.composer.preparingAudio
: playback.source === 'voice-conversation'
? 'Speaking response'
: 'Reading aloud'
? t.composer.speakingResponse
: t.composer.readingAloud
return (
<div
+40 -26
View File
@@ -36,6 +36,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
@@ -176,13 +177,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || '__no_workspace__'
const label = baseName(path) || path || 'No workspace'
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
@@ -233,6 +234,8 @@ export function ChatSidebar({
onArchiveSession,
onNewSessionInWorkspace
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
@@ -402,8 +405,8 @@ export function ChatSidebar({
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -589,13 +592,15 @@ export function ChatSidebar({
onNavigate(item)
}}
tooltip={item.label}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
@@ -615,9 +620,9 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
aria-label={s.searchAria}
onChange={setSearchQuery}
placeholder="Search sessions…"
placeholder={s.searchPlaceholder}
value={searchQuery}
/>
</div>
@@ -629,10 +634,10 @@ export function ChatSidebar({
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}.
{s.noMatch(trimmedQuery)}
</div>
}
label="Results"
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -653,7 +658,7 @@ export function ChatSidebar({
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
@@ -703,9 +708,9 @@ export function ChatSidebar({
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
@@ -724,7 +729,7 @@ export function ChatSidebar({
) : null}
</div>
}
label="Sessions"
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
)
}
const SidebarAllPinnedState = () => (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
function SidebarAllPinnedState() {
const { t } = useI18n()
return (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
{t.sidebar.allPinned}
</div>
)
}
function SidebarPinnedEmptyState() {
const { t } = useI18n()
return (
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift-click a chat to pin</span>
<span>{t.sidebar.shiftClickHint}</span>
</div>
)
}
@@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
@@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
/>
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}>
<Tip label={s.newSessionIn(group.label)}>
<button
aria-label={`New session in ${group.label}`}
aria-label={s.newSessionIn(group.label)}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
// Profile groups start a fresh session in that profile but keep the
// all-profiles browse view (newSessionInProfile leaves the scope
@@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
{reorderable && (
<span
{...dragHandleProps}
aria-label={`Reorder workspace ${group.label}`}
aria-label={s.reorderWorkspace(group.label)}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
@@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
aria-label={s.showMoreIn(nextCount, group.label)}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
type="button"
@@ -1144,7 +1157,8 @@ interface SidebarLoadMoreRowProps {
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
@@ -16,6 +16,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
@@ -43,13 +44,15 @@ interface ItemSpec {
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? 'Unpin' : 'Pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
@@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'copy',
label: 'Copy ID',
label: r.copyId,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
{
disabled: !sessionId,
icon: 'cloud-download',
label: 'Export',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
@@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'edit',
label: 'Rename',
label: r.rename,
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
@@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !onArchive,
icon: 'archive',
label: 'Archive',
label: r.archive,
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
@@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
icon: 'trash',
label: 'Delete',
label: t.common.delete,
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
@@ -132,6 +135,7 @@ interface SessionActionsMenuProps
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
return (
@@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${actions.title}`}
aria-label={t.sidebar.row.actionsFor(actions.title)}
className="w-40"
sideOffset={sideOffset}
>
@@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
}
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
@@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
const { t } = useI18n()
const r = t.sidebar.row
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
const result = await renameSession(sessionId, next, profile)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Rename failed')
notifyError(err, r.renameFailed)
} finally {
setSubmitting(false)
}
@@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename session</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
<DialogTitle>{r.renameTitle}</DialogTitle>
<DialogDescription>{r.renameDesc}</DialogDescription>
</DialogHeader>
<Input
autoFocus
@@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
onOpenChange(false)
}
}}
placeholder="Untitled session"
placeholder={r.untitledPlaceholder}
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={submitting} onClick={() => void submit()} type="button">
Save
{t.common.save}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
const AGE_TICKS: ReadonlyArray<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm']
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
[86_400_000, 'ageDay'],
[3_600_000, 'ageHour'],
[60_000, 'ageMin']
]
function formatAge(seconds: number): string {
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
const delta = Math.max(0, Date.now() - seconds * 1000)
for (const [ms, suffix] of AGE_TICKS) {
for (const [ms, key] of AGE_TICKS) {
if (delta >= ms) {
return `${Math.floor(delta / ms)}${suffix}`
return `${Math.floor(delta / ms)}${r[key]}`
}
}
return 'now'
return r.ageNow
}
export function SidebarSessionRow({
@@ -61,8 +62,10 @@ export function SidebarSessionRow({
ref,
...rest
}: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
@@ -196,10 +199,10 @@ export function SidebarSessionRow({
title={title}
>
<Button
aria-label={`Actions for ${title}`}
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
@@ -220,6 +223,9 @@ function SidebarRowDot({
needsInput?: boolean
className?: string
}) {
const { t } = useI18n()
const r = t.sidebar.row
// "Needs input" wins over "working": a clarify-blocked session is technically
// still running, but the actionable state is that it's waiting on the user.
// Amber + steady (no ping) reads as "your turn", distinct from the accent
@@ -227,17 +233,17 @@ function SidebarRowDot({
if (needsInput) {
return (
<span
aria-label="Needs your input"
aria-label={r.needsInput}
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title="Waiting for your answer"
title={r.waitingForAnswer}
/>
)
}
return (
<span
aria-label={isWorking ? 'Session running' : undefined}
aria-label={isWorking ? r.sessionRunning : undefined}
className={cn(
'rounded-full',
isWorking
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
@@ -32,12 +33,15 @@ export function CronJobActionsMenu({
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
const { t } = useI18n()
const c = t.cron
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
aria-label={c.actionsFor(title)}
className="w-44"
sideOffset={sideOffset}
>
@@ -49,7 +53,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -60,7 +64,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
<span>{c.triggerNow}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -70,7 +74,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
<span>{c.edit}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -81,7 +85,7 @@ export function CronJobActionsMenu({
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return (
<Button
aria-label={`Actions for ${title}`}
aria-label={t.cron.actionsFor(title)}
className={className}
size="icon-sm"
title="Cron job actions"
title={t.cron.actionsTitle}
variant="ghost"
{...props}
>
+178 -179
View File
@@ -1,10 +1,9 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Dialog,
DialogContent,
@@ -14,7 +13,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -27,78 +25,49 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
{ label: 'This desktop', value: 'local' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Slack', value: 'slack' },
{ label: 'Email', value: 'email' }
]
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{
expr: '0 9 * * *',
hint: 'Every day at 9:00 AM',
label: 'Daily',
value: 'daily'
},
{
expr: '0 9 * * 1-5',
hint: 'Monday through Friday at 9:00 AM',
label: 'Weekdays',
value: 'weekdays'
},
{
expr: '0 9 * * 1',
hint: 'Every Monday at 9:00 AM',
label: 'Weekly',
value: 'weekly'
},
{
expr: '0 9 1 * *',
hint: 'The first day of each month at 9:00 AM',
label: 'Monthly',
value: 'monthly'
},
{
expr: '0 * * * *',
hint: 'At the top of every hour',
label: 'Hourly',
value: 'hourly'
},
{
expr: '*/15 * * * *',
hint: 'Every 15 minutes',
label: 'Every 15 minutes',
value: 'every-15-minutes'
},
{
hint: 'Cron syntax or natural language',
label: 'Custom',
value: 'custom'
}
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
{ expr: '0 9 * * 1', value: 'weekly' },
{ expr: '0 9 1 * *', value: 'monthly' },
{ expr: '0 * * * *', value: 'hourly' },
{ expr: '*/15 * * * *', value: 'every-15-minutes' },
{ value: 'custom' }
]
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
enabled: 'default',
scheduled: 'default',
running: 'default',
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
paused: 'warn',
disabled: 'muted',
error: 'destructive',
error: 'bad',
completed: 'muted'
}
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
}
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : value)
@@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] {
return parts.length === 5 ? parts : null
}
function dayName(value: string): string {
const names: Record<string, string> = {
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
}
return names[value] ?? `day ${value}`
function dayName(value: string, c: Translations['cron']): string {
return c.days[value] ?? c.dayFallback(value)
}
function formatCronTime(minute: string, hour: string): string {
@@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
}
function scheduleSummary(option: ScheduleOption, expr: string): string {
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
const parts = cronParts(expr)
if (!parts) {
return option.hint
return c.scheduleHints[option.value] ?? ''
}
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
if (option.value === 'daily') {
return `Every day at ${formatCronTime(minute, hour)}`
return c.everyDayAt(formatCronTime(minute, hour))
}
if (option.value === 'weekdays') {
return `Weekdays at ${formatCronTime(minute, hour)}`
return c.weekdaysAt(formatCronTime(minute, hour))
}
if (option.value === 'weekly') {
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
}
if (option.value === 'monthly') {
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
}
if (option.value === 'hourly') {
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
}
return option.hint
return c.scheduleHints[option.value] ?? ''
}
function formatTime(iso?: null | string): string {
@@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps {
interface CronViewProps extends React.ComponentProps<'section'> {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose }: CronViewProps) {
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
const { t } = useI18n()
const c = t.cron
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, 'Failed to load cron jobs')
notifyError(err, c.failedLoad)
} finally {
setRefreshing(false)
}
}, [])
}, [c])
useRefreshHotkey(refresh)
@@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) {
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({
kind: 'success',
title: isPaused ? 'Cron resumed' : 'Cron paused',
title: isPaused ? c.resumed : c.paused,
message: truncate(jobTitle(job), 60)
})
} catch (err) {
notifyError(err, 'Failed to update cron job')
notifyError(err, c.failedUpdate)
} finally {
setBusyJobId(null)
}
@@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) {
try {
const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, 'Failed to trigger cron job')
notifyError(err, c.failedTrigger)
} finally {
setBusyJobId(null)
}
}
async function handleConfirmDelete() {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, c.failedDelete)
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) {
})
setJobs(current => (current ? [...current, created] : [created]))
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
prompt: values.prompt,
@@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) {
})
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
}
setEditor({ mode: 'closed' })
}
return (
<OverlayView closeLabel="Close cron" onClose={onClose}>
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
{totalCount > 0 && (
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
<SearchField
containerClassName="max-w-[60vw]"
onChange={setQuery}
placeholder="Search cron jobs…"
value={query}
/>
</div>
)}
<OverlayView closeLabel={c.close} onClose={onClose}>
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder={c.search}
searchTrailingAction={
<Button
aria-label={refreshing ? c.refreshing : c.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? c.refreshing : c.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
<PageLoader label={c.loading} />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
actionLabel={totalCount === 0 ? c.createFirst : undefined}
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
/>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
{c.active(enabledCount, totalCount)}
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
{c.newCron}
</Button>
</div>
<div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
c={c}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
@@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
</div>
</div>
)}
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
It will stop firing immediately.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (!pendingDelete) {
return
}
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
}}
open={pendingDelete !== null}
title="Delete cron job?"
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{c.deleteDescPrefix}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
{c.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? c.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}
function CronJobRow({
busy,
c,
job,
onDelete,
onEdit,
@@ -501,6 +491,7 @@ function CronJobRow({
onTrigger
}: {
busy: boolean
c: Translations['cron']
job: CronJob
onDelete: () => void
onEdit: () => void
@@ -516,19 +507,15 @@ function CronJobRow({
return (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
{state}
</Badge>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
@@ -537,8 +524,12 @@ function CronJobRow({
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
@@ -569,6 +560,16 @@ function CronJobRow({
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
>
{children}
</span>
)
}
function EmptyState({
actionLabel,
description,
@@ -605,6 +606,8 @@ function CronEditorDialog({
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
const { t } = useI18n()
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const initial = isEdit ? editor.job : null
@@ -647,7 +650,7 @@ function CronEditorDialog({
}
}
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -655,7 +658,7 @@ function CronEditorDialog({
const trimmedSchedule = schedule.trim()
if (!trimmedPrompt || !trimmedSchedule) {
setError('Prompt and schedule are required.')
setError(c.promptScheduleRequired)
return
}
@@ -671,7 +674,7 @@ function CronEditorDialog({
schedule: trimmedSchedule
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save cron job')
setError(err instanceof Error ? err.message : c.failedSave)
} finally {
setSaving(false)
}
@@ -681,60 +684,56 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the schedule, prompt, or delivery target. Changes apply on next run.'
: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="cron-name" label="Name" optional>
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder="Morning briefing"
placeholder={c.namePlaceholder}
value={name}
/>
</Field>
<Field htmlFor="cron-prompt" label="Prompt">
<Field htmlFor="cron-prompt" label={c.promptLabel}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder="Summarize my unread Slack threads and email me the top 5..."
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger id="cron-frequency">
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label="Deliver to">
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{DELIVERY_VALUES.map(value => (
<SelectItem key={value} value={value}>
{c.deliveryLabels[value]}
</SelectItem>
))}
</SelectContent>
@@ -743,15 +742,15 @@ function CronEditorDialog({
</div>
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label="Custom schedule">
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * * or weekdays at 9am"
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
@@ -771,10 +770,10 @@ function CronEditorDialog({
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
@@ -787,18 +786,20 @@ function Field({
children,
htmlFor,
label,
optional
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
@@ -820,7 +821,5 @@ interface EditorValues {
interface ScheduleOption {
expr?: string
hint: string
label: string
value: string
}
+21 -1
View File
@@ -29,7 +29,7 @@ import {
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -506,6 +506,25 @@ export function DesktopController() {
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill. Both are nanostores, so the blanket
// invalidateQueries() the profile store fires on swap doesn't touch them —
// without this the statusbar keeps showing the previous profile's model
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
// swap (background sockets persist), so the open→open effect won't re-run.
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -681,6 +700,7 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -151,7 +152,7 @@ export function useGatewayBoot({
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
}
} finally {
reconnecting = false
+94 -77
View File
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
@@ -14,6 +13,7 @@ import {
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -33,31 +33,15 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
type EditMap = Record<string, Record<string, string>>
const STATE_LABELS: Record<string, string> = {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
const PILL_TONE: Record<StatusTone, string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
}
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
good: 'default',
muted: 'muted',
warn: 'warn',
bad: 'destructive'
}
const HINT_BY_STATE: Record<string, string> = {
pending_restart: 'Restart the gateway from the status bar to apply this change.',
gateway_stopped: 'Start the gateway from the status bar to connect.'
}
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {
@@ -86,7 +70,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
TELEGRAM_BOT_TOKEN: {
label: 'Bot token',
help: 'Create a bot with @BotFather, then paste the token it gives you.',
placeholder: '123456:ABC...'
placeholder: 'Paste Telegram bot token'
},
TELEGRAM_ALLOWED_USERS: {
label: 'Allowed Telegram user IDs',
@@ -153,13 +137,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
placeholder: 'xoxb-...'
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
placeholder: 'Paste Slack bot token'
},
SLACK_APP_TOKEN: {
label: 'Slack app token',
help: 'Starts with xapp-. Required for Socket Mode.',
placeholder: 'xapp-...'
help: 'Use the app-level token required for Socket Mode.',
placeholder: 'Paste Slack app token'
},
SLACK_ALLOWED_USERS: {
label: 'Allowed Slack user IDs',
@@ -219,18 +203,21 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
}
}
function fieldCopy(field: MessagingEnvVarInfo) {
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
const copy = FIELD_COPY[field.key] || {}
const localized = m.fieldCopy[field.key] || {}
return {
label: copy.label || field.prompt || field.key,
help: copy.help || field.description,
placeholder: copy.placeholder || field.prompt,
label: localized.label || copy.label || field.prompt || field.key,
help: localized.help || copy.help || field.description,
placeholder: localized.placeholder || copy.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced)
}
}
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@@ -249,14 +236,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, 'Messaging platforms failed to load')
notifyError(err, m.loadFailed)
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
}, [])
}, [m])
useRefreshHotkey(() => void refreshPlatforms())
@@ -330,11 +317,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
)
notify({
kind: 'success',
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
message: 'Restart the gateway for this change to take effect.'
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply
})
} catch (err) {
notifyError(err, `Failed to update ${platform.name}`)
notifyError(err, m.failedUpdate(platform.name))
} finally {
setSaving(null)
}
@@ -355,11 +342,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
await refreshPlatforms()
notify({
kind: 'success',
title: `${platform.name} setup saved`,
message: 'Restart the gateway to reconnect with the new credentials.'
title: m.setupSaved(platform.name),
message: m.restartToReconnect
})
} catch (err) {
notifyError(err, `Failed to save ${platform.name}`)
notifyError(err, m.failedSave(platform.name))
} finally {
setSaving(null)
}
@@ -378,9 +365,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
await refreshPlatforms()
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
} catch (err) {
notifyError(err, `Failed to clear ${key}`)
notifyError(err, m.failedClear(key))
} finally {
setSaving(null)
}
@@ -391,11 +378,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..."
searchPlaceholder={m.search}
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
<PageLoader label={m.loading} />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto p-2">
@@ -485,12 +472,14 @@ function PlatformDetail({
platform: MessagingPlatformInfo
saving: string | null
}) {
const { t } = useI18n()
const m = t.messaging
const [showAdvanced, setShowAdvanced] = useState(false)
const hasEdits = Object.keys(trimEdits(edits)).length > 0
const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
const hiddenCount = advancedFields.length
const isSavingEnv = saving === `env:${platform.id}`
@@ -506,11 +495,11 @@ function PlatformDetail({
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
<SetupPill active={platform.configured}>
{platform.configured ? 'Credentials set' : 'Needs setup'}
{platform.configured ? m.credentialsSet : m.needsSetup}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
@@ -524,14 +513,14 @@ function PlatformDetail({
)}
<section>
<SectionTitle>Get your credentials</SectionTitle>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform)}
{introCopy(platform, m)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
@@ -539,7 +528,7 @@ function PlatformDetail({
</section>
<section>
<SectionTitle>Required</SectionTitle>
<SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
@@ -554,7 +543,7 @@ function PlatformDetail({
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
This platform does not need a token here. Use the setup guide above, then enable it below.
{m.noTokenNeeded}
</p>
)}
</div>
@@ -562,7 +551,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
@@ -585,7 +574,7 @@ function PlatformDetail({
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>Advanced ({hiddenCount})</span>
<span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
@@ -609,19 +598,23 @@ function PlatformDetail({
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? 'Saving...' : 'Save changes'}
{isSavingEnv ? m.saving : m.saveChanges}
</Button>
</div>
</div>
@@ -636,7 +629,7 @@ const PLATFORM_INTRO: Record<string, string> = {
discord:
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
slack:
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
@@ -667,7 +660,8 @@ const PLATFORM_INTRO: Record<string, string> = {
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
function MessagingField({
edits,
@@ -682,7 +676,9 @@ function MessagingField({
onEdit: (key: string, value: string) => void
saving: string | null
}) {
const copy = fieldCopy(field)
const { t } = useI18n()
const m = t.messaging
const copy = fieldCopy(field, m)
const fieldId = `messaging-field-${field.key}`
return (
@@ -693,12 +689,12 @@ function MessagingField({
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
@@ -709,7 +705,7 @@ function MessagingField({
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
title={m.clearField(field.key)}
variant="ghost"
>
<Trash2 className="size-3.5" />
@@ -721,7 +717,7 @@ function MessagingField({
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
</span>
}
/>
@@ -733,24 +729,45 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
const { t } = useI18n()
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
const hint =
platform.state === 'pending_restart'
? t.messaging.hintPendingRestart
: platform.gateway_running
? null
: t.messaging.hintGatewayStopped
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
}
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return (
<Badge variant={TONE_VARIANT[tone]}>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[tone]
)}
>
<StatusDot tone={tone} />
{children}
</Badge>
</span>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[active ? 'good' : 'muted']
)}
>
{children}
</span>
)
}
@@ -0,0 +1,37 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
inputRef?: RefObject<HTMLInputElement | null>
loading?: boolean
onChange: (value: string) => void
placeholder: string
value: string
}
export function OverlaySearchInput({
containerClassName,
inputRef,
loading = false,
onChange,
placeholder,
value
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
inputRef={inputRef}
loading={loading}
onChange={onChange}
placeholder={placeholder}
value={value}
/>
)
}
@@ -11,6 +11,7 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
filters?: ReactNode
onSearchChange: (value: string) => void
searchPlaceholder: string
searchTrailingAction?: ReactNode
searchValue: string
/** Hide the search field when there's nothing to search (empty dataset). */
searchHidden?: boolean
@@ -23,6 +24,7 @@ export function PageSearchShell({
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
searchHidden = false,
...props
@@ -58,6 +60,7 @@ export function PageSearchShell({
containerClassName="max-w-[45vw]"
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
</div>
File diff suppressed because it is too large Load Diff
@@ -331,7 +331,14 @@ export function useSessionActions({
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
})
const stored = created.stored_session_id ?? null
if (
@@ -453,15 +460,31 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
return
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
}
}
setFreshDraftReady(false)
@@ -513,7 +536,11 @@ export function useSessionActions({
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
cols: 96,
// Owning profile: in app-global remote mode one backend serves every
// profile, so the gateway opens this profile's state.db + home to
// resume + persist the right session (no-op for single/launch profile).
...(sessionProfile ? { profile: sessionProfile } : {})
})
if (!isCurrentResume()) {
@@ -747,7 +774,7 @@ export function useSessionActions({
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
await deleteSession(storedSessionId)
await deleteSession(storedSessionId, removed?.profile)
clearQueuedPrompts(storedSessionId)
if (closingRuntimeId) {
@@ -823,7 +850,7 @@ export function useSessionActions({
}
try {
await setSessionArchived(storedSessionId, true)
await setSessionArchived(storedSessionId, true, archived?.profile)
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
} catch (err) {
if (archived) {
@@ -2,7 +2,8 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@@ -18,29 +19,31 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
function relativeTime(ms: number | undefined) {
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
if (!ms) {
return 'never'
return a.never
}
const diff = Date.now() - ms
if (diff < 60_000) {
return 'just now'
return a.justNow
}
if (diff < 3_600_000) {
return `${Math.round(diff / 60_000)} min ago`
return a.minAgo(Math.round(diff / 60_000))
}
if (diff < 86_400_000) {
return `${Math.round(diff / 3_600_000)} hours ago`
return a.hoursAgo(Math.round(diff / 3_600_000))
}
return `${Math.round(diff / 86_400_000)} days ago`
return a.daysAgo(Math.round(diff / 86_400_000))
}
export function AboutSettings() {
const { t } = useI18n()
const a = t.settings.about
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
@@ -69,21 +72,21 @@ export function AboutSettings() {
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? "This build can't update itself from inside the app."
statusLine = status?.message ?? a.cantUpdate
statusTone = 'error'
} else if (status?.error) {
statusLine = "We couldn't reach the update server."
statusLine = a.cantReach
statusTone = 'error'
} else if (applying) {
statusLine = 'An update is currently installing.'
statusLine = a.installing
statusTone = 'available'
} else if (behind > 0) {
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
statusLine = a.updateReady(behind)
statusTone = 'available'
} else if (status) {
statusLine = "You're on the latest version."
statusLine = a.onLatest
} else {
statusLine = 'Tap "Check now" to look for updates.'
statusLine = a.tapCheck
}
return (
@@ -93,15 +96,15 @@ export function AboutSettings() {
<Sparkles className="size-8" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
</p>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title="Updates" />
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
@@ -111,12 +114,19 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
@@ -126,13 +136,13 @@ export function AboutSettings() {
size="sm"
variant="textStrong"
>
{checking && <Loader2 className="size-3 animate-spin" />}
{checking ? 'Checking…' : 'Check now'}
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
{behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm">
See what&apos;s new
{a.seeWhatsNew}
</Button>
)}
@@ -146,16 +156,17 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
Release notes
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
title="Automatic updates"
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
</div>
</SettingsContent>
@@ -1,16 +1,16 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check } from '@/lib/icons'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { SettingsContent } from './primitives'
import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -52,80 +52,193 @@ function ThemePreview({ name }: { name: string }) {
)
}
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
</div>
{control && <div className="shrink-0">{control}</div>}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale, locale, setLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
const locales = Object.keys(LOCALE_META) as Locale[]
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
return
}
triggerHaptic('selection')
try {
await setLocale(code)
triggerHaptic('success')
} catch (error) {
notifyError(error, t.language.saveError)
}
}
return (
<SettingsContent>
<div className="grid gap-8">
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
</p>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
</div>
<section>
<SectionHead
control={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={MODE_OPTIONS}
value={mode}
/>
}
description="Pick a fixed mode or let Hermes follow your system setting."
title="Color Mode"
/>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<Pill>{LOCALE_META[locale].name}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{locales.map(code => {
const active = locale === code
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
disabled={isSavingLocale}
key={code}
onClick={() => void selectLocale(code)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{LOCALE_META[code].name}
</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
{code}
</div>
</button>
)
})}
</div>
</section>
<section>
<SectionHead
control={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={
[
{ id: 'product', label: 'Product' },
{ id: 'technical', label: 'Technical' }
] as const
}
value={toolViewMode}
/>
}
description="Product hides raw tool payloads; Technical shows full input/output."
title="Tool Call Display"
/>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.colorMode}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
</div>
<Pill>{t.settings.modeOptions[mode].label}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
const active = mode === id
const copy = t.settings.modeOptions[id]
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section>
<section className="grid gap-3">
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3">
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className="group text-left"
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
@@ -133,17 +246,8 @@ export function AppearanceSettings() {
}}
type="button"
>
<div
className={cn(
'rounded-xl transition',
active
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'opacity-90 group-hover:opacity-100'
)}
>
<ThemePreview name={theme.name} />
</div>
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
@@ -152,7 +256,11 @@ export function AppearanceSettings() {
{theme.description}
</div>
</div>
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
@@ -13,6 +13,7 @@ import {
getHermesConfigSchema,
saveHermesConfig
} from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
@@ -37,9 +38,20 @@ function ConfigField({
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
}) {
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const { t } = useI18n()
const label =
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
const rawDescription = (
t.settings.fieldDescriptions[schemaKey] ??
FIELD_DESCRIPTIONS[schemaKey] ??
schema.description ??
''
).trim()
const normalizedDesc = normalize(rawDescription)
const description =
+9 -4
View File
@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'memory.provider': ['', 'builtin', 'honcho'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
export const FIELD_LABELS: Record<string, string> = {
@@ -309,7 +310,8 @@ 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'
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
'voice.auto_tts': 'Automatically speak assistant responses.',
'stt.enabled': 'Enable local or provider-backed speech transcription.',
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
'updates.non_interactive_local_changes':
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
// Curated desktop config surface: only fields a user might tune from the app.
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort'
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
]
}
]
@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -6,6 +7,7 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
@@ -74,6 +76,23 @@ function ModeCard({
)
}
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -83,6 +102,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
@@ -100,8 +129,14 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig()
.getConnectionConfig(scope)
.then(config => {
if (cancelled) {
return
@@ -117,7 +152,7 @@ export function GatewaySettings() {
})
return () => void (cancelled = true)
}, [])
}, [scope])
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
@@ -223,6 +258,10 @@ export function GatewaySettings() {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -239,6 +278,7 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -296,6 +336,7 @@ export function GatewaySettings() {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@@ -305,7 +346,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@@ -327,7 +368,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@@ -357,6 +398,7 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -395,10 +437,35 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
its own remote host.
</p>
</div>
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
Applies to
</div>
<div className="flex flex-wrap gap-1.5">
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
{namedProfiles.map(profile => (
<ScopeChip
active={scope === profile.name}
key={profile.name}
label={profile.name}
onSelect={() => setScope(profile.name)}
/>
))}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope === null
? 'Default connection for every profile that has no override of its own.'
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
</p>
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
+15 -13
View File
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
@@ -34,6 +35,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
@@ -64,12 +66,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, 'Export failed')
notifyError(err, t.settings.exportFailed)
}
}
const resetConfig = async () => {
if (!window.confirm('Reset all settings to Hermes defaults?')) {
if (!window.confirm(t.settings.resetConfirm)) {
return
}
@@ -78,12 +80,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, 'Reset failed')
notifyError(err, t.settings.resetFailed)
}
}
return (
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@@ -94,7 +96,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
active={activeView === view}
icon={s.icon}
key={s.id}
label={s.label}
label={t.settings.sections[s.id] ?? s.label}
onClick={() => setActiveView(view)}
/>
)
@@ -127,13 +129,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label="Gateway"
label={t.settings.nav.gateway}
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="Tools & Keys"
label={t.settings.nav.apiKeys}
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
@@ -157,29 +159,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label="MCP"
label={t.settings.nav.mcp}
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label="Archived Chats"
label={t.settings.nav.archivedChats}
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label="About"
label={t.settings.nav.about}
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label="Export config">
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Import config">
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
@@ -189,7 +191,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<IconUpload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Reset to defaults">
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
@@ -57,7 +57,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false)
await setSessionArchived(session.id, false, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
@@ -78,7 +78,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await deleteSession(session.id)
await deleteSession(session.id, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
@@ -44,6 +45,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
@@ -76,7 +78,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
@@ -85,12 +87,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: 'Swap sidebar sides',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
},
title: 'Swap the sessions and file browser sides'
title: t.titlebar.swapSidebarSidesTitle
},
...leftTools
]
@@ -98,7 +100,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = {
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
@@ -111,13 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
label: 'Open settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
@@ -199,6 +201,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
@@ -221,6 +224,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
+1 -1
View File
@@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
await renderSkills()
expect(screen.getByText('Cron Jobs')).toBeTruthy()
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull()
})
+77 -57
View File
@@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
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, toggleToolset } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -70,33 +72,39 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
}
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
const { t } = useI18n()
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
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)
try {
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
setSkills(nextSkills)
setToolsets(nextToolsets)
} catch (err) {
notifyError(err, 'Skills failed to load')
notifyError(err, t.skills.skillsLoadFailed)
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshCapabilities)
}, [t])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
}, [t])
useRefreshHotkey(refreshCapabilities)
useEffect(() => {
void refreshCapabilities()
@@ -148,11 +156,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
message: t.skills.appliesToNewSessions(skill.name)
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
notifyError(err, t.skills.failedToUpdate(skill.name))
} finally {
setSavingSkill(null)
}
@@ -169,11 +177,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
})
} catch (err) {
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
} finally {
setSavingToolset(null)
}
@@ -183,54 +191,66 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
))}
</>
) : undefined
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</>
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchTrailingAction={
<Button
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? t.skills.refreshing : t.skills.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -239,7 +259,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'}
{asText(skill.description) || t.skills.noDescription}
</p>
</div>
<Switch
@@ -256,15 +276,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div>
<div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
@@ -277,19 +297,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label={t.skills.configureToolset(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'}
{toolset.configured ? t.skills.configured : t.skills.needsKeys}
</StatusPill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
aria-label={t.skills.toggleToolset(label)}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
@@ -297,7 +317,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'}
{asText(toolset.description) || t.skills.noDescription}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
@@ -27,6 +28,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
export function BootFailureOverlay() {
const boot = useStore($desktopBoot)
const onboarding = useStore($desktopOnboarding)
const { t } = useI18n()
const [busy, setBusy] = useState<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false)
@@ -141,7 +143,7 @@ export function BootFailureOverlay() {
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
if (result?.connected) {
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage })
window.location.reload()
return
@@ -149,19 +151,24 @@ export function BootFailureOverlay() {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
} catch (err) {
notifyError(err, 'Sign-in failed')
notifyError(err, t.boot.failure.signInFailed)
} finally {
setBusy(null)
}
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const copy = t.boot.failure
const label = signInLabel(remoteReauth)
const label = signInLabel(remoteReauth, {
identityProvider: copy.identityProvider,
remoteGateway: copy.signInToRemoteGateway,
withProvider: copy.signInWithProvider
})
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
@@ -172,12 +179,10 @@ export function BootFailureOverlay() {
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
{remoteReauth ? copy.remoteTitle : copy.title}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{remoteReauth
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
{remoteReauth ? copy.remoteDescription : copy.description}
</p>
</div>
</div>
@@ -197,28 +202,26 @@ export function BootFailureOverlay() {
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
{copy.retry}
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
{copy.useLocalGateway}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
Open logs
{copy.openLogs}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{remoteReauth
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
{remoteReauth ? copy.remoteSignInHint : copy.repairHint}
</p>
</div>
@@ -229,7 +232,7 @@ export function BootFailureOverlay() {
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? 'Hide' : 'Show'} recent logs
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
@@ -14,6 +14,18 @@ export interface RemoteReauth {
providerLabel: string
}
interface SignInCopy {
identityProvider: string
remoteGateway: string
withProvider: (provider: string) => string
}
const DEFAULT_SIGN_IN_COPY: SignInCopy = {
identityProvider: 'your identity provider',
remoteGateway: 'Sign in to remote gateway',
withProvider: provider => `Sign in with ${provider}`
}
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
@@ -58,10 +70,12 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
}
// Button copy for the remote sign-in action.
export function signInLabel(reauth: RemoteReauth | null): string {
export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
if (reauth?.isPassword) {
return 'Sign in to remote gateway'
return copy.remoteGateway
}
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
return copy.withProvider(provider ?? copy.identityProvider)
}
+16 -8
View File
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -30,8 +31,10 @@ const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
export function NotificationStack() {
const notifications = useStore($notifications)
const { t } = useI18n()
const lastNotificationIdRef = useRef<string | null>(null)
const [expanded, setExpanded] = useState(false)
const copy = t.notifications
useEffect(() => {
if (notifications.length <= 1) {
@@ -72,7 +75,7 @@ export function NotificationStack() {
// scope, so fall back to its constant (34px) when mounted on <body>.
return createPortal(
<div
aria-label="Notifications"
aria-label={copy.region}
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
@@ -81,10 +84,10 @@ export function NotificationStack() {
{overflowCount > 0 && (
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
</button>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
Clear all
{copy.clearAll}
</button>
</div>
)}
@@ -97,6 +100,8 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
const { t } = useI18n()
const copy = t.notifications
return (
<Alert
@@ -126,7 +131,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
</AlertDescription>
</div>
<button
aria-label="Dismiss notification"
aria-label={copy.dismiss}
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dismissNotification(notification.id)}
type="button"
@@ -138,9 +143,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
}
function NotificationDetail({ detail }: { detail: string }) {
const { t } = useI18n()
const copy = t.notifications
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
@@ -148,12 +156,12 @@ function NotificationDetail({ detail }: { detail: string }) {
<CopyButton
appearance="inline"
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
errorMessage="Could not copy notification detail"
errorMessage={copy.copyDetailFailed}
iconClassName="size-3"
label="Copy detail"
label={copy.copyDetail}
text={detail}
>
Copy detail
{copy.copyDetail}
</CopyButton>
</div>
</details>
+7 -1
View File
@@ -12,7 +12,7 @@ declare global {
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
@@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
@@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
+21 -4
View File
@@ -29,6 +29,7 @@ import type {
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionMessagesResponse,
@@ -80,6 +81,7 @@ export type {
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
RpcEvent,
@@ -166,8 +168,13 @@ export async function listAllProfileSessions(
}
}
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
// Mutations take the owning `profile` so Electron routes them to that profile's
// backend (remote pool or local primary) via request.profile — matching the
// read path. A remote session's row lives only on its remote host, so a mutation
// that hit the local primary would no-op or 404. Omit for the current/default.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
@@ -180,8 +187,10 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
// `profile` reads another profile's transcript straight off its state.db via the
// primary backend (no spawn). Omit for the current/default profile.
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
// the current/default profile.
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
@@ -190,8 +199,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
})
}
export function deleteSession(id: string): Promise<{ ok: boolean }> {
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
@@ -203,6 +213,7 @@ export function renameSession(
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title, ...(profile ? { profile } : {}) }
@@ -554,6 +565,12 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
+8
View File
@@ -0,0 +1,8 @@
import { en } from './en'
import type { Locale, Translations } from './types'
import { zh } from './zh'
export const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh
}
+168
View File
@@ -0,0 +1,168 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesConfigRecord } from '@/hermes'
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
import type { Locale } from './types'
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
const { isLoadingConfig, isSavingLocale, locale, saveError, setLocale, t } = useI18n()
return (
<div>
<p data-testid="locale">{locale}</p>
<p data-testid="label">{t.language.label}</p>
<p data-testid="loading">{String(isLoadingConfig)}</p>
<p data-testid="saving">{String(isSavingLocale)}</p>
<p data-testid="save-error">{saveError?.message ?? ''}</p>
<button onClick={() => void setLocale(target).catch(() => undefined)} type="button">
switch
</button>
</div>
)
}
describe('I18nProvider', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('defaults to English without a config client', () => {
render(
<I18nProvider configClient={null}>
<LanguageProbe />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
})
it('normalizes an initial locale alias and switches translations', async () => {
render(
<I18nProvider configClient={null} initialLocale="zh-CN">
<LanguageProbe target="en" />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('zh')
expect(screen.getByTestId('label').textContent).toBe('语言')
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
expect(screen.getByTestId('label').textContent).toBe('Language')
})
it('loads the initial locale from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-Hans' } }),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('zh')
expect(screen.getByTestId('label').textContent).toBe('语言')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('keeps English usable when config loading fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('does not overwrite unsupported configured languages', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('reads latest config before saving language and preserves unrelated values', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = {
display: { language: 'en', skin: 'slate' },
terminal: { cwd: '/new' }
}
const configClient: I18nConfigClient = {
getConfig: vi
.fn()
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' }, terminal: { cwd: '/old' } })
.mockResolvedValueOnce(latestConfig),
saveConfig
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
expect(saveConfig).toHaveBeenCalledWith({
display: { language: 'zh', skin: 'slate' },
terminal: { cwd: '/new' }
})
})
it('rolls back the visible locale when saving fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
saveConfig: vi.fn().mockRejectedValue(new Error('save failed'))
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('save failed'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
})
})
+183
View File
@@ -0,0 +1,183 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes'
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages'
import { setRuntimeI18nLocale } from './runtime'
import type { Locale, Translations } from './types'
export { LOCALE_META } from './languages'
export interface I18nConfigClient {
getConfig: () => Promise<HermesConfigRecord>
saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }>
}
const defaultConfigClient: I18nConfigClient = {
getConfig: () => {
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
return Promise.resolve({})
}
return getHermesConfigRecord()
},
saveConfig: config => {
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
return Promise.resolve({ ok: true })
}
return saveHermesConfig(config)
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown {
return isRecord(config.display) ? config.display.language : undefined
}
export function withConfigDisplayLanguage(config: HermesConfigRecord, locale: Locale): HermesConfigRecord {
const display = isRecord(config.display) ? config.display : {}
return {
...config,
display: {
...display,
language: localeConfigValue(locale)
}
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export interface I18nContextValue {
configLoadError: Error | null
isLoadingConfig: boolean
isSavingLocale: boolean
locale: Locale
saveError: Error | null
setLocale: (next: Locale) => Promise<void>
t: Translations
}
const I18nContext = createContext<I18nContextValue>({
configLoadError: null,
isLoadingConfig: false,
isSavingLocale: false,
locale: DEFAULT_LOCALE,
saveError: null,
setLocale: async () => {},
t: TRANSLATIONS[DEFAULT_LOCALE]
})
export interface I18nProviderProps {
children: ReactNode
configClient?: I18nConfigClient | null
initialLocale?: unknown
}
export function I18nProvider({ children, configClient = defaultConfigClient, initialLocale }: I18nProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() => normalizeLocale(initialLocale))
const [isLoadingConfig, setIsLoadingConfig] = useState(false)
const [isSavingLocale, setIsSavingLocale] = useState(false)
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
const [saveError, setSaveError] = useState<Error | null>(null)
const localeRef = useRef(locale)
useEffect(() => {
localeRef.current = locale
setRuntimeI18nLocale(locale)
}, [locale])
useEffect(() => {
if (!configClient) {
return
}
let cancelled = false
setIsLoadingConfig(true)
setConfigLoadError(null)
configClient
.getConfig()
.then(config => {
if (!cancelled) {
setLocaleState(normalizeLocale(getConfigDisplayLanguage(config)))
}
})
.catch(error => {
if (!cancelled) {
setConfigLoadError(toError(error))
setLocaleState(DEFAULT_LOCALE)
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingConfig(false)
}
})
return () => {
cancelled = true
}
}, [configClient, initialLocale])
const setLocale = useCallback(
async (next: Locale) => {
const previousLocale = localeRef.current
setSaveError(null)
setLocaleState(next)
if (!configClient) {
return
}
setIsSavingLocale(true)
try {
const latestConfig = await configClient.getConfig()
const result = await configClient.saveConfig(withConfigDisplayLanguage(latestConfig, next))
if (!result.ok) {
throw new Error('Failed to save language')
}
} catch (error) {
const nextError = toError(error)
setLocaleState(previousLocale)
setSaveError(nextError)
throw nextError
} finally {
setIsSavingLocale(false)
}
},
[configClient]
)
const value = useMemo<I18nContextValue>(
() => ({
configLoadError,
isLoadingConfig,
isSavingLocale,
locale,
saveError,
setLocale,
t: TRANSLATIONS[locale]
}),
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
return useContext(I18nContext)
}
+744
View File
@@ -0,0 +1,744 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
common: {
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
refresh: 'Refresh',
retry: 'Retry',
on: 'On',
off: 'Off'
},
boot: {
ready: 'Hermes Desktop is ready',
desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`,
steps: {
connectingGateway: 'Connecting live desktop gateway',
loadingSettings: 'Loading Hermes settings',
loadingSessions: 'Loading recent sessions',
startingDesktopConnection: 'Starting desktop connection',
startingHermesDesktop: 'Starting Hermes Desktop…'
},
errors: {
backgroundExited: 'Hermes background process exited.',
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
backendStopped: 'Backend stopped',
desktopBootFailed: 'Desktop boot failed',
gatewaySignInRequired: 'Gateway sign-in required',
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
},
failure: {
title: "Hermes couldn't start",
description:
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
remoteTitle: 'Remote gateway sign-in required',
remoteDescription:
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
retry: 'Retry',
repairInstall: 'Repair install',
useLocalGateway: 'Use local gateway',
openLogs: 'Open logs',
repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
hideRecentLogs: 'Hide recent logs',
showRecentLogs: 'Show recent logs',
signedInTitle: 'Signed in',
signedInMessage: 'Reconnecting to the remote gateway…',
signInIncompleteTitle: 'Sign-in incomplete',
signInIncompleteMessage: 'The login window closed before authentication finished.',
signInFailed: 'Sign-in failed',
signInToRemoteGateway: 'Sign in to remote gateway',
signInWithProvider: provider => `Sign in with ${provider}`,
identityProvider: 'your identity provider'
}
},
notifications: {
region: 'Notifications',
hide: 'Hide',
show: 'Show',
more: count => `${count} more ${count === 1 ? 'notification' : 'notifications'}`,
clearAll: 'Clear all',
dismiss: 'Dismiss notification',
details: 'Details',
copyDetail: 'Copy detail',
copyDetailFailed: 'Could not copy notification detail',
backendOutOfDateTitle: 'Backend out of date',
backendOutOfDateMessage:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
updateHermes: 'Update Hermes',
updateReadyTitle: 'Update ready',
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
seeWhatsNew: "See what's new",
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
methodNotAllowed:
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
microphonePermission: 'Microphone permission was denied.',
openaiRejectedApiKey: 'OpenAI rejected the API key.',
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
}
},
titlebar: {
hideSidebar: 'Hide sidebar',
showSidebar: 'Show sidebar',
search: 'Search',
searchTitle: 'Search sessions, views, and actions',
swapSidebarSides: 'Swap sidebar sides',
swapSidebarSidesTitle: 'Swap the sessions and file browser sides',
hideRightSidebar: 'Hide right sidebar',
showRightSidebar: 'Show right sidebar',
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings'
},
language: {
label: 'Language',
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
saveError: 'Language update failed'
},
settings: {
closeSettings: 'Close settings',
exportConfig: 'Export config',
importConfig: 'Import config',
resetToDefaults: 'Reset to defaults',
resetConfirm: 'Reset all settings to Hermes defaults?',
exportFailed: 'Export failed',
resetFailed: 'Reset failed',
nav: {
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
},
sections: {
model: 'Model',
chat: 'Chat',
appearance: 'Appearance',
workspace: 'Workspace',
safety: 'Safety',
memory: 'Memory & Context',
voice: 'Voice',
advanced: 'Advanced'
},
searchPlaceholder: {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
},
modeOptions: {
light: { label: 'Light', description: 'Bright desktop surfaces' },
dark: { label: 'Dark', description: 'Low-glare workspace' },
system: { label: 'System', description: 'Follow OS appearance' }
},
appearance: {
title: 'Appearance',
intro:
'These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and chat surface styling.',
colorMode: 'Color Mode',
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
toolViewTitle: 'Tool Call Display',
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
about: {
heading: 'Hermes Desktop',
version: value => `Version ${value}`,
versionUnavailable: 'Version unavailable',
updates: 'Updates',
checkNow: 'Check now',
checking: 'Checking…',
seeWhatsNew: "See what's new",
releaseNotes: 'Release notes',
onLatest: "You're on the latest version.",
installing: 'An update is currently installing.',
cantUpdate: "This build can't update itself from inside the app.",
cantReach: "We couldn't reach the update server.",
tapCheck: 'Tap "Check now" to look for updates.',
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
lastChecked: age => `Last checked ${age}`,
justNowSuffix: ' · just now',
automaticUpdates: 'Automatic updates',
automaticUpdatesDesc:
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
never: 'never',
justNow: 'just now',
minAgo: count => `${count} min ago`,
hoursAgo: count => `${count} hours ago`,
daysAgo: count => `${count} days ago`
}
},
skills: {
tabSkills: 'Skills',
tabToolsets: 'Toolsets',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search toolsets...',
refresh: 'Refresh skills',
refreshing: 'Refreshing skills',
loading: 'Loading capabilities...',
noSkillsTitle: 'No skills found',
noSkillsDesc: 'Try a broader search or different category.',
noToolsetsTitle: 'No toolsets found',
noToolsetsDesc: 'Try a broader search query.',
noDescription: 'No description.',
configured: 'Configured',
needsKeys: 'Needs keys',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
configureToolset: label => `Configure ${label}`,
toggleToolset: label => `Toggle ${label} toolset`,
skillsLoadFailed: 'Skills failed to load',
toolsetsRefreshFailed: 'Toolsets failed to refresh',
skillEnabled: 'Skill enabled',
skillDisabled: 'Skill disabled',
toolsetEnabled: 'Toolset enabled',
toolsetDisabled: 'Toolset disabled',
appliesToNewSessions: name => `${name} applies to new sessions.`,
failedToUpdate: name => `Failed to update ${name}`
},
agents: {
close: 'Close agents',
title: 'Spawn tree',
subtitle: 'Live subagent activity for the current turn.',
emptyTitle: 'No live subagents',
emptyDesc: 'When a turn delegates work, child agents stream their progress here.',
running: 'Running',
failed: 'Failed',
done: 'Done',
streaming: 'Streaming',
files: 'Files',
moreFiles: count => `+${count} more files`,
delegation: index => `Delegation ${index}`,
workers: count => `${count} workers`,
workersActive: count => `${count} active`,
agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`,
activeCount: count => `${count} active`,
failedCount: count => `${count} failed`,
toolsCount: count => `${count} tools`,
filesCount: count => `${count} files`,
updatedAgo: age => `updated ${age}`,
ageNow: 'now',
ageSeconds: seconds => `${seconds}s ago`,
ageMinutes: minutes => `${minutes}m ago`,
ageHours: hours => `${hours}h ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokensK: k => `${k}k tok`,
tokens: value => `${value} tok`
},
commandCenter: {
close: 'Close command center',
searchPlaceholder: 'Search sessions, views, and actions',
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
sectionDescriptions: {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
usage: 'Token, cost, and skill activity over time'
},
nav: {
newChat: { title: 'New session', detail: 'Start a fresh session' },
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
},
sectionEntries: {
sessions: { title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
system: { title: 'System panel', detail: 'Gateway status, logs, restart/update' },
usage: { title: 'Usage panel', detail: 'Token, cost, and skill activity' }
},
providerNavigate: 'Navigate',
providerSessions: 'Sessions',
refresh: 'Refresh',
refreshing: 'Refreshing...',
noResults: 'No matching results found.',
pinSession: 'Pin session',
unpinSession: 'Unpin session',
exportSession: 'Export session',
deleteSession: 'Delete session',
noSessions: 'No sessions yet.',
gatewayRunning: 'Messaging gateway running',
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartMessaging: 'Restart messaging',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
actionFailed: 'failed',
actionStartedWaiting: 'Action started, waiting for status...',
loadingStatus: 'Loading status...',
recentLogs: 'Recent logs',
noLogs: 'No logs loaded yet.',
days: count => `${count}d`,
statSessions: 'Sessions',
statApiCalls: 'API calls',
statTokens: 'Tokens in/out',
statCost: 'Est. cost',
actualCost: cost => `actual ${cost}`,
loadingUsage: 'Loading usage...',
noUsage: period => `No usage in the last ${period} days.`,
retry: 'Retry',
dailyTokens: 'Daily tokens',
input: 'input',
output: 'output',
noDailyActivity: 'No daily activity.',
topModels: 'Top models',
noModelUsage: 'No model usage yet.',
topSkills: 'Top skills',
noSkillActivity: 'No skill activity yet.',
actions: count => `${count} actions`
},
messaging: {
search: 'Search messaging...',
loading: 'Loading messaging platforms...',
loadFailed: 'Messaging platforms failed to load',
states: {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
},
unknown: 'Unknown',
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
credentialsSet: 'Credentials set',
needsSetup: 'Needs setup',
gatewayStopped: 'Messaging gateway stopped',
getCredentials: 'Get your credentials',
openSetupGuide: 'Open setup guide',
required: 'Required',
recommended: 'Recommended',
advanced: count => `Advanced (${count})`,
noTokenNeeded: 'This platform does not need a token here. Use the setup guide above, then enable it below.',
enabled: 'Enabled',
disabled: 'Disabled',
unsavedChanges: 'Unsaved changes',
saving: 'Saving...',
saveChanges: 'Save changes',
saved: 'Saved',
replaceValue: 'Replace current value',
openDocs: 'Open docs',
clearField: key => `Clear ${key}`,
enableAria: name => `Enable ${name}`,
disableAria: name => `Disable ${name}`,
platformEnabled: name => `${name} enabled`,
platformDisabled: name => `${name} disabled`,
restartToApply: 'Restart the gateway for this change to take effect.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
fieldCopy: {},
platformIntro: {}
},
profiles: {
close: 'Close profiles',
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
title: 'Profiles',
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
loading: 'Loading profiles...',
newProfile: 'New profile',
noProfiles: 'No profiles yet.',
selectPrompt: 'Select a profile to view its details.',
refresh: 'Refresh profiles',
refreshing: 'Refreshing profiles',
default: 'default',
skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`,
env: 'env',
defaultBadge: 'Default',
rename: 'Rename',
copySetup: 'Copy setup',
copying: 'Copying...',
modelLabel: 'Model',
skillsLabel: 'Skills',
notSet: 'Not set',
soulDesc: 'The system prompt and persona instructions baked into this profile.',
unsavedChanges: 'Unsaved changes',
loadingSoul: 'Loading SOUL.md...',
emptySoul: 'Empty SOUL.md — start writing the persona...',
saving: 'Saving...',
saveSoul: 'Save SOUL.md',
deleteTitle: 'Delete profile?',
deleteDescPrefix: 'This will delete ',
deleteDescMid: ' and remove its ',
deleteDescSuffix: ' directory. This cannot be undone.',
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,
nameRequired: 'Name is required.',
creating: 'Creating...',
createAction: 'Create profile',
renameTitle: 'Rename profile',
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
renameDescSuffix: '.',
newNameLabel: 'New name',
renaming: 'Renaming...',
created: 'Profile created',
renamed: 'Profile renamed',
deleted: 'Profile deleted',
setupCopied: 'Setup command copied',
soulSaved: 'SOUL.md saved',
failedLoad: 'Failed to load profiles',
failedDelete: 'Failed to delete profile',
failedCopy: 'Failed to copy setup command',
failedLoadSoul: 'Failed to load SOUL.md',
failedSaveSoul: 'Failed to save SOUL.md',
failedCreate: 'Failed to create profile',
failedRename: 'Failed to rename profile'
},
cron: {
close: 'Close cron',
search: 'Search cron jobs...',
refresh: 'Refresh cron jobs',
refreshing: 'Refreshing cron jobs',
loading: 'Loading cron jobs...',
states: {
enabled: 'enabled',
scheduled: 'scheduled',
running: 'running',
paused: 'paused',
disabled: 'disabled',
error: 'error',
completed: 'completed'
},
deliveryLabels: {
local: 'This desktop',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: 'Email'
},
scheduleLabels: {
daily: 'Daily',
weekdays: 'Weekdays',
weekly: 'Weekly',
monthly: 'Monthly',
hourly: 'Hourly',
'every-15-minutes': 'Every 15 minutes',
custom: 'Custom'
},
scheduleHints: {
daily: 'Every day at 9:00 AM',
weekdays: 'Monday through Friday at 9:00 AM',
weekly: 'Every Monday at 9:00 AM',
monthly: 'The first day of each month at 9:00 AM',
hourly: 'At the top of every hour',
'every-15-minutes': 'Every 15 minutes',
custom: 'Cron syntax or natural language'
},
days: {
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
},
dayFallback: value => `day ${value}`,
everyDayAt: time => `Every day at ${time}`,
weekdaysAt: time => `Weekdays at ${time}`,
everyDayOfWeekAt: (day, time) => `Every ${day} at ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
topOfHour: 'At the top of every hour',
everyHourAt: minute => `Every hour at :${minute}`,
active: (enabled, total) => `${enabled}/${total} active`,
newCron: 'New cron',
createFirst: 'Create first cron',
emptyDescNew:
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
emptyDescSearch: 'Try a broader search query.',
emptyTitleNew: 'No scheduled jobs yet',
emptyTitleSearch: 'No matches',
last: 'Last:',
next: 'Next:',
actionsFor: title => `Actions for ${title}`,
actionsTitle: 'Cron job actions',
resume: 'Resume cron',
pause: 'Pause cron',
resumeTitle: 'Resume',
pauseTitle: 'Pause',
triggerNow: 'Trigger now',
edit: 'Edit cron',
deleteTitle: 'Delete cron job?',
deleteDescPrefix: 'This will remove ',
deleteDescSuffix: ' permanently. It will stop firing immediately.',
deleting: 'Deleting...',
resumed: 'Cron resumed',
paused: 'Cron paused',
triggered: 'Cron triggered',
deleted: 'Cron deleted',
created: 'Cron created',
updated: 'Cron updated',
failedLoad: 'Failed to load cron jobs',
failedUpdate: 'Failed to update cron job',
failedTrigger: 'Failed to trigger cron job',
failedDelete: 'Failed to delete cron job',
failedSave: 'Failed to save cron job',
editTitle: 'Edit cron job',
createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
createDesc:
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name',
namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt',
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
frequencyLabel: 'Frequency',
deliverLabel: 'Deliver to',
customScheduleLabel: 'Custom schedule',
customPlaceholder: '0 9 * * * or weekdays at 9am',
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
optional: 'Optional',
promptScheduleRequired: 'Prompt and schedule are required.',
saveChanges: 'Save changes',
createAction: 'Create cron'
},
artifacts: {
search: 'Search artifacts...',
refresh: 'Refresh artifacts',
refreshing: 'Refreshing artifacts',
indexing: 'Indexing recent session artifacts',
tabAll: 'All',
tabImages: 'Images',
tabFiles: 'Files',
tabLinks: 'Links',
noArtifactsTitle: 'No artifacts found',
noArtifactsDesc: 'Generated images and file outputs will appear here as sessions produce them.',
failedLoad: 'Artifacts failed to load',
openFailed: 'Open failed',
itemsImage: 'images',
itemsLink: 'links',
itemsFile: 'files',
itemsGeneric: 'items',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end} of ${total}`,
goToPage: (itemLabel, page) => `Go to ${itemLabel} page ${page}`,
colTitleLink: 'Link title',
colTitleFile: 'Name',
colTitleDefault: 'Title / name',
colLocationLink: 'URL',
colLocationFile: 'Path',
colLocationDefault: 'Location',
colSession: 'Session',
kindImage: 'image',
kindFile: 'file',
kindLink: 'link',
chat: 'Chat',
copyUrl: 'Copy URL',
copyPath: 'Copy path'
},
sidebar: {
nav: {
'new-session': 'New session',
skills: 'Skills & Tools',
messaging: 'Messaging',
artifacts: 'Artifacts'
},
searchAria: 'Search sessions',
searchPlaceholder: 'Search sessions…',
clearSearch: 'Clear search',
noMatch: query => `No sessions match “${query}”.`,
results: 'Results',
pinned: 'Pinned',
sessions: 'Sessions',
groupAriaGrouped: 'Show sessions as a single list',
groupAriaUngrouped: 'Group sessions by workspace',
groupTitleGrouped: 'Ungroup sessions',
groupTitleUngrouped: 'Group by workspace',
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
noWorkspace: 'No workspace',
newSessionIn: label => `New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`,
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
loading: 'Loading…',
loadMore: 'Load more',
loadCount: step => `Load ${step} more`,
row: {
pin: 'Pin',
unpin: 'Unpin',
copyId: 'Copy ID',
export: 'Export',
rename: 'Rename',
archive: 'Archive',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
ageNow: 'now',
ageDay: 'd',
ageHour: 'h',
ageMin: 'm'
}
},
composer: {
message: 'Message',
placeholderStarting: 'Starting Hermes...',
placeholderReconnecting: 'Reconnecting to Hermes…',
placeholderFollowUp: 'Send follow-up',
newSessionPlaceholders: [
'What are we building?',
'Give Hermes a task',
"What's on your mind?",
'Describe what you need',
'What should we tackle?',
'Ask anything',
'Start with a goal'
],
followUpPlaceholders: [
'Send a follow-up',
'Add more context',
'Refine the request',
"What's next?",
'Keep it going',
'Push it further',
'Adjust or continue'
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
transcribing: 'Transcribing',
thinking: 'Thinking',
muted: 'Muted',
listening: 'Listening',
muteMic: 'Mute microphone',
unmuteMic: 'Unmute microphone',
stopListening: 'Stop listening and send',
stopShort: 'Stop',
endConversation: 'End voice conversation',
endShort: 'End',
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceDictation: 'Voice dictation',
commonCommands: 'Common commands',
hotkeys: 'Hotkeys',
helpFooter: 'opens the full panel · backspace dismisses',
commandDescs: {
'/help': 'full list of commands + hotkeys',
'/clear': 'start a new session',
'/resume': 'resume a prior session',
'/details': 'control transcript detail level',
'/copy': 'copy selection or last assistant message',
'/quit': 'exit hermes'
},
hotkeyDescs: {
'@': 'reference files, folders, urls, git',
'/': 'slash command palette',
'?': 'this quick help (delete to dismiss)',
Enter: 'send · Shift+Enter for newline',
'Cmd/Ctrl+K': 'send next queued turn',
'Cmd/Ctrl+L': 'redraw',
Esc: 'close popover · cancel run',
'↑ / ↓': 'cycle popover / history'
},
attachUrlTitle: 'Attach a URL',
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
urlPlaceholder: 'https://example.com/post',
urlHintPre: 'Include the full URL, e.g. ',
attach: 'Attach',
queued: count => `${count} Queued`,
attachmentOnly: 'Attachment-only turn',
emptyTurn: 'Empty turn',
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
editQueued: 'Edit queued turn',
sendQueuedNow: 'Send queued turn now',
deleteQueued: 'Delete queued turn',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
removeAttachment: label => `Remove ${label}`,
dictating: 'Dictating',
preparingAudio: 'Preparing audio',
speakingResponse: 'Speaking response',
readingAloud: 'Reading aloud',
themeSuggestions: 'Desktop theme suggestions',
noMatchingThemes: 'No matching themes.',
themeTryPre: 'Try ',
themeTryPost: '.',
attachLabel: 'Attach',
files: 'Files…',
folder: 'Folder…',
images: 'Images…',
pasteImage: 'Paste image',
url: 'URL…',
promptSnippets: 'Prompt snippets…',
tipPre: 'Tip: type ',
tipPost: ' to reference files inline.',
snippetsTitle: 'Prompt snippets',
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
snippets: {
codeReview: {
label: 'Code review',
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
text: 'Please review this for bugs, regressions, and missing tests.'
},
implementationPlan: {
label: 'Implementation plan',
description: 'Outline an approach before touching code so the diff stays focused.',
text: 'Please make a concise implementation plan before changing code.'
},
explainThis: {
label: 'Explain this',
description: 'Walk through how the selected code works and link to the key files.',
text: 'Please explain how this works and point me to the key files.'
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
export { TRANSLATIONS } from './catalog'
export {
getConfigDisplayLanguage,
type I18nConfigClient,
type I18nContextValue,
I18nProvider,
LOCALE_META,
useI18n,
withConfigDisplayLanguage
} from './context'
export {
DEFAULT_LOCALE,
isLocale,
isSupportedLocaleValue,
LOCALE_OPTIONS,
localeConfigValue,
normalizeLocale
} from './languages'
export { setRuntimeI18nLocale, translateNow } from './runtime'
export type { Locale, Translations } from './types'
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LOCALE,
isLocale,
isSupportedLocaleValue,
localeConfigValue,
normalizeLocale
} from './languages'
describe('desktop i18n languages', () => {
it('normalizes supported locale aliases', () => {
expect(normalizeLocale('en')).toBe('en')
expect(normalizeLocale('EN-US')).toBe('en')
expect(normalizeLocale('zh')).toBe('zh')
expect(normalizeLocale('zh-CN')).toBe('zh')
expect(normalizeLocale('zh-Hans')).toBe('zh')
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
})
it('falls back to English for empty or unsupported values', () => {
expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE)
expect(normalizeLocale('')).toBe(DEFAULT_LOCALE)
expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE)
})
it('distinguishes exact locale ids from supported config aliases', () => {
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
expect(isSupportedLocaleValue('ja')).toBe(false)
expect(isLocale('zh-CN')).toBe(false)
expect(isLocale('zh')).toBe(true)
})
it('returns the persisted config value for supported locales', () => {
expect(localeConfigValue('en')).toBe('en')
expect(localeConfigValue('zh')).toBe('zh')
})
})
+56
View File
@@ -0,0 +1,56 @@
import type { Locale } from './types'
export const DEFAULT_LOCALE: Locale = 'en'
export const LOCALE_OPTIONS = [
{
id: 'en',
name: 'English',
configValue: 'en'
},
{
id: 'zh',
name: '简体中文',
configValue: 'zh'
}
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
// Endonyms (native names) for the language picker so users recognize their
// language regardless of the current UI language. No country flags:
// languages are not countries.
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
) as Record<Locale, { name: string }>
const LOCALE_ALIASES: Record<string, Locale> = {
en: 'en',
'en-us': 'en',
en_us: 'en',
zh: 'zh',
'zh-cn': 'zh',
zh_cn: 'zh',
'zh-hans': 'zh',
zh_hans: 'zh',
'zh-hans-cn': 'zh',
zh_hans_cn: 'zh'
}
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value)
}
export function normalizeLocale(value: unknown): Locale {
if (typeof value !== 'string') {
return DEFAULT_LOCALE
}
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
}
export function isSupportedLocaleValue(value: unknown): boolean {
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
}
export function localeConfigValue(locale: Locale): string {
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
}
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { setRuntimeI18nLocale, translateNow } from './runtime'
describe('desktop i18n runtime translator', () => {
beforeEach(() => {
setRuntimeI18nLocale('en')
})
afterEach(() => {
setRuntimeI18nLocale('en')
})
it('translates string paths for the active runtime locale', () => {
setRuntimeI18nLocale('zh')
expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
})
it('passes arguments to function translations', () => {
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
})
it('returns the key when no locale can resolve a path', () => {
setRuntimeI18nLocale('zh')
expect(translateNow('missing.path')).toBe('missing.path')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE } from './languages'
import type { Locale, Translations } from './types'
let runtimeLocale: Locale = DEFAULT_LOCALE
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolvePath(catalog: Translations, key: string): unknown {
return key.split('.').reduce<unknown>((current, part) => {
if (!isRecord(current)) {
return undefined
}
return current[part]
}, catalog)
}
function renderTranslation(value: unknown, args: unknown[]): string | null {
if (typeof value === 'string') {
return value
}
if (typeof value === 'function') {
return (value as (...args: unknown[]) => string)(...args)
}
return null
}
export function setRuntimeI18nLocale(locale: Locale) {
runtimeLocale = locale
}
export function translateNow(key: string, ...args: unknown[]): string {
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
if (active !== null) {
return active
}
if (runtimeLocale !== DEFAULT_LOCALE) {
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
if (fallback !== null) {
return fallback
}
}
return key
}
+610
View File
@@ -0,0 +1,610 @@
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
// so a missing key is a compile error — that's the completeness guard for
// "full" coverage as more surfaces are migrated off hardcoded English.
export type Locale = 'en' | 'zh'
interface ModeOptionCopy {
label: string
description: string
}
export interface Translations {
common: {
save: string
saving: string
cancel: string
close: string
confirm: string
delete: string
refresh: string
retry: string
on: string
off: string
}
boot: {
ready: string
desktopBootFailedWithMessage: (message: string) => string
steps: {
connectingGateway: string
loadingSettings: string
loadingSessions: string
startingDesktopConnection: string
startingHermesDesktop: string
}
errors: {
backgroundExited: string
backgroundExitedDuringStartup: string
backendStopped: string
desktopBootFailed: string
gatewaySignInRequired: string
ipcBridgeUnavailable: string
}
failure: {
title: string
description: string
remoteTitle: string
remoteDescription: string
retry: string
repairInstall: string
useLocalGateway: string
openLogs: string
repairHint: string
remoteSignInHint: string
hideRecentLogs: string
showRecentLogs: string
signedInTitle: string
signedInMessage: string
signInIncompleteTitle: string
signInIncompleteMessage: string
signInFailed: string
signInToRemoteGateway: string
signInWithProvider: (provider: string) => string
identityProvider: string
}
}
notifications: {
region: string
hide: string
show: string
more: (count: number) => string
clearAll: string
dismiss: string
details: string
copyDetail: string
copyDetailFailed: string
backendOutOfDateTitle: string
backendOutOfDateMessage: string
updateHermes: string
updateReadyTitle: string
updateReadyMessage: (count: number) => string
seeWhatsNew: string
errors: {
elevenLabsNeedsKey: string
elevenLabsRejectedKey: string
methodNotAllowed: string
microphonePermission: string
openaiRejectedApiKey: string
openaiRejectedApiKeyWithStatus: (status: string) => string
openaiTtsNeedsKey: string
}
}
titlebar: {
hideSidebar: string
showSidebar: string
search: string
searchTitle: string
swapSidebarSides: string
swapSidebarSidesTitle: string
hideRightSidebar: string
showRightSidebar: string
muteHaptics: string
unmuteHaptics: string
openSettings: string
}
language: {
label: string
description: string
saving: string
saveError: string
}
settings: {
closeSettings: string
exportConfig: string
importConfig: string
resetToDefaults: string
resetConfirm: string
exportFailed: string
resetFailed: string
nav: {
gateway: string
apiKeys: string
mcp: string
archivedChats: string
about: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy>
appearance: {
title: string
intro: string
colorMode: string
colorModeDesc: string
toolViewTitle: string
toolViewDesc: string
product: string
productDesc: string
technical: string
technicalDesc: string
themeTitle: string
themeDesc: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
about: {
heading: string
version: (value: string) => string
versionUnavailable: string
updates: string
checkNow: string
checking: string
seeWhatsNew: string
releaseNotes: string
onLatest: string
installing: string
cantUpdate: string
cantReach: string
tapCheck: string
updateReady: (count: number) => string
lastChecked: (age: string) => string
justNowSuffix: string
automaticUpdates: string
automaticUpdatesDesc: string
branchCommit: (branch: string, commit: string) => string
never: string
justNow: string
minAgo: (count: number) => string
hoursAgo: (count: number) => string
daysAgo: (count: number) => string
}
}
skills: {
tabSkills: string
tabToolsets: string
all: string
searchSkills: string
searchToolsets: string
refresh: string
refreshing: string
loading: string
noSkillsTitle: string
noSkillsDesc: string
noToolsetsTitle: string
noToolsetsDesc: string
noDescription: string
configured: string
needsKeys: string
toolsetsEnabled: (enabled: number, total: number) => string
configureToolset: (label: string) => string
toggleToolset: (label: string) => string
skillsLoadFailed: string
toolsetsRefreshFailed: string
skillEnabled: string
skillDisabled: string
toolsetEnabled: string
toolsetDisabled: string
appliesToNewSessions: (name: string) => string
failedToUpdate: (name: string) => string
}
agents: {
close: string
title: string
subtitle: string
emptyTitle: string
emptyDesc: string
running: string
failed: string
done: string
streaming: string
files: string
moreFiles: (count: number) => string
delegation: (index: number) => string
workers: (count: number) => string
workersActive: (count: number) => string
agentsCount: (count: number) => string
activeCount: (count: number) => string
failedCount: (count: number) => string
toolsCount: (count: number) => string
filesCount: (count: number) => string
updatedAgo: (age: string) => string
ageNow: string
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokensK: (k: string) => string
tokens: (value: number) => string
}
commandCenter: {
close: string
searchPlaceholder: string
sections: Record<'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
providerNavigate: string
providerSessions: string
refresh: string
refreshing: string
noResults: string
pinSession: string
unpinSession: string
exportSession: string
deleteSession: string
noSessions: string
gatewayRunning: string
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartMessaging: string
updateHermes: string
actionRunning: string
actionDone: string
actionFailed: string
actionStartedWaiting: string
loadingStatus: string
recentLogs: string
noLogs: string
days: (count: number) => string
statSessions: string
statApiCalls: string
statTokens: string
statCost: string
actualCost: (cost: string) => string
loadingUsage: string
noUsage: (period: number) => string
retry: string
dailyTokens: string
input: string
output: string
noDailyActivity: string
topModels: string
noModelUsage: string
topSkills: string
noSkillActivity: string
actions: (count: string) => string
}
messaging: {
search: string
loading: string
loadFailed: string
states: Record<string, string>
unknown: string
hintPendingRestart: string
hintGatewayStopped: string
credentialsSet: string
needsSetup: string
gatewayStopped: string
getCredentials: string
openSetupGuide: string
required: string
recommended: string
advanced: (count: number) => string
noTokenNeeded: string
enabled: string
disabled: string
unsavedChanges: string
saving: string
saveChanges: string
saved: string
replaceValue: string
openDocs: string
clearField: (key: string) => string
enableAria: (name: string) => string
disableAria: (name: string) => string
platformEnabled: (name: string) => string
platformDisabled: (name: string) => string
restartToApply: string
setupSaved: (name: string) => string
restartToReconnect: string
keyCleared: (key: string) => string
setupUpdated: (name: string) => string
failedUpdate: (name: string) => string
failedSave: (name: string) => string
failedClear: (key: string) => string
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
platformIntro: Record<string, string>
}
profiles: {
close: string
nameHint: string
title: string
count: (count: number) => string
loading: string
newProfile: string
noProfiles: string
selectPrompt: string
refresh: string
refreshing: string
default: string
skills: (count: number) => string
env: string
defaultBadge: string
rename: string
copySetup: string
copying: string
modelLabel: string
skillsLabel: string
notSet: string
soulDesc: string
unsavedChanges: string
loadingSoul: string
emptySoul: string
saving: string
saveSoul: string
deleteTitle: string
deleteDescPrefix: string
deleteDescMid: string
deleteDescSuffix: string
deleting: string
createDesc: string
nameLabel: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string
nameRequired: string
creating: string
createAction: string
renameTitle: string
renameDescPrefix: string
renameDescSuffix: string
newNameLabel: string
renaming: string
created: string
renamed: string
deleted: string
setupCopied: string
soulSaved: string
failedLoad: string
failedDelete: string
failedCopy: string
failedLoadSoul: string
failedSaveSoul: string
failedCreate: string
failedRename: string
}
cron: {
close: string
search: string
refresh: string
refreshing: string
loading: string
states: Record<string, string>
deliveryLabels: Record<string, string>
scheduleLabels: Record<string, string>
scheduleHints: Record<string, string>
days: Record<string, string>
dayFallback: (value: string) => string
everyDayAt: (time: string) => string
weekdaysAt: (time: string) => string
everyDayOfWeekAt: (day: string, time: string) => string
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
topOfHour: string
everyHourAt: (minute: string) => string
active: (enabled: number, total: number) => string
newCron: string
createFirst: string
emptyDescNew: string
emptyDescSearch: string
emptyTitleNew: string
emptyTitleSearch: string
last: string
next: string
actionsFor: (title: string) => string
actionsTitle: string
resume: string
pause: string
resumeTitle: string
pauseTitle: string
triggerNow: string
edit: string
deleteTitle: string
deleteDescPrefix: string
deleteDescSuffix: string
deleting: string
resumed: string
paused: string
triggered: string
deleted: string
created: string
updated: string
failedLoad: string
failedUpdate: string
failedTrigger: string
failedDelete: string
failedSave: string
editTitle: string
createTitle: string
editDesc: string
createDesc: string
nameLabel: string
namePlaceholder: string
promptLabel: string
promptPlaceholder: string
frequencyLabel: string
deliverLabel: string
customScheduleLabel: string
customPlaceholder: string
customHint: string
optional: string
promptScheduleRequired: string
saveChanges: string
createAction: string
}
artifacts: {
search: string
refresh: string
refreshing: string
indexing: string
tabAll: string
tabImages: string
tabFiles: string
tabLinks: string
noArtifactsTitle: string
noArtifactsDesc: string
failedLoad: string
openFailed: string
itemsImage: string
itemsLink: string
itemsFile: string
itemsGeneric: string
zero: string
rangeOf: (start: number, end: number, total: number) => string
goToPage: (itemLabel: string, page: number) => string
colTitleLink: string
colTitleFile: string
colTitleDefault: string
colLocationLink: string
colLocationFile: string
colLocationDefault: string
colSession: string
kindImage: string
kindFile: string
kindLink: string
chat: string
copyUrl: string
copyPath: string
}
sidebar: {
nav: Record<string, string>
searchAria: string
searchPlaceholder: string
clearSearch: string
noMatch: (query: string) => string
results: string
pinned: string
sessions: string
groupAriaGrouped: string
groupAriaUngrouped: string
groupTitleGrouped: string
groupTitleUngrouped: string
allPinned: string
shiftClickHint: string
noWorkspace: string
newSessionIn: (label: string) => string
reorderWorkspace: (label: string) => string
showMoreIn: (count: number, label: string) => string
loading: string
loadMore: string
loadCount: (step: number) => string
row: {
pin: string
unpin: string
copyId: string
export: string
rename: string
archive: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
renamed: string
renameFailed: string
renameTitle: string
renameDesc: string
untitledPlaceholder: string
ageNow: string
ageDay: string
ageHour: string
ageMin: string
}
}
composer: {
message: string
placeholderStarting: string
placeholderReconnecting: string
placeholderFollowUp: string
newSessionPlaceholders: readonly string[]
followUpPlaceholders: readonly string[]
startVoice: string
queueMessage: string
stop: string
send: string
speaking: string
transcribing: string
thinking: string
muted: string
listening: string
muteMic: string
unmuteMic: string
stopListening: string
stopShort: string
endConversation: string
endShort: string
stopDictation: string
transcribingDictation: string
voiceDictation: string
commonCommands: string
hotkeys: string
helpFooter: string
commandDescs: Record<string, string>
hotkeyDescs: Record<string, string>
attachUrlTitle: string
attachUrlDesc: string
urlPlaceholder: string
urlHintPre: string
attach: string
queued: (count: number) => string
attachmentOnly: string
emptyTurn: string
attachments: (count: number) => string
editingInComposer: string
editQueued: string
sendQueuedNow: string
deleteQueued: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
removeAttachment: (label: string) => string
dictating: string
preparingAudio: string
speakingResponse: string
readingAloud: string
themeSuggestions: string
noMatchingThemes: string
themeTryPre: string
themeTryPost: string
attachLabel: string
files: string
folder: string
images: string
pasteImage: string
url: string
promptSnippets: string
tipPre: string
tipPost: string
snippetsTitle: string
snippetsDesc: string
snippets: Record<string, { label: string; description: string; text: string }>
}
}
+873
View File
@@ -0,0 +1,873 @@
import type { Translations } from './types'
export const zh: Translations = {
common: {
save: '保存',
saving: '保存中…',
cancel: '取消',
close: '关闭',
confirm: '确认',
delete: '删除',
refresh: '刷新',
retry: '重试',
on: '开',
off: '关'
},
boot: {
ready: 'Hermes Desktop 已就绪',
desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
steps: {
connectingGateway: '正在连接实时桌面网关',
loadingSettings: '正在加载 Hermes 设置',
loadingSessions: '正在加载最近会话',
startingDesktopConnection: '正在启动桌面连接',
startingHermesDesktop: '正在启动 Hermes Desktop…'
},
errors: {
backgroundExited: 'Hermes 后台进程已退出。',
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
backendStopped: '后端已停止',
desktopBootFailed: '桌面启动失败',
gatewaySignInRequired: '需要登录网关',
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
},
failure: {
title: 'Hermes 无法启动',
description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
remoteTitle: '需要重新登录远程网关',
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
retry: '重试',
repairInstall: '修复安装',
useLocalGateway: '使用本地网关',
openLogs: '打开日志',
repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。',
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
hideRecentLogs: '隐藏最近日志',
showRecentLogs: '显示最近日志',
signedInTitle: '已登录',
signedInMessage: '正在重新连接远程网关…',
signInIncompleteTitle: '登录未完成',
signInIncompleteMessage: '登录窗口在认证完成前关闭。',
signInFailed: '登录失败',
signInToRemoteGateway: '登录远程网关',
signInWithProvider: provider => `使用 ${provider} 登录`,
identityProvider: '你的身份提供方'
}
},
notifications: {
region: '通知',
hide: '隐藏',
show: '显示',
more: count => `另外 ${count} 条通知`,
clearAll: '全部清除',
dismiss: '关闭通知',
details: '详情',
copyDetail: '复制详情',
copyDetailFailed: '无法复制通知详情',
backendOutOfDateTitle: '后端版本过旧',
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `${count} 项新更改可用。`,
seeWhatsNew: '查看更新内容',
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
}
},
titlebar: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
search: '搜索',
searchTitle: '搜索会话、视图与操作',
swapSidebarSides: '交换侧边栏位置',
swapSidebarSidesTitle: '交换会话栏和文件浏览器的位置',
hideRightSidebar: '隐藏右侧栏',
showRightSidebar: '显示右侧栏',
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置'
},
language: {
label: '语言',
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
saveError: '语言更新失败'
},
settings: {
closeSettings: '关闭设置',
exportConfig: '导出配置',
importConfig: '导入配置',
resetToDefaults: '恢复默认',
resetConfirm: '将所有设置恢复为 Hermes 默认值?',
exportFailed: '导出失败',
resetFailed: '重置失败',
nav: {
gateway: '网关',
apiKeys: '工具与密钥',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
},
sections: {
model: '模型',
chat: '对话',
appearance: '外观',
workspace: '工作区',
safety: '安全',
memory: '记忆与上下文',
voice: '语音',
advanced: '高级'
},
searchPlaceholder: {
about: '关于 Hermes Desktop',
config: '搜索设置…',
gateway: '网关连接…',
keys: '搜索 API 密钥…',
mcp: '搜索 MCP 服务器…',
sessions: '搜索已归档会话…'
},
modeOptions: {
light: { label: '明亮', description: '明亮的桌面界面' },
dark: { label: '暗色', description: '低眩光工作区' },
system: { label: '跟随系统', description: '跟随系统外观' }
},
appearance: {
title: '外观',
intro: '这些是仅桌面端的显示偏好。模式控制明暗;主题控制强调色与对话界面样式。',
colorMode: '颜色模式',
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
toolViewTitle: '工具调用显示',
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
fieldLabels: {
model: '默认模型',
model_context_length: '上下文窗口',
fallback_providers: '备用模型',
toolsets: '启用的工具集',
timezone: '时区',
'display.personality': '人格',
'display.show_reasoning': '推理过程块',
'agent.max_turns': '最大智能体步数',
'agent.image_input_mode': '图片附件',
'terminal.cwd': '工作目录',
'terminal.backend': '执行后端',
'terminal.timeout': '命令超时',
'terminal.persistent_shell': '持久化 Shell',
'terminal.env_passthrough': '环境变量透传',
file_read_max_chars: '文件读取上限',
'tool_output.max_bytes': '终端输出上限',
'tool_output.max_lines': '文件分页上限',
'tool_output.max_line_length': '行长度上限',
'code_execution.mode': '代码执行模式',
'approvals.mode': '审批模式',
'approvals.timeout': '审批超时',
'approvals.mcp_reload_confirm': '确认 MCP 重载',
command_allowlist: '命令白名单',
'security.redact_secrets': '隐去密钥',
'security.allow_private_urls': '允许私有 URL',
'browser.allow_private_urls': '浏览器私有 URL',
'browser.auto_local_for_private_urls': '私有 URL 使用本地浏览器',
'checkpoints.enabled': '文件检查点',
'checkpoints.max_snapshots': '检查点上限',
'voice.record_key': '语音快捷键',
'voice.max_recording_seconds': '最长录音时长',
'voice.auto_tts': '朗读回复',
'stt.enabled': '语音转文字',
'stt.provider': '语音转文字提供方',
'stt.local.model': '本地转写模型',
'stt.local.language': '转写语言',
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
'stt.elevenlabs.language_code': 'ElevenLabs 语言',
'stt.elevenlabs.tag_audio_events': '标记音频事件',
'stt.elevenlabs.diarize': '说话人区分',
'tts.provider': '文字转语音提供方',
'tts.edge.voice': 'Edge 语音',
'tts.openai.model': 'OpenAI TTS 模型',
'tts.openai.voice': 'OpenAI 语音',
'tts.elevenlabs.voice_id': 'ElevenLabs 语音',
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
'memory.memory_enabled': '持久记忆',
'memory.user_profile_enabled': '用户画像',
'memory.memory_char_limit': '记忆预算',
'memory.user_char_limit': '画像预算',
'memory.provider': '记忆提供方',
'context.engine': '上下文引擎',
'compression.enabled': '自动压缩',
'compression.threshold': '压缩阈值',
'compression.target_ratio': '压缩目标',
'compression.protect_last_n': '保护最近消息',
'agent.api_max_retries': 'API 重试次数',
'agent.service_tier': '服务等级',
'agent.tool_use_enforcement': '工具调用强制',
'delegation.model': '子智能体模型',
'delegation.provider': '子智能体提供方',
'delegation.max_iterations': '子智能体轮次上限',
'delegation.max_concurrent_children': '并行子智能体',
'delegation.child_timeout_seconds': '子智能体超时',
'delegation.reasoning_effort': '子智能体推理强度'
},
fieldDescriptions: {
model: '用于新对话,除非你在输入框中选择其他模型。',
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
'display.personality': '新会话的默认助手风格。',
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
'display.show_reasoning': '当后端提供推理内容时予以显示。',
'agent.image_input_mode': '控制图片附件如何发送给模型。',
'terminal.cwd': '工具与终端操作的默认项目目录。',
'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
'terminal.env_passthrough': '传入工具执行的环境变量。',
file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
'approvals.mode': 'Hermes 如何处理需要显式审批的命令。',
'approvals.timeout': '审批提示在超时前等待的时长。',
'security.redact_secrets': '尽可能从模型可见内容中隐藏检测到的密钥。',
'checkpoints.enabled': '在文件编辑前创建可回滚的快照。',
'memory.memory_enabled': '保存有助于未来会话的持久记忆。',
'memory.user_profile_enabled': '维护一份精简的用户偏好画像。',
'context.engine': '在接近上下文上限时管理长对话的策略。',
'compression.enabled': '当对话变大时对较早的上下文进行摘要。',
'voice.auto_tts': '自动朗读助手回复。',
'stt.enabled': '启用本地或提供方支持的语音转写。',
'stt.elevenlabs.language_code': '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。',
'agent.max_turns': 'Hermes 停止一次运行前工具调用轮次的上限。'
},
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
updates: '更新',
checkNow: '立即检查',
checking: '检查中…',
seeWhatsNew: '查看新增内容',
releaseNotes: '发行说明',
onLatest: '你已是最新版本。',
installing: '正在安装更新。',
cantUpdate: '此版本无法在应用内自我更新。',
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
hoursAgo: count => `${count} 小时前`,
daysAgo: count => `${count} 天前`
}
},
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
all: '全部',
searchSkills: '搜索技能…',
searchToolsets: '搜索工具集…',
refresh: '刷新技能',
refreshing: '正在刷新技能',
loading: '正在加载能力…',
noSkillsTitle: '未找到技能',
noSkillsDesc: '尝试更宽泛的搜索或其他分类。',
noToolsetsTitle: '未找到工具集',
noToolsetsDesc: '尝试更宽泛的搜索词。',
noDescription: '暂无描述。',
configured: '已配置',
needsKeys: '需要密钥',
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
configureToolset: label => `配置 ${label}`,
toggleToolset: label => `切换 ${label} 工具集`,
skillsLoadFailed: '技能加载失败',
toolsetsRefreshFailed: '工具集刷新失败',
skillEnabled: '技能已启用',
skillDisabled: '技能已禁用',
toolsetEnabled: '工具集已启用',
toolsetDisabled: '工具集已禁用',
appliesToNewSessions: name => `${name} 将应用于新会话。`,
failedToUpdate: name => `更新 ${name} 失败`
},
agents: {
close: '关闭代理',
title: '派生树',
subtitle: '当前回合的子代理实时活动。',
emptyTitle: '暂无活跃子代理',
emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
running: '运行中',
failed: '失败',
done: '完成',
streaming: '流式传输',
files: '文件',
moreFiles: count => `还有 ${count} 个文件`,
delegation: index => `派发 ${index}`,
workers: count => `${count} 个工作单元`,
workersActive: count => `${count} 个活跃`,
agentsCount: count => `${count} 个代理`,
activeCount: count => `${count} 个活跃`,
failedCount: count => `${count} 个失败`,
toolsCount: count => `${count} 个工具`,
filesCount: count => `${count} 个文件`,
updatedAgo: age => `更新于 ${age}`,
ageNow: '刚刚',
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分钟前`,
ageHours: hours => `${hours} 小时前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokensK: k => `${k}k 词元`,
tokens: value => `${value} 词元`
},
commandCenter: {
close: '关闭命令中心',
searchPlaceholder: '搜索会话、视图与操作',
sections: { sessions: '会话', system: '系统', usage: '用量' },
sectionDescriptions: {
sessions: '搜索与管理会话',
system: '状态、日志与系统操作',
usage: '一段时间内的词元、成本与技能活动'
},
nav: {
newChat: { title: '新建会话', detail: '开始一个新会话' },
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
artifacts: { title: '产物', detail: '浏览生成的输出' }
},
sectionEntries: {
sessions: { title: '会话面板', detail: '搜索、置顶与管理会话' },
system: { title: '系统面板', detail: '网关状态、日志、重启/更新' },
usage: { title: '用量面板', detail: '词元、成本与技能活动' }
},
providerNavigate: '导航',
providerSessions: '会话',
refresh: '刷新',
refreshing: '刷新中…',
noResults: '未找到匹配结果。',
pinSession: '置顶会话',
unpinSession: '取消置顶',
exportSession: '导出会话',
deleteSession: '删除会话',
noSessions: '暂无会话。',
gatewayRunning: '消息网关运行中',
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartMessaging: '重启消息服务',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
actionFailed: '失败',
actionStartedWaiting: '操作已启动,等待状态…',
loadingStatus: '正在加载状态…',
recentLogs: '最近日志',
noLogs: '尚未加载日志。',
days: count => `${count}`,
statSessions: '会话',
statApiCalls: 'API 调用',
statTokens: '输入/输出词元',
statCost: '预估成本',
actualCost: cost => `实际 ${cost}`,
loadingUsage: '正在加载用量…',
noUsage: period => `最近 ${period} 天暂无用量。`,
retry: '重试',
dailyTokens: '每日词元',
input: '输入',
output: '输出',
noDailyActivity: '暂无每日活动。',
topModels: '常用模型',
noModelUsage: '暂无模型用量。',
topSkills: '常用技能',
noSkillActivity: '暂无技能活动。',
actions: count => `${count} 次操作`
},
messaging: {
search: '搜索消息平台…',
loading: '正在加载消息平台…',
loadFailed: '消息平台加载失败',
states: {
connected: '已连接',
connecting: '连接中',
disabled: '已禁用',
fatal: '错误',
gateway_stopped: '消息网关已停止',
not_configured: '需要设置',
pending_restart: '需要重启',
retrying: '重试中',
startup_failed: '启动失败'
},
unknown: '未知',
hintPendingRestart: '在状态栏重启网关以应用此更改。',
hintGatewayStopped: '在状态栏启动网关以建立连接。',
credentialsSet: '凭据已设置',
needsSetup: '需要设置',
gatewayStopped: '消息网关已停止',
getCredentials: '获取你的凭据',
openSetupGuide: '打开设置指南',
required: '必填',
recommended: '推荐',
advanced: count => `高级 (${count})`,
noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
enabled: '已启用',
disabled: '已禁用',
unsavedChanges: '有未保存的更改',
saving: '保存中…',
saveChanges: '保存更改',
saved: '已保存',
replaceValue: '替换当前值',
openDocs: '打开文档',
clearField: key => `清除 ${key}`,
enableAria: name => `启用 ${name}`,
disableAria: name => `禁用 ${name}`,
platformEnabled: name => `${name} 已启用`,
platformDisabled: name => `${name} 已禁用`,
restartToApply: '重启网关后此更改才会生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '重启网关以使用新凭据重新连接。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
fieldCopy: {
TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
TELEGRAM_ALLOWED_USERS: {
label: '允许的 Telegram 用户 ID',
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
},
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
MATTERMOST_URL: { label: '服务器 URL' },
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
MATRIX_HOMESERVER: { label: 'Homeserver URL' },
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
MATRIX_USER_ID: { label: 'Bot 用户 ID' },
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
WHATSAPP_MODE: { label: '桥接模式' },
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
},
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
discord:
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},
profiles: {
close: '关闭配置档案',
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
title: '配置档案',
count: count => `${count} 个配置档案`,
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
noProfiles: '暂无配置档案。',
selectPrompt: '选择一个配置档案以查看其详情。',
refresh: '刷新配置档案',
refreshing: '正在刷新配置档案',
default: '默认',
skills: count => `${count} 个技能`,
env: 'env',
defaultBadge: '默认',
rename: '重命名',
copySetup: '复制安装命令',
copying: '复制中…',
modelLabel: '模型',
skillsLabel: '技能',
notSet: '未设置',
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
unsavedChanges: '有未保存的更改',
loadingSoul: '正在加载 SOUL.md…',
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
saving: '保存中…',
saveSoul: '保存 SOUL.md',
deleteTitle: '删除配置档案?',
deleteDescPrefix: '这将删除 ',
deleteDescMid: ' 并移除其 ',
deleteDescSuffix: ' 目录。此操作无法撤销。',
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,
nameRequired: '名称为必填项。',
creating: '创建中…',
createAction: '创建配置档案',
renameTitle: '重命名配置档案',
renameDescPrefix: '重命名会更新配置档案目录以及 ',
renameDescSuffix: ' 中的所有包装脚本。',
newNameLabel: '新名称',
renaming: '重命名中…',
created: '配置档案已创建',
renamed: '配置档案已重命名',
deleted: '配置档案已删除',
setupCopied: '安装命令已复制',
soulSaved: 'SOUL.md 已保存',
failedLoad: '加载配置档案失败',
failedDelete: '删除配置档案失败',
failedCopy: '复制安装命令失败',
failedLoadSoul: '加载 SOUL.md 失败',
failedSaveSoul: '保存 SOUL.md 失败',
failedCreate: '创建配置档案失败',
failedRename: '重命名配置档案失败'
},
cron: {
close: '关闭定时任务',
search: '搜索定时任务…',
refresh: '刷新定时任务',
refreshing: '正在刷新定时任务',
loading: '正在加载定时任务…',
states: {
enabled: '已启用',
scheduled: '已排程',
running: '运行中',
paused: '已暂停',
disabled: '已禁用',
error: '错误',
completed: '已完成'
},
deliveryLabels: {
local: '此桌面',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: '电子邮件'
},
scheduleLabels: {
daily: '每天',
weekdays: '工作日',
weekly: '每周',
monthly: '每月',
hourly: '每小时',
'every-15-minutes': '每 15 分钟',
custom: '自定义'
},
scheduleHints: {
daily: '每天上午 9:00',
weekdays: '周一至周五上午 9:00',
weekly: '每周一上午 9:00',
monthly: '每月第一天上午 9:00',
hourly: '每个整点',
'every-15-minutes': '每 15 分钟',
custom: 'Cron 语法或自然语言'
},
days: {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日'
},
dayFallback: value => `${value}`,
everyDayAt: time => `每天 ${time}`,
weekdaysAt: time => `工作日 ${time}`,
everyDayOfWeekAt: (day, time) => `${day} ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth}${time}`,
topOfHour: '每个整点',
everyHourAt: minute => `每小时的 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 个启用`,
newCron: '新建定时任务',
createFirst: '创建第一个定时任务',
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
emptyDescSearch: '尝试更宽泛的搜索词。',
emptyTitleNew: '暂无排程任务',
emptyTitleSearch: '无匹配项',
last: '上次:',
next: '下次:',
actionsFor: title => `${title} 的操作`,
actionsTitle: '定时任务操作',
resume: '恢复定时任务',
pause: '暂停定时任务',
resumeTitle: '恢复',
pauseTitle: '暂停',
triggerNow: '立即触发',
edit: '编辑定时任务',
deleteTitle: '删除定时任务?',
deleteDescPrefix: '这将永久移除 ',
deleteDescSuffix: '。它会立即停止触发。',
deleting: '删除中…',
resumed: '定时任务已恢复',
paused: '定时任务已暂停',
triggered: '定时任务已触发',
deleted: '定时任务已删除',
created: '定时任务已创建',
updated: '定时任务已更新',
failedLoad: '加载定时任务失败',
failedUpdate: '更新定时任务失败',
failedTrigger: '触发定时任务失败',
failedDelete: '删除定时任务失败',
failedSave: '保存定时任务失败',
editTitle: '编辑定时任务',
createTitle: '新建定时任务',
editDesc: '更新排程、提示词或投递目标。更改将在下次运行时生效。',
createDesc: '排程一个提示词以自动运行。使用 cron 语法或类似"每 15 分钟"的自然语言。',
nameLabel: '名称',
namePlaceholder: '晨间简报',
promptLabel: '提示词',
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
frequencyLabel: '频率',
deliverLabel: '投递至',
customScheduleLabel: '自定义排程',
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
optional: '可选',
promptScheduleRequired: '提示词和排程为必填项。',
saveChanges: '保存更改',
createAction: '创建定时任务'
},
artifacts: {
search: '搜索产物…',
refresh: '刷新产物',
refreshing: '正在刷新产物',
indexing: '正在索引最近会话的产物',
tabAll: '全部',
tabImages: '图片',
tabFiles: '文件',
tabLinks: '链接',
noArtifactsTitle: '未找到产物',
noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
failedLoad: '产物加载失败',
openFailed: '打开失败',
itemsImage: '张图片',
itemsLink: '个链接',
itemsFile: '个文件',
itemsGeneric: '项',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
goToPage: (itemLabel, page) => `前往${itemLabel}${page}`,
colTitleLink: '链接标题',
colTitleFile: '名称',
colTitleDefault: '标题 / 名称',
colLocationLink: 'URL',
colLocationFile: '路径',
colLocationDefault: '位置',
colSession: '会话',
kindImage: '图片',
kindFile: '文件',
kindLink: '链接',
chat: '对话',
copyUrl: '复制 URL',
copyPath: '复制路径'
},
sidebar: {
nav: {
'new-session': '新建会话',
skills: '技能与工具',
messaging: '消息平台',
artifacts: '产物'
},
searchAria: '搜索会话',
searchPlaceholder: '搜索会话…',
clearSearch: '清除搜索',
noMatch: query => `没有会话匹配"${query}"。`,
results: '结果',
pinned: '已置顶',
sessions: '会话',
groupAriaGrouped: '以单一列表显示会话',
groupAriaUngrouped: '按工作区分组会话',
groupTitleGrouped: '取消分组',
groupTitleUngrouped: '按工作区分组',
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
noWorkspace: '无工作区',
newSessionIn: label => `${label} 中新建会话`,
reorderWorkspace: label => `重新排序工作区 ${label}`,
showMoreIn: (count, label) => `${label} 中再显示 ${count}`,
loading: '加载中…',
loadMore: '加载更多',
loadCount: step => `再加载 ${step}`,
row: {
pin: '置顶',
unpin: '取消置顶',
copyId: '复制 ID',
export: '导出',
rename: '重命名',
archive: '归档',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
untitledPlaceholder: '无标题会话',
ageNow: '刚刚',
ageDay: '天',
ageHour: '时',
ageMin: '分'
}
},
composer: {
message: '消息',
placeholderStarting: '正在启动 Hermes…',
placeholderReconnecting: '正在重新连接 Hermes…',
placeholderFollowUp: '发送后续消息',
newSessionPlaceholders: [
'我们要构建什么?',
'给 Hermes 一个任务',
'你在想什么?',
'描述你需要什么',
'我们该处理什么?',
'随便问点什么',
'从一个目标开始'
],
followUpPlaceholders: [
'发送后续消息',
'补充更多上下文',
'细化这个请求',
'下一步是什么?',
'继续推进',
'再深入一点',
'调整或继续'
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
stop: '停止',
send: '发送',
speaking: '讲话中',
transcribing: '转写中',
thinking: '思考中',
muted: '已静音',
listening: '聆听中',
muteMic: '麦克风静音',
unmuteMic: '取消麦克风静音',
stopListening: '停止聆听并发送',
stopShort: '停止',
endConversation: '结束语音对话',
endShort: '结束',
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceDictation: '语音听写',
commonCommands: '常用命令',
hotkeys: '快捷键',
helpFooter: '打开完整面板 · 退格键关闭',
commandDescs: {
'/help': '命令与快捷键的完整列表',
'/clear': '开始新会话',
'/resume': '恢复之前的会话',
'/details': '控制对话记录的详细程度',
'/copy': '复制所选内容或最后一条助手消息',
'/quit': '退出 hermes'
},
hotkeyDescs: {
'@': '引用文件、文件夹、URL、git',
'/': '斜杠命令面板',
'?': '此快速帮助(删除以关闭)',
Enter: '发送 · Shift+Enter 换行',
'Cmd/Ctrl+K': '发送下一条排队的回合',
'Cmd/Ctrl+L': '重绘',
Esc: '关闭弹窗 · 取消运行',
'↑ / ↓': '循环弹窗 / 历史'
},
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
urlPlaceholder: 'https://example.com/post',
urlHintPre: '请包含完整 URL,例如 ',
attach: '附加',
queued: count => `${count} 条排队`,
attachmentOnly: '仅附件回合',
emptyTurn: '空回合',
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editQueued: '编辑排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
removeAttachment: label => `移除 ${label}`,
dictating: '听写中',
preparingAudio: '正在准备音频',
speakingResponse: '正在朗读回复',
readingAloud: '朗读中',
themeSuggestions: '桌面主题建议',
noMatchingThemes: '没有匹配的主题。',
themeTryPre: '试试 ',
themeTryPost: '。',
attachLabel: '附加',
files: '文件…',
folder: '文件夹…',
images: '图片…',
pasteImage: '粘贴图片',
url: 'URL…',
promptSnippets: '提示词片段…',
tipPre: '提示:输入 ',
tipPost: ' 以内联引用文件。',
snippetsTitle: '提示词片段',
snippetsDesc: '选择一个起始提示词放入输入框。',
snippets: {
codeReview: {
label: '代码审查',
description: '审查当前更改是否存在回归、遗漏的边界情况和缺失的测试。',
text: '请审查这部分是否存在缺陷、回归和缺失的测试。'
},
implementationPlan: {
label: '实现计划',
description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
text: '请在修改代码前制定一个简洁的实现计划。'
},
explainThis: {
label: '解释这段',
description: '讲解所选代码的工作方式,并链接到关键文件。',
text: '请解释这是如何工作的,并指给我关键文件。'
}
}
}
}
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
})
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
+10 -7
View File
@@ -8,6 +8,7 @@ import { HashRouter } from 'react-router-dom'
import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
import { ThemeProvider } from './themes/context'
@@ -27,13 +28,15 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
+4 -3
View File
@@ -1,6 +1,7 @@
import { atom } from 'nanostores'
import type { DesktopBootProgress } from '@/global'
import { translateNow } from '@/i18n'
export interface DesktopBootState extends DesktopBootProgress {
visible: boolean
@@ -9,7 +10,7 @@ export interface DesktopBootState extends DesktopBootProgress {
const INITIAL_BOOT_STATE: DesktopBootState = {
error: null,
fakeMode: false,
message: 'Starting Hermes Desktop',
message: translateNow('boot.steps.startingHermesDesktop'),
phase: 'renderer.init',
progress: 2,
running: true,
@@ -61,7 +62,7 @@ export function setDesktopBootStep(step: {
})
}
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
export function completeDesktopBoot(message = translateNow('boot.ready')) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
@@ -80,7 +81,7 @@ export function failDesktopBoot(message: string) {
$desktopBoot.set({
...current,
error: message,
message: `Desktop boot failed: ${message}`,
message: translateNow('boot.desktopBootFailedWithMessage', message),
phase: 'renderer.error',
progress: clampProgress(current.progress),
running: false,
+10 -7
View File
@@ -1,5 +1,7 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
export interface NotificationAction {
@@ -52,28 +54,29 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
summarize: msg => {
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
return status
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
: translateNow('notifications.errors.openaiRejectedApiKey')
}
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
},
{
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
summarize: msg =>
/ELEVENLABS_API_KEY not set/i.test(msg)
? 'ElevenLabs STT needs ELEVENLABS_API_KEY.'
: 'ElevenLabs rejected the API key (401).'
? translateNow('notifications.errors.elevenLabsNeedsKey')
: translateNow('notifications.errors.elevenLabsRejectedKey')
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () =>
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
summarize: () => translateNow('notifications.errors.methodNotAllowed')
},
{
test: msg => /microphone permission/i.test(msg),
summarize: () => 'Microphone permission was denied.'
summarize: () => translateNow('notifications.errors.microphonePermission')
}
]
+7 -6
View File
@@ -13,6 +13,7 @@ import type {
DesktopUpdateStatus,
DesktopVersionInfo
} from '@/global'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
@@ -85,12 +86,12 @@ export function reportBackendContract(contract: number | undefined): void {
}
notify({
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
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.',
title: 'Backend out of date'
message: translateNow('notifications.backendOutOfDateMessage'),
title: translateNow('notifications.backendOutOfDateTitle')
})
}
@@ -121,7 +122,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
notify({
action: {
label: "See what's new",
label: translateNow('notifications.seeWhatsNew'),
onClick: () => {
snoozeUpdateToast()
openUpdatesWindow()
@@ -130,9 +131,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
durationMs: 0,
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
message: translateNow('notifications.updateReadyMessage', behind),
onDismiss: () => snoozeUpdateToast(),
title: 'Update ready'
title: translateNow('notifications.updateReadyTitle')
})
}
+4
View File
@@ -466,6 +466,10 @@ export interface ProfileInfo {
skill_count: number
}
export interface ProfileSetupCommand {
command: string
}
export interface ProfileSoul {
content: string
exists: boolean
+1 -1
View File
@@ -871,7 +871,7 @@ delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
+33 -5
View File
@@ -3194,6 +3194,18 @@ class HermesCLI:
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
_DEFAULT_CONFIG_MODEL = ""
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
# Read max_tokens from config (env var override: HERMES_MAX_TOKENS)
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
if _env_mt:
try:
self.max_tokens = int(_env_mt)
except (ValueError, TypeError):
self.max_tokens = None
elif isinstance(_model_config, dict):
_mt = _model_config.get("max_tokens")
self.max_tokens = _mt if isinstance(_mt, int) else None
else:
self.max_tokens = None
# Auto-detect model from local server if still on default
if self.model == _DEFAULT_CONFIG_MODEL:
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
@@ -5097,9 +5109,9 @@ class HermesCLI:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
ChatConsole().print(
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
f"[dim]Session {_escape(self.session_id)} was compressed into "
f"{_escape(resolved_id)}; resuming the descendant with your "
f"transcript.[/]"
f"transcript.[/dim]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
@@ -5168,6 +5180,7 @@ class HermesCLI:
acp_command=runtime.get("command"),
acp_args=runtime.get("args"),
credential_pool=runtime.get("credential_pool"),
max_tokens=self.max_tokens,
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets,
@@ -5378,7 +5391,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
return
try:
@@ -5388,7 +5401,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
return
# Retarget the terminal/code-exec tools to match the process cwd.
@@ -5398,7 +5411,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
@@ -9002,6 +9015,10 @@ class HermesCLI:
elif canonical == "update":
if self._handle_update_command():
return False
elif canonical == "version":
from hermes_cli.main import _print_version_info
_print_version_info(check_updates=True)
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
@@ -9284,6 +9301,7 @@ class HermesCLI:
api_mode=turn_route["runtime"].get("api_mode"),
acp_command=turn_route["runtime"].get("command"),
acp_args=turn_route["runtime"].get("args"),
max_tokens=turn_route["runtime"].get("max_tokens"),
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
quiet_mode=True,
@@ -13094,6 +13112,16 @@ class HermesCLI:
_welcome_color = "#FFF8DC"
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
# Warm the /model picker's provider-models cache off-thread during this
# idle window (banner shown, user about to type). The no-args picker
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
# it's opened in a session. Fire-and-forget, guarded once-per-process.
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
except Exception:
pass
# Redaction opt-out warning (#17691): ON by default, loud when off.
# The redactor snapshots its state at import time so any toggle now
# won't affect the running process — we just want the operator to
+11
View File
@@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC):
expanded = os.path.expanduser(raw)
if os.path.isfile(expanded):
found.append((raw, expanded))
else:
# The reply mentions a deliverable-looking path that does not
# exist on disk, so it is silently dropped from native delivery.
# This is the most common reason a promised file never arrives
# (the model said "here's your file" but never wrote it, or
# referenced the wrong path). Log it so the gap is visible in
# gateway.log rather than vanishing without a trace.
logger.info(
"Skipping bare file path in reply (no file on disk): %s",
_log_safe_path(raw),
)
# Deduplicate by expanded path, preserving discovery order
seen: set = set()
+2 -2
View File
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send document: {e}")
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
async def send_video(
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send video: {e}")
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
async def send_image(
+94 -2
View File
@@ -1179,6 +1179,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
format_runtime_provider_error,
_get_model_config,
)
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
@@ -1200,6 +1201,26 @@ def _resolve_runtime_agent_kwargs() -> dict:
except Exception as exc:
raise RuntimeError(format_runtime_provider_error(exc)) from exc
model_cfg = _get_model_config()
max_tokens = None
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
if _env_mt:
try:
max_tokens = int(_env_mt)
except (ValueError, TypeError):
max_tokens = None
elif isinstance(model_cfg, dict):
mt = model_cfg.get("max_tokens")
if isinstance(mt, int):
max_tokens = mt
# Fall back to a per-provider output cap (custom_providers max_output_tokens)
# only when the documented global model.max_tokens isn't set, so the global
# key always wins.
if max_tokens is None:
_runtime_mot = runtime.get("max_output_tokens")
if isinstance(_runtime_mot, int) and _runtime_mot > 0:
max_tokens = _runtime_mot
return {
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
@@ -1208,6 +1229,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
"max_tokens": max_tokens,
}
@@ -2596,6 +2618,7 @@ class GatewayRunner:
"api_key": override.get("api_key"),
"base_url": override.get("base_url"),
"api_mode": override.get("api_mode"),
"max_tokens": override.get("max_tokens"),
}
if override_runtime.get("api_key"):
logger.debug(
@@ -2693,6 +2716,7 @@ class GatewayRunner:
"command": runtime_kwargs.get("command"),
"args": list(runtime_kwargs.get("args") or []),
"credential_pool": runtime_kwargs.get("credential_pool"),
"max_tokens": runtime_kwargs.get("max_tokens"),
}
route = {
"model": model,
@@ -7908,6 +7932,8 @@ class GatewayRunner:
return await self._handle_profile_command(event)
if _cmd_def_inner.name == "update":
return await self._handle_update_command(event)
if _cmd_def_inner.name == "version":
return await self._handle_version_command(event)
# Catch-all: any other recognized slash command reached the
# running-agent guard. Reject gracefully rather than falling
@@ -8264,6 +8290,9 @@ class GatewayRunner:
if canonical == "update":
return await self._handle_update_command(event)
if canonical == "version":
return await self._handle_version_command(event)
if canonical == "debug":
return await self._handle_debug_command(event)
@@ -10889,6 +10918,12 @@ class GatewayRunner:
return event.platform_update_id <= recorded_uid
async def _handle_version_command(self, event: MessageEvent) -> str:
"""Handle /version — show the running Hermes Agent version."""
from hermes_cli.banner import format_banner_version_label
return format_banner_version_label()
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
@@ -11998,13 +12033,24 @@ class GatewayRunner:
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
return t("gateway.voice.enabled_short")
toggle_line = t("gateway.voice.enabled_short")
else:
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
return t("gateway.voice.disabled_short")
toggle_line = t("gateway.voice.disabled_short")
# Bare /voice still toggles, but append an explainer so users
# discover the on/off/tts/status subcommands (and, on Discord,
# live voice-channel join/leave). The toggle result is shown
# first via the {toggle} placeholder.
supports_voice_channels = adapter is not None and hasattr(
adapter, "join_voice_channel"
)
channels = (
t("gateway.voice.help_channels") if supports_voice_channels else ""
)
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
"""Join the user's current Discord voice channel."""
@@ -17024,6 +17070,47 @@ class GatewayRunner:
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
# ── Discord voice "verbal ack before tool calls" ────────────────
# When the bot is in a voice channel with the continuous mixer
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
# look into that") over the ambient idle bed on the FIRST tool call of
# the turn. Fires from tool_start_callback (independent of the
# tool-progress text gate), at most once per turn. No-op on every
# other platform / when not in a voice channel.
_voice_ack_fired = [False]
_voice_ack_guild: List[Optional[int]] = [None]
if source.platform == Platform.DISCORD:
_va = self.adapters.get(Platform.DISCORD)
# source.chat_id is the linked text channel; resolve the guild whose
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
_vtc = getattr(_va, "_voice_text_channels", None)
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
for _gid, _tc in _vtc.items():
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
_voice_ack_guild[0] = _gid
break
_voice_ack_loop = asyncio.get_running_loop()
def voice_ack_callback(call_id, tool_name, args):
"""tool_start_callback: speak a one-time ack in the voice channel."""
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
return
if not _run_still_current():
return
_voice_ack_fired[0] = True
_adapter = self.adapters.get(Platform.DISCORD)
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
return
try:
safe_schedule_threadsafe(
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
_voice_ack_loop,
logger=logger,
log_message="voice ack scheduling error",
)
except Exception as _ack_err:
logger.debug("voice ack schedule failed: %s", _ack_err)
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
# that implements ``delete_message``). When enabled via
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
@@ -17834,6 +17921,11 @@ class GatewayRunner:
# Per-message state — callbacks and reasoning config change every
# turn and must not be baked into the cached agent constructor.
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
# Discord voice verbal-ack hook (fires once per turn on first tool
# call; armed only when in a voice channel with the mixer running).
agent.tool_start_callback = (
voice_ack_callback if _voice_ack_guild[0] is not None else None
)
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
+7 -3
View File
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
return None
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError:
except (OSError, UnicodeDecodeError):
# OSError: file vanished or permission flipped between exists() and
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
# (a truncated or clobbered status file). Either way it's unusable.
return None
if not raw:
return None
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
try:
raw = pid_path.read_text().strip()
except OSError:
# File was deleted between exists() and read_text(), or permission flipped.
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
return None
if not raw:
return None
+19
View File
@@ -225,6 +225,25 @@ def check_for_updates() -> Optional[int]:
cache_file = hermes_home / ".update_check"
embedded_rev = os.environ.get("HERMES_REVISION") or None
# Docker images have no working tree to count commits against — the
# published image excludes `.git` (see .dockerignore) and sets no
# HERMES_REVISION (that's nix-only). Without this guard the checks below
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
# then gets rendered by the CLI banner and the TUI badge as a phantom
# "1 commit behind" — even though no git repo or commit math is involved,
# and `hermes update` correctly refuses to run in-place inside the
# container anyway. The dashboard's REST `/api/hermes/update/check`
# endpoint already short-circuits docker the same way (web_server.py);
# mirror that here so the banner/TUI surfaces agree. Returning None makes
# both the Rich banner (build_welcome_banner) and the Ink badge
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "docker":
return None
except Exception:
pass
# Read cache — invalidate if the embedded rev OR installed version has
# changed since the last check. The version guard matters for pip installs:
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
+3 -85
View File
@@ -216,6 +216,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("image", "Attach a local image file for your next prompt", "Info",
cli_only=True, args_hint="<path>"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
# Exit
@@ -349,6 +350,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
"steer",
"stop",
"update",
"version",
}
)
@@ -1148,41 +1150,6 @@ def slack_subcommand_map() -> dict[str, str]:
# ---------------------------------------------------------------------------
# Per-process cache for /model<space> LM Studio autocomplete. Probing on
# every keystroke would block the UI; a short TTL keeps it live without
# hammering the server.
_LMSTUDIO_COMPLETION_CACHE: tuple[float, list[str]] | None = None
def _lmstudio_completion_models() -> list[str]:
"""Locally-loaded LM Studio models for /model autocomplete (cached, gated)."""
global _LMSTUDIO_COMPLETION_CACHE
# Gate: don't probe 127.0.0.1 on every keystroke for users who don't use LM Studio.
if not (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL")):
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store() or {}
if "lmstudio" not in (store.get("providers") or {}) \
and "lmstudio" not in (store.get("credential_pool") or {}):
return []
except Exception:
return []
now = time.time()
if _LMSTUDIO_COMPLETION_CACHE and (now - _LMSTUDIO_COMPLETION_CACHE[0]) < 30.0:
return _LMSTUDIO_COMPLETION_CACHE[1]
try:
from hermes_cli.models import fetch_lmstudio_models
models = fetch_lmstudio_models(
api_key=os.environ.get("LM_API_KEY", ""),
base_url=os.environ.get("LM_BASE_URL") or "http://127.0.0.1:1234/v1",
timeout=0.8,
)
except Exception:
models = []
_LMSTUDIO_COMPLETION_CACHE = (now, models)
return models
class SlashCommandCompleter(Completer):
"""Autocomplete for built-in slash commands, subcommands, and skill commands."""
@@ -1599,52 +1566,6 @@ class SlashCommandCompleter(Completer):
except Exception:
pass
def _model_completions(self, sub_text: str, sub_lower: str):
"""Yield completions for /model from config aliases + built-in aliases."""
seen = set()
# Config-based direct aliases (preferred — include provider info)
try:
from hermes_cli.model_switch import (
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
)
_ensure_direct_aliases()
for name, da in DIRECT_ALIASES.items():
if name.startswith(sub_lower) and name != sub_lower:
seen.add(name)
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{da.model} ({da.provider})",
)
# Built-in catalog aliases not already covered
for name in sorted(MODEL_ALIASES.keys()):
if name in seen:
continue
if name.startswith(sub_lower) and name != sub_lower:
identity = MODEL_ALIASES[name]
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{identity.vendor}/{identity.family}",
)
except Exception:
pass
# LM Studio: surface locally-loaded models. Gated on the user actually
# having LM Studio configured (env var or auth-store entry) so we
# don't probe 127.0.0.1 on every keystroke for users who don't use it.
for name in _lmstudio_completion_models():
if name in seen:
continue
if name.startswith(sub_lower) and name != sub_lower:
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta="LM Studio",
)
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
@@ -1668,9 +1589,6 @@ class SlashCommandCompleter(Completer):
# Dynamic completions for commands with runtime lists
if " " not in sub_text:
if base_cmd == "/model":
yield from self._model_completions(sub_text, sub_lower)
return
if base_cmd == "/skin":
yield from self._skin_completions(sub_text, sub_lower)
return
@@ -1788,7 +1706,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
return Suggestion(cmd_name[len(word):])
return None
# Command is complete — suggest subcommands or model names
# Command is complete — suggest subcommands
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
+43 -5
View File
@@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = {
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
# warning log if out of range.
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
# raise deliberately, each level multiplies API cost.
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
"orchestrator_enabled": True, # kill switch for role="orchestrator"
# When a subagent hits a dangerous-command approval prompt, the parent's
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
@@ -1841,6 +1841,28 @@ DEFAULT_CONFIG = {
# real memory cost. Default 32 MiB matches the historical hardcoded
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
"max_attachment_bytes": 33554432,
# Voice-channel audio effects (the continuous mixer). OFF by default.
# When enabled, the bot installs a software mixer on the outgoing voice
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
# TTS replies can OVERLAP (ducking the ambient under speech) instead of
# stop-and-swap — the Grok-voice-mode feel. discord.py ships no mixer;
# this is implemented in plugins/platforms/discord/voice_mixer.py.
"voice_fx": {
"enabled": False, # master switch for the mixer subsystem
"ambient_enabled": True, # play the idle "thinking" bed while tools run
"ambient_path": "", # custom loop audio file; "" = synthesised pad
"ambient_gain": 0.18, # idle bed loudness, 0.01.0
"duck_gain": 0.06, # ambient loudness while speech plays
"speech_gain": 1.0, # TTS / ack loudness, 0.01.0
"ack_enabled": True, # speak a short phrase before the first tool call
"ack_phrases": [ # picked at random; set [] to disable phrases
"Let me look into that.",
"One moment.",
"Checking on that now.",
"Give me a sec.",
"On it.",
],
},
},
# WhatsApp platform settings (gateway mode)
@@ -2253,6 +2275,22 @@ DEFAULT_CONFIG = {
# disable backups entirely, set ``pre_update_backup: false`` above
# rather than ``backup_keep: 0``.
"backup_keep": 5,
# What `hermes update` does with uncommitted local changes to the
# source tree when it runs NON-interactively — i.e. triggered from
# the desktop/chat app or the gateway, where there's no TTY to answer
# a restore prompt. Interactive (terminal) updates are unaffected:
# they always stash the changes and ask whether to restore, exactly
# as they always have.
# "stash" — auto-stash the changes, pull, then auto-restore them
# on top of the updated code (the safe default; nothing
# is ever lost — conflicts are preserved in a git stash).
# "discard" — auto-stash the changes and throw the stash away after
# the pull. Use this only if you never intend to keep
# local edits to the source tree on this machine.
# Stash-and-drop (not `reset --hard` + `clean -fd`) so
# ignored paths — node_modules, venv, build outputs —
# are never touched.
"non_interactive_local_changes": "stash",
},
# Language Server Protocol — semantic diagnostics from real
@@ -2382,7 +2420,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 26,
"_config_version": 27,
}
# =============================================================================
@@ -3937,7 +3975,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions", "streaming",
"sessions", "streaming", "updates",
}
# Valid fields inside a custom_providers list entry
+4 -1
View File
@@ -81,7 +81,10 @@ def cron_list(show_all: bool = False):
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
next_run = job.get("next_run_at", "?")
repeat_info = job.get("repeat", {})
# `repeat` may be present-but-null in the job record (e.g. a one-shot
# job persisted with "repeat": null), so coalesce to {} rather than
# relying on the dict-default, which only applies to a missing key.
repeat_info = job.get("repeat") or {}
repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0)
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else ""
+32 -19
View File
@@ -2,13 +2,18 @@
Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL)
(HttpOnly, lifetime = token TTL, ~15 min)
- hermes_session_rt: the OAuth refresh token
(HttpOnly, lifetime = 30 days)
**DEPRECATED in OAuth contract v1** Nous Portal
does not issue refresh tokens; we keep the cookie
name and clear semantics for forward compatibility
and to flush stale cookies from old browsers.
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
Nous Portal issues a rotating refresh token for the
dashboard auth-code grant (Portal NAS #293 / hermes
#37247). ``set_session_cookies`` writes this cookie
whenever the provider returns a non-empty
``refresh_token``; the middleware uses it to rotate a
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-only sessions
the RT cookie is simply not written.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
@@ -39,13 +44,15 @@ The setters and readers BOTH consult the active prefix because the
cookie *name* changes a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.
.. deprecated:: contract v1
``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1
default) and silently skips writing the RT cookie in that case.
``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT
cookie so users carrying a stale cookie from an earlier deployment get
it cleared on logout / session expiry. The full refresh-flow machinery
was rewritten as "401 → redirect to /login" in Phase 6.
Refresh-token handling:
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
it) and silently skips writing the RT cookie in that case, so a
refresh-token-less provider degrades to access-token-only sessions.
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
cookie on logout / session expiry so a stale cookie from an earlier
deployment gets cleared. The transparent rotation flow ("expired AT +
live RT rotate server-side, else 401 /login") lives in
``middleware._attempt_refresh``.
"""
from __future__ import annotations
@@ -66,7 +73,13 @@ PKCE_COOKIE = "hermes_session_pkce"
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
# real authority — once the RT itself expires/rotates out, a refresh attempt
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
# lifetime to a server-side TTL that can change independently; revisit if the
# stale-cookie refresh churn ever matters.)
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
@@ -126,11 +139,11 @@ def set_session_cookies(
``access_token_expires_in`` is in seconds. Use the provider's reported
TTL for the access token.
``refresh_token`` is accepted for backward / forward compatibility but
SKIPPED when empty Nous Portal contract v1 issues no refresh tokens
so a ``Session.refresh_token == ""`` from the provider means we don't
persist anything. If a future contract revision starts emitting refresh
tokens, this helper will write the RT cookie again with no other change.
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
issues a 24h rotating refresh token (hermes #37247); a provider that
omits it returns ``Session.refresh_token == ""`` and we simply don't
persist the RT cookie the session then behaves as access-token-only
until the AT expires. No other branch changes between the two cases.
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
+51
View File
@@ -4382,6 +4382,35 @@ def _setup_standard_platform(platform: dict):
if not prompt_yes_no(f" Reconfigure {label}?", False):
return
auto_token_saved = False
auto_owner_user_id = None
if platform.get("key") == "telegram":
print()
print_info(" Telegram can be configured automatically with a managed bot:")
print_info(" [1] Automatic (scan QR → confirm in Telegram → done)")
print_info(" [2] Manual BotFather token")
choice = prompt(" Choice [1/2]", default="1")
if choice.strip() == "1":
try:
from hermes_cli.telegram_managed_bot import (
auto_setup_telegram_bot_result,
is_valid_telegram_bot_token,
)
except ImportError:
print_warning(" Automatic setup is unavailable in this install.")
else:
result = auto_setup_telegram_bot_result()
if result and is_valid_telegram_bot_token(result.token):
save_env_value(token_var, result.token)
print_success(" Saved TELEGRAM_BOT_TOKEN")
auto_token_saved = True
auto_owner_user_id = result.owner_user_id
else:
if result:
print_warning(" Automatic setup returned an invalid Telegram token.")
print()
print_info(" Falling back to manual setup...")
allowed_val_set = None # Track if user set an allowlist (for home channel offer)
for var in platform["vars"]:
@@ -4391,8 +4420,30 @@ def _setup_standard_platform(platform: dict):
if existing and var["name"] != token_var:
print_info(f" Current: {existing}")
if auto_token_saved and var["name"] == token_var:
print_info(" Token saved by automatic setup.")
continue
# Allowlist fields get special handling for the deny-by-default security model
if var.get("is_allowlist"):
if "TELEGRAM" in var["name"] and auto_owner_user_id:
detected_id = str(auto_owner_user_id)
print_success(f" Detected your Telegram user ID: {detected_id}")
if prompt_yes_no(" Allow this Telegram account to use the bot?", True):
extra = prompt(
" Additional allowed user IDs (comma-separated, optional)",
password=False,
)
ids = [detected_id]
for uid in extra.replace(" ", "").split(","):
if uid and uid not in ids:
ids.append(uid)
cleaned = ",".join(ids)
save_env_value(var["name"], cleaned)
print_success(" Saved — only these users can interact with the bot.")
allowed_val_set = cleaned
continue
print_info(" The gateway DENIES all users by default for security.")
print_info(" Enter user IDs to create an allowlist, or leave empty")
print_info(" and you'll be asked about open access next.")
+207 -219
View File
@@ -1264,6 +1264,32 @@ def _workspace_root(dir: Path) -> Path:
return dir
def _termux_workspace_install_context(
dir: Path, *, include_child_workspaces: bool = False
) -> tuple[Path, tuple[str, ...]]:
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
ws_root = _workspace_root(dir)
if ws_root == dir:
return dir, ()
try:
workspace = dir.relative_to(ws_root).as_posix()
except ValueError:
return ws_root, ()
workspace_args: list[str] = ["--workspace", workspace]
if include_child_workspaces:
packages_dir = dir / "packages"
if packages_dir.is_dir():
for child in sorted(packages_dir.iterdir()):
if child.is_dir() and (child / "package.json").is_file():
workspace_args.extend(
["--workspace", child.relative_to(ws_root).as_posix()]
)
workspace_args.append("--include-workspace-root=false")
return ws_root, tuple(workspace_args)
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@@ -1524,16 +1550,43 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
# Existing desktop behaviour runs npm from the workspace root. Termux
# scopes the install to ui-tui so launch does not pull desktop/web
# dependencies into the hot path.
did_install = False
if _tui_need_npm_install(tui_dir):
termux_startup = _is_termux_startup_environment()
termux_need_rebuild = False
if termux_startup and not tui_dev:
termux_need_rebuild = _tui_need_rebuild(tui_dir)
skip_install_for_fresh_termux_bundle = (
termux_startup and not tui_dev and not termux_need_rebuild
)
if (
not skip_install_for_fresh_termux_bundle
and _tui_need_npm_install(tui_dir)
):
npm = _node_bin("npm")
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
npm_cwd = _workspace_root(tui_dir)
npm_workspace_args: tuple[str, ...] = ()
if termux_startup:
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
tui_dir,
include_child_workspaces=True,
)
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(_workspace_root(tui_dir)),
[
npm,
"install",
*npm_workspace_args,
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
],
cwd=str(npm_cwd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -1579,8 +1632,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# Termux cold starts use the freshness check because esbuild startup is
# expensive on old mobile CPUs.
should_build = True
if _is_termux_startup_environment():
should_build = did_install or _tui_need_rebuild(tui_dir)
if termux_startup:
should_build = did_install or termux_need_rebuild
if should_build:
npm = _node_bin("npm")
@@ -2557,6 +2610,8 @@ def select_provider_and_model(args=None):
"api_key": entry.get("api_key", ""),
"key_env": entry.get("key_env", ""),
"model": entry.get("model", ""),
"models": entry.get("models", {}),
"discover_models": entry.get("discover_models", True),
"api_mode": entry.get("api_mode", ""),
"provider_key": provider_key,
"api_key_ref": _lookup_ref(
@@ -4739,17 +4794,45 @@ def _model_flow_named_custom(config, provider_info):
api_key = os.environ.get(key_env, "")
config_api_key = _custom_provider_api_key_config_value(provider_info, api_key)
# Honor ``discover_models: false`` (default True) — when discovery is
# disabled, use the configured ``models:`` list verbatim and skip the
# live /models probe. This lets operators restrict the picker to the
# subset their plan actually serves instead of the endpoint's full
# catalog (#18726: Baidu Qianfan returns 100+ models for a 2-3 model
# plan). Same semantics as the slash-command picker (model_switch.py
# sections 3 & 4): default discovers, false keeps the explicit list.
discover = provider_info.get("discover_models", True)
if isinstance(discover, str):
discover = discover.lower() not in {"false", "no", "0"}
configured_models: list[str] = []
cfg_models = provider_info.get("models", {})
if isinstance(cfg_models, dict):
configured_models = [str(m) for m in cfg_models if str(m).strip()]
elif isinstance(cfg_models, list):
configured_models = [
str(m) for m in cfg_models if isinstance(m, str) and m.strip()
]
print(f" Provider: {name}")
print(f" URL: {base_url}")
if saved_model:
print(f" Current: {saved_model}")
print()
print("Fetching available models...")
fetch_kwargs = {"timeout": 8.0}
if api_mode:
fetch_kwargs["api_mode"] = api_mode
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
if not discover and configured_models:
# Discovery disabled with an explicit list — use it verbatim, no probe.
print(f"Using configured models (discover_models: false): {len(configured_models)}")
models = configured_models
else:
print("Fetching available models...")
fetch_kwargs = {"timeout": 8.0}
if api_mode:
fetch_kwargs["api_mode"] = api_mode
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
# If the probe came back empty but the operator configured an explicit
# list, fall back to it rather than forcing manual entry.
if not models and configured_models:
models = configured_models
if models:
default_idx = 0
@@ -6564,7 +6647,9 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
print(f"Hermes Agent v{__version__} ({__release_date__})")
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
print(f"Project: {PROJECT_ROOT}")
# Show Python version
@@ -7004,10 +7089,14 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if text:
_say(text)
npm_cwd = _workspace_root(web_dir)
npm_workspace_args: tuple[str, ...] = ()
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
r1 = _run_npm_install_deterministic(
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
)
if r1.returncode != 0:
_say(
@@ -8148,59 +8237,6 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
return stash_ref
def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool:
"""Discard working-tree dirt on a managed (non-fork) clone.
On a managed install (%LOCALAPPDATA%\\hermes\\hermes-agent or
~/.hermes/hermes-agent) the user never edits the source tree, so any
"dirty" state is pure git artifact: CRLF renormalization, npm lockfile
churn, or files left behind when a directory was deleted upstream (e.g.
apps/bootstrap-installer/). Stashing that dirt and re-applying it after a
pull is actively dangerous the stash/restore cycle has been observed to
clobber freshly-pulled source files (apps/desktop/ deletion
"[UNRESOLVED_ENTRY] Cannot resolve entry module index.html").
For a managed clone the correct move is to throw the dirt away with
``git reset --hard HEAD`` + ``git clean -fd`` (mirroring install.ps1's
update path), NOT preserve it. Forks keep the stash machinery because
their local edits are intentional.
Returns True if the tree was cleaned (or was already clean), False on
a git failure (caller should fall back to the stash path).
"""
status = subprocess.run(
git_cmd + ["status", "--porcelain"],
cwd=cwd,
capture_output=True,
text=True,
)
if status.returncode != 0:
return False
if not status.stdout.strip():
return True
print("→ Discarding working-tree changes on managed clone before update...")
reset = subprocess.run(
git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd,
capture_output=True,
text=True,
)
if reset.returncode != 0:
return False
# Drop untracked files too (e.g. orphaned build artifacts), but never
# touch ignored paths — node_modules, venv, build outputs, and the like
# are expensive to rebuild and not git-artifact dirt.
subprocess.run(
git_cmd + ["clean", "-fd"],
cwd=cwd,
capture_output=True,
text=True,
)
return True
def _resolve_stash_selector(
git_cmd: list[str], cwd: Path, stash_ref: str
) -> Optional[str]:
@@ -8341,6 +8377,54 @@ def _restore_stashed_changes(
return True
def _discard_stashed_changes(
git_cmd: list[str],
cwd: Path,
stash_ref: str,
) -> bool:
"""Throw away a stash created before an update, without applying it.
Used only on a NON-interactive update when the user has set
``updates.non_interactive_local_changes: discard`` i.e. they've opted out
of keeping local source edits on this machine. Drops the stash entry
instead of re-applying it, so the working tree stays clean at the freshly
pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only
affects what was stashed (tracked changes + the untracked files we
explicitly captured) ignored paths like node_modules/venv/build outputs
are never touched, since they were never stashed.
Returns True if the stash was dropped, False on a git failure (in which
case the stash is left in place for safety).
"""
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
if stash_selector is None:
print(
"⚠ Configured to discard local changes on non-interactive update, "
"but Hermes couldn't find the stash entry to drop."
)
_print_stash_cleanup_guidance(stash_ref)
return False
drop = subprocess.run(
git_cmd + ["stash", "drop", stash_selector],
cwd=cwd,
capture_output=True,
text=True,
)
if drop.returncode != 0:
print(
"⚠ Configured to discard local changes, but Hermes couldn't drop "
"the saved stash entry."
)
if drop.stderr.strip():
print(f" {drop.stderr.strip().splitlines()[0]}")
_print_stash_cleanup_guidance(stash_ref, stash_selector)
return False
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
return True
# =========================================================================
# Fork detection and upstream management for `hermes update`
# =========================================================================
@@ -10131,62 +10215,6 @@ def _cmd_update_pip(args):
print("✓ Update complete! Restart hermes to use the new version.")
def _should_handoff_after_pull(finalize_only: bool) -> bool:
"""Whether to hand the post-pull steps off to a fresh subprocess on new
code.
Returns False (finish in-process) when:
- this IS already the finalize subprocess (avoid an infinite loop),
- under pytest, so the test runner's interpreter never spawns a real
recursive update mid-suite,
- the ``HERMES_UPDATE_NO_HANDOFF`` escape hatch is set.
Cross-platform: unlike an ``os.exec*`` replacement (which on Windows
spawns a *new* PID and breaks the desktop installer's exit-code wait on
the original process), a child subprocess + exit-code forwarding keeps the
parent PID intact everywhere, so this stays on for Windows too.
"""
if finalize_only or os.environ.get("HERMES_UPDATE_FINALIZE") == "1":
return False
if os.environ.get("HERMES_UPDATE_NO_HANDOFF") == "1":
return False
if "pytest" in sys.modules:
return False
return True
def _handoff_update_to_refreshed_code():
"""Finish the update in a fresh subprocess running the just-pulled code.
Called after a successful git pull + dependency install (so the new source
AND its deps are on disk). Spawns ``hermes update`` again with
``HERMES_UPDATE_FINALIZE=1`` set; that child skips the fetch/pull/snapshot
work and this hand-off, and runs only the post-pull finalize steps with
new code. stdin/stdout/stderr are inherited, so interactive prompts and a
parent streaming our output both keep working.
Returns the child's exit code, or ``None`` if the subprocess could not be
launched at all in which case the caller finishes the update in-process,
the historical behavior.
"""
try:
env = dict(os.environ)
env["HERMES_UPDATE_FINALIZE"] = "1"
cmd = [sys.executable, "-m", "hermes_cli.main", *sys.argv[1:]]
print("→ Finishing update with refreshed code...")
sys.stdout.flush()
sys.stderr.flush()
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env)
return result.returncode
except Exception as exc: # pragma: no cover - spawn almost never fails
logger.warning(
"update: could not hand off to refreshed code (%s); "
"finishing in-process",
exc,
)
return None
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
@@ -10198,18 +10226,31 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
assume_yes = bool(getattr(args, "yes", False))
# Self-update hand-off marker. ``hermes update`` runs from the *old*
# install, so its post-pull steps (dep install, config migration, gateway
# restart) would otherwise execute stale in-memory code even though the new
# source is already on disk. After a successful pull we re-exec into the
# refreshed code with HERMES_UPDATE_FINALIZE=1; ``finalize_only`` is True on
# that second pass and makes us skip the fetch/pull work and the re-exec.
finalize_only = os.environ.get("HERMES_UPDATE_FINALIZE") == "1"
# Whether this update is running without a human at the keyboard.
# Interactive terminal updates always stash-and-ask (unchanged behavior);
# only non-interactive updates (desktop/chat app, gateway, `--yes`) consult
# the `updates.non_interactive_local_changes` config setting to decide
# whether to auto-restore stashed local source changes or throw them away.
_non_interactive_update = (
gateway_mode
or assume_yes
or not (sys.stdin.isatty() and sys.stdout.isatty())
)
discard_local_changes = False
if _non_interactive_update:
try:
from hermes_cli.config import load_config
if finalize_only:
print("⚕ Finalizing update with refreshed code...")
else:
print("⚕ Updating Hermes Agent...")
_update_cfg = (load_config() or {}).get("updates", {})
if isinstance(_update_cfg, dict):
_mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower()
discard_local_changes = _mode == "discard"
except Exception as exc:
# Never let a config read failure change the safe default.
logger.debug("Could not read updates.non_interactive_local_changes: %s", exc)
discard_local_changes = False
print("⚕ Updating Hermes Agent...")
print()
# On Windows, abort early if another hermes.exe is holding the venv shim
@@ -10225,10 +10266,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
sys.exit(2)
# Pre-update backup — runs before any git/file mutation so users can
# always roll back to the exact state they had before this update. Skipped
# on the finalize re-exec (the original pass already took it).
if not finalize_only:
_run_pre_update_backup(args)
# always roll back to the exact state they had before this update.
_run_pre_update_backup(args)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
@@ -10272,21 +10311,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# On Windows, Git-for-Windows defaults to core.autocrlf=true, which
# renormalizes the repo's LF-only text files to CRLF in the working tree.
# On a managed, never-user-edited clone that makes tracked files read as
# "locally modified", which forces an autostash on every update (and the
# stash/restore cycle can clobber source files — see _stash_local_changes_
# if_needed below). Pin autocrlf=false so the dirt is never created. This
# mirrors what install.ps1's update path already does (PR #38239).
if sys.platform == "win32" and git_dir.exists():
subprocess.run(
git_cmd + ["config", "core.autocrlf", "false"],
cwd=PROJECT_ROOT,
check=False,
capture_output=True,
)
# Discard npm lockfile churn before any stash/branch logic. npm rewrites
# tracked package-lock.json files non-deterministically at install/build
# time (platform-specific optional deps, ideallyInert annotations, etc.),
@@ -10364,14 +10388,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
else f"branch '{current_branch}'"
)
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
# Stash before checkout so uncommitted work isn't lost — but on a
# managed (non-fork) clone there's nothing to preserve, so discard
# git-artifact dirt instead (a dirty tree would otherwise block the
# checkout). Forks keep the stash so their edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
# Stash before checkout so uncommitted work isn't lost
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
checkout_result = subprocess.run(
git_cmd + ["checkout", branch],
cwd=PROJECT_ROOT,
@@ -10405,16 +10423,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f" {track_result.stderr.strip().splitlines()[0]}")
sys.exit(1)
else:
# On a managed (non-fork) clone the user never edits the source
# tree, so any dirt is git artifact (CRLF, lockfile churn,
# upstream-deleted dirs). Throw it away rather than stash/restore
# it — the stash/restore cycle has clobbered freshly-pulled source
# (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Forks fall
# through to the stash path so their intentional edits survive.
if not is_fork and _clean_managed_worktree(git_cmd, PROJECT_ROOT):
auto_stash_ref = None
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
prompt_for_restore = (
auto_stash_ref is not None
@@ -10432,11 +10441,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
commit_count = int(result.stdout.strip())
# On the finalize re-exec the pull already happened in the original
# pass, so origin is level with HEAD (count == 0). Don't take the
# "Already up to date" early return — fall through and run the
# post-pull steps (now with refreshed code).
if commit_count == 0 and not finalize_only:
if commit_count == 0:
_invalidate_update_cache()
# Even if origin is up to date, the fork may be behind upstream
@@ -10463,30 +10468,24 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("✓ Already up to date!")
return
# The "found N commits" notice and pre-update snapshot belong to the
# original pass only — the finalize re-exec sees count == 0 and the
# snapshot was already taken before the pull.
print(f"→ Found {commit_count} new commit(s)")
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
pre_update_snapshot_id = None
if not finalize_only:
print(f"→ Found {commit_count} new commit(s)")
try:
from hermes_cli.backup import create_quick_snapshot
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
# before pulling so a user can recover if something goes wrong.
# Issue #15733 reported missing pairing data after an update; even
# though `git pull` can't touch $HERMES_HOME, this is cheap
# belt-and-suspenders insurance and gives the user something to
# restore from via `/snapshot list` / `/snapshot restore <id>`.
try:
from hermes_cli.backup import create_quick_snapshot
pre_update_snapshot_id = create_quick_snapshot(
label="pre-update", keep=1
)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
if pre_update_snapshot_id:
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
except Exception as exc:
# Never let a snapshot failure block an update.
logger.debug("Pre-update snapshot failed: %s", exc)
print("→ Pulling updates...")
update_succeeded = False
@@ -10576,6 +10575,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
f" ️ Local changes preserved in stash (ref: {auto_stash_ref})"
)
print(f" Restore manually with: git stash apply")
elif discard_local_changes:
# Non-interactive update + user opted into discarding local
# source edits (updates.non_interactive_local_changes:
# discard). Throw the stash away instead of re-applying it.
_discard_stashed_changes(
git_cmd,
PROJECT_ROOT,
auto_stash_ref,
)
else:
_restore_stashed_changes(
git_cmd,
@@ -10658,26 +10666,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
_refresh_active_lazy_features()
# Hand off the remaining post-pull work (node deps, web/desktop build,
# config migration, skills sync, gateway restart) to the freshly-pulled
# code. These are the most frequently-changed and historically most
# fragile update steps, yet they used to run from the modules this
# process imported at startup — so a bug fixed in the pulled version
# still crashed here, forcing users to run ``hermes update`` twice. The
# git pull AND dependency install are done, so the new source and its
# deps are on disk; finish in a fresh subprocess running new code and
# forward its exit code. On the finalize pass (or under pytest /
# opt-out) we skip the hand-off and finish in-process — see
# _should_handoff_after_pull.
if _should_handoff_after_pull(finalize_only):
handoff_rc = _handoff_update_to_refreshed_code()
if handoff_rc is not None:
# The child ran every remaining post-pull step on new code;
# forward its result and stop (cmd_update's finally still
# restores stdio on the way out).
sys.exit(handoff_rc)
# else: spawning the child failed — fall through and finish here.
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
+71 -4
View File
@@ -51,12 +51,48 @@ def resolve_uv() -> Optional[str]:
return None
def ensure_uv() -> Optional[str]:
"""Return the managed uv path, installing it first if necessary.
class _UvResult(str):
"""``ensure_uv()`` return value that survives an update boundary.
On failure returns ``None`` (never raises) so callers can fall
back to pip gracefully.
``ensure_uv()``'s arity has flipped between a single path string and a
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
the call site from the *old*, already-imported ``hermes_cli.main`` against
this *freshly pulled* module, so the two can disagree on how many values
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
and crashes the first update: the returned path is a plain ``str``, which is
itself iterable, so the 2-target unpack walks its characters and raises
``ValueError: too many values to unpack (expected 2)`` (and on the failure
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
NoneType``). This wrapper answers to both conventions:
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
Missing uv is the empty string (falsy) instead of ``None`` so legacy
2-target call sites can still unpack a failure without raising, while
``if not uv_bin`` keeps working for single-value callers.
POSIX only. This wrapper is **never** returned on Windows see
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
"""
fresh_bootstrap: bool
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
self = super().__new__(cls, path or "")
self.fresh_bootstrap = fresh
return self
def __iter__(self):
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
# First element mirrors the historical contract: the path string, or
# ``None`` when uv is unavailable.
return iter(((str(self) or None), self.fresh_bootstrap))
def _ensure_uv_path() -> Optional[str]:
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
existing = resolve_uv()
if existing:
return existing
@@ -88,6 +124,37 @@ def ensure_uv() -> Optional[str]:
return result
def ensure_uv():
"""Return the managed uv path, installing it first if necessary.
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
both usable directly as the path *and* unpackable as
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
release see :class:`_UvResult` for the update-boundary rationale.
On **Windows** we deliberately return a plain ``str``/``None`` instead.
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
which iterates every entry *as a string* (``for c in arg``). The dependency
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
so a ``_UvResult`` whose ``__iter__`` yields ``(path, fresh_bootstrap)``
rather than characters would inject the bool into the command line and
crash the install with ``TypeError: sequence item 1: expected str instance,
bool found``. A plain ``str`` matches the historical Windows contract and is
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
Windows char-iteration: both use the iterator protocol, with contradictory
results.)
On failure the result is falsy never raises so callers can fall back to
pip gracefully.
"""
result = _ensure_uv_path()
if platform.system() == "Windows":
# See docstring: a str subclass with an overridden __iter__ is unsafe as
# a Windows subprocess argument. Hand back the plain path (or None).
return result
return _UvResult(result)
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
+81 -3
View File
@@ -1117,6 +1117,62 @@ def switch_model(
# Authenticated providers listing (for /model no-args display)
# ---------------------------------------------------------------------------
# Process-level guard so the picker prewarm thread is spawned at most once per
# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a
# long-lived process (or repeated triggers) would leak one OS thread per call.
import threading as _threading # noqa: E402
_picker_prewarm_done = _threading.Event()
def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
"""Warm the provider-models disk cache in a background daemon thread.
The no-args ``/model`` picker calls ``list_authenticated_providers()``,
which fetches each authenticated provider's live ``/v1/models`` list on a
cold/stale cache. Those fetches are independent HTTP round-trips but run
serially, so the first ``/model`` open in a session (or any open after the
1h cache TTL expires) blocks ~1-2s on the user's critical path.
This pre-warms that exact path off-thread during idle session time: it
runs ``list_authenticated_providers()`` once, which populates
``provider_models_cache.json`` for every authed provider. By the time the
user types ``/model``, the picker hits the warm disk cache and renders in
~100ms.
Fire-and-forget. Process-level Event guard ensures it runs at most once.
Fully exception-isolated a slow or offline provider can never affect the
session. Returns the spawned thread (for tests) or None if already warmed.
"""
if _picker_prewarm_done.is_set():
return None
_picker_prewarm_done.set()
def _warm() -> None:
try:
from hermes_cli.inventory import load_picker_context
ctx = load_picker_context()
# Calling this is what populates cached_provider_model_ids() ->
# provider_models_cache.json for each authed provider. We discard
# the result; the side effect (warm disk cache) is the point.
list_authenticated_providers(
current_provider=ctx.current_provider,
current_base_url=ctx.current_base_url,
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
max_models=50,
)
except Exception:
# Best-effort warmup — never surface errors into the session.
logger.debug("picker cache prewarm failed", exc_info=True)
t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm")
t.start()
return t
def list_authenticated_providers(
current_provider: str = "",
current_base_url: str = "",
@@ -1734,6 +1790,13 @@ def list_authenticated_providers(
else (f"env:{key_env}" if key_env else "")
)
# Read discover_models from the entry (same semantics as
# section 3: true by default, set false to keep the explicit
# ``models:`` list instead of replacing it with live /models).
discover = entry.get("discover_models", True)
if isinstance(discover, str):
discover = discover.lower() not in {"false", "no", "0"}
group_key = (api_url, credential_identity, api_mode)
if group_key not in groups:
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
@@ -1754,9 +1817,15 @@ def list_authenticated_providers(
"api_url": api_url,
"api_key": api_key,
"models": [],
"discover_models": discover,
}
elif api_key and not groups[group_key].get("api_key"):
groups[group_key]["api_key"] = api_key
else:
if api_key and not groups[group_key].get("api_key"):
groups[group_key]["api_key"] = api_key
# If any entry in this group opts out of discovery,
# honour that for the whole grouped row.
if not discover:
groups[group_key]["discover_models"] = False
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@@ -1845,7 +1914,16 @@ def list_authenticated_providers(
# - Without an api_key AND no explicit models, fall through to
# live discovery so bare-endpoint custom providers (local
# llama.cpp / Ollama servers) still appear populated.
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
# - When discover_models: false is set, skip live discovery and
# keep the explicit ``models:`` list regardless of whether an
# api_key is present. This supports endpoints that expose a
# full aggregator catalog via /models but only serve a subset
# (parity with section 3's user ``providers:`` behaviour).
should_probe = (
bool(api_url)
and (bool(api_key) or not grp["models"])
and grp.get("discover_models", True)
)
if should_probe:
try:
from hermes_cli.models import fetch_api_models
+35 -6
View File
@@ -1150,17 +1150,46 @@ _PROVIDER_ALIASES = {
}
# Cost-safe overrides for the *silent* auto-default
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
# non-interactive fallback when a profile sets ``provider: nous`` with no model
# silently bills the most expensive model for traffic the user never opted into
# (a missing default escalated to Opus and billed 863 requests before the user
# noticed). Pin the silent default to a low-cost curated model instead so a
# missing model can never escalate to the flagship.
#
# This is deliberately a fixed, side-effect-free default for the hot resolution
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
# richer free/paid-tier-aware resolver — see ``get_recommended_default_model``
# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can
# hit the Portal; this fallback must stay cheap and network-free.
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
"nous": "deepseek/deepseek-v4-flash",
}
def get_default_model_for_provider(provider: str) -> str:
"""Return the default model for a provider, or empty string if unknown.
"""Return a cost-safe default model for a provider, or "" if unknown.
Uses the first entry in _PROVIDER_MODELS as the default. This is the
model a user would be offered first in the ``hermes model`` picker.
Used as a NON-INTERACTIVE fallback when a provider is configured but no
model was ever selected (e.g. ``hermes auth add openai-codex`` without
``hermes model``, or a profile that sets ``provider`` with no ``model``).
Used as a fallback when the user has configured a provider but never
selected a model (e.g. ``hermes auth add openai-codex`` without
``hermes model``).
For most providers this is the first entry in ``_PROVIDER_MODELS`` the
same model the ``hermes model`` picker offers first. For metered aggregators
whose curated list is ordered most-capable-first, that entry is also the
most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such
providers carry an explicit low-cost override in
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
auto-escalate to the flagship.
"""
models = _PROVIDER_MODELS.get(provider, [])
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider)
if override and override in models:
return override
return models[0] if models else ""
+42 -3
View File
@@ -159,6 +159,33 @@ def _has_agent_browser() -> bool:
return bool(agent_browser_bin or local_bin.exists())
def _local_browser_runnable() -> bool:
"""Return True when the *local* browser backend would actually start.
The ``agent-browser`` CLI being present is necessary but not sufficient for
local mode: agent-browser also needs a Chromium build on disk (without one
it hangs on first use until the command timeout fires), unless the
Lightpanda engine is selected text-only navigation needs no Chromium.
This mirrors the local-mode tail of
:func:`tools.browser_tool.check_browser_requirements`, so the setup/status
surfaces advertise local browser readiness only when the runtime would
actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
"""
if not _has_agent_browser():
return False
try:
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
except Exception:
# If the runtime probe can't be imported, fall back to binary presence
# (prior behaviour) rather than crashing the setup/status surface.
return True
if _using_lightpanda_engine():
return True
return _chromium_installed()
def _browser_label(current_provider: str) -> str:
mapping = {
"browserbase": "Browserbase",
@@ -188,13 +215,23 @@ def _resolve_browser_feature_state(
browser_provider: str,
browser_provider_explicit: bool,
browser_local_available: bool,
browser_local_runnable: bool,
direct_camofox: bool,
direct_browserbase: bool,
direct_browser_use: bool,
direct_firecrawl: bool,
managed_browser_available: bool,
) -> tuple[str, bool, bool, bool]:
"""Resolve browser availability using the same precedence as runtime."""
"""Resolve browser availability using the same precedence as runtime.
``browser_local_available`` means "the agent-browser CLI is present" the
only local requirement for cloud providers, which host their own Chromium.
``browser_local_runnable`` additionally requires a usable local Chromium
build (or the Lightpanda engine), mirroring the local-mode tail of
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
on the latter, or setup/status advertise a browser that fails on first use
when Chromium is missing.
"""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
@@ -223,7 +260,7 @@ def _resolve_browser_feature_state(
return current_provider, False, False, False
current_provider = "local"
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
@@ -243,7 +280,7 @@ def _resolve_browser_feature_state(
active = bool(browser_tool_enabled and available)
return "browserbase", available, active, False
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return "local", available, active, False
@@ -445,6 +482,7 @@ def get_nous_subscription_features(
tts_active = bool(tts_tool_enabled and tts_available)
browser_local_available = _has_agent_browser()
browser_local_runnable = _local_browser_runnable()
(
browser_current_provider,
browser_available,
@@ -455,6 +493,7 @@ def get_nous_subscription_features(
browser_provider=browser_provider,
browser_provider_explicit=browser_provider_explicit,
browser_local_available=browser_local_available,
browser_local_runnable=browser_local_runnable,
direct_camofox=direct_camofox,
direct_browserbase=direct_browserbase,
direct_browser_use=direct_browser_use,
+22
View File
@@ -474,6 +474,21 @@ def _try_resolve_from_custom_pool(
return None
def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Propagate a per-provider output cap onto the resolved runtime dict.
Accepts ``max_output_tokens`` or ``max_tokens`` on a ``custom_providers``
entry so a provider block can pin its own output limit. Gateway and CLI
map this onto ``AIAgent.max_tokens`` only when the top-level
``model.max_tokens`` isn't set, so the documented global key still wins.
"""
for _k in ("max_output_tokens", "max_tokens"):
_v = entry.get(_k)
if isinstance(_v, int) and _v > 0:
result["max_output_tokens"] = _v
return
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
requested_norm = _normalize_custom_provider_name(requested_provider or "")
if not requested_norm or requested_norm == "custom":
@@ -541,6 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Also check the 'name' field if present
display_name = entry.get("name", "")
@@ -562,6 +578,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Fall back to custom_providers: list (legacy format)
@@ -611,6 +628,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
model_name = str(entry.get("model", "") or "").strip()
if model_name:
result["model"] = model_name
_lift_max_output_tokens(entry, result)
return result
return None
@@ -699,6 +717,8 @@ def _resolve_named_custom_runtime(
model_name = custom_provider.get("model")
if model_name:
pool_result["model"] = model_name
if isinstance(custom_provider.get("max_output_tokens"), int):
pool_result["max_output_tokens"] = custom_provider["max_output_tokens"]
request_overrides = _custom_provider_request_overrides(custom_provider)
if request_overrides:
pool_result["request_overrides"] = {
@@ -736,6 +756,8 @@ def _resolve_named_custom_runtime(
# provider name differs from the actual model string the API expects.
if custom_provider.get("model"):
result["model"] = custom_provider["model"]
if isinstance(custom_provider.get("max_output_tokens"), int):
result["max_output_tokens"] = custom_provider["max_output_tokens"]
request_overrides = _custom_provider_request_overrides(custom_provider)
if request_overrides:
result["request_overrides"] = request_overrides
+104 -17
View File
@@ -415,7 +415,9 @@ def _print_setup_summary(config: dict, hermes_home):
elif browser_provider == "Camofox":
missing_browser_hint = "CAMOFOX_URL"
elif browser_provider == "Local browser":
missing_browser_hint = "npm install -g agent-browser"
missing_browser_hint = (
"npm install -g agent-browser && agent-browser install --with-deps"
)
tool_status.append(
("Browser Automation", False, missing_browser_hint)
)
@@ -1637,6 +1639,52 @@ def setup_agent_settings(config: dict):
# =============================================================================
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
def _is_valid_telegram_bot_token(token: str) -> bool:
return bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
def _setup_telegram_auto_result():
"""Attempt automatic Telegram bot creation via managed QR onboarding."""
try:
from hermes_cli.telegram_managed_bot import auto_setup_telegram_bot_result
except ImportError:
return None
profile_name: str | None = None
try:
hermes_home = str(get_hermes_home())
if "/profiles/" in hermes_home:
profile_name = hermes_home.rstrip("/").rsplit("/", 1)[-1]
except Exception:
pass
return auto_setup_telegram_bot_result(profile_name=profile_name)
def _setup_telegram_auto() -> str | None:
"""Attempt automatic Telegram bot creation and return only the token."""
result = _setup_telegram_auto_result()
return result.token if result else None
def _prompt_telegram_bot_token() -> str | None:
print_info("Create a bot via @BotFather on Telegram")
while True:
token = prompt("Telegram bot token", password=True)
if not token:
return None
if not _is_valid_telegram_bot_token(token):
print_error(
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
)
continue
return token
def _setup_telegram():
"""Configure Telegram bot credentials and allowlist."""
print_header("Telegram")
@@ -1655,20 +1703,40 @@ def _setup_telegram():
print_success("Telegram allowlist configured")
return
print_info("Create a bot via @BotFather on Telegram")
import re
print_info("How would you like to create your Telegram bot?")
print()
print_info(" [1] Automatic (recommended)")
print_info(" Scan a QR code → confirm in Telegram → done.")
print_info(" No token copy-paste needed.")
print()
print_info(" [2] Manual")
print_info(" Create a bot via @BotFather yourself and paste the token.")
print()
while True:
token = prompt("Telegram bot token", password=True)
choice = prompt("Choice [1/2]", default="1")
token = None
setup_result = None
if choice.strip() == "1":
setup_result = _setup_telegram_auto_result()
if setup_result:
token = setup_result.token
if not _is_valid_telegram_bot_token(token):
print_error("Automatic setup returned an invalid Telegram bot token.")
token = None
setup_result = None
else:
token = None
if not token:
return
if not re.match(r"^\d+:[A-Za-z0-9_-]{30,}$", token):
print_error(
"Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)"
)
continue
break
print()
print_info("Falling back to manual setup...")
print()
if not token:
token = _prompt_telegram_bot_token()
if not token:
return
save_env_value("TELEGRAM_BOT_TOKEN", token)
print_success("Telegram token saved")
@@ -1678,11 +1746,30 @@ def _setup_telegram():
print_info(" 1. Message @userinfobot on Telegram")
print_info(" 2. It will reply with your numeric ID (e.g., 123456789)")
print()
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
detected_user_id = getattr(setup_result, "owner_user_id", None)
if detected_user_id:
detected_id = str(detected_user_id)
print_success(f"Detected your Telegram user ID: {detected_id}")
if prompt_yes_no("Allow this Telegram account to use the bot?", True):
extra = prompt("Additional allowed user IDs (comma-separated, optional)")
ids = [detected_id]
for uid in extra.replace(" ", "").split(","):
if uid and uid not in ids:
ids.append(uid)
allowed_users = ",".join(ids)
else:
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
else:
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
if allowed_users:
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", ""))
allowed_users = allowed_users.replace(" ", "")
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users)
print_success("Telegram allowlist configured - only listed users can use the bot")
else:
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
+358
View File
@@ -0,0 +1,358 @@
"""Telegram Managed Bot onboarding client.
Uses Telegram's Managed Bots feature to create a user-owned child bot without
manual BotFather token copy-paste. Hermes talks only to the Nous onboarding
service; the raw Telegram token is saved locally after one-time retrieval.
"""
from __future__ import annotations
import os
import re
import secrets
import sys
import time
import urllib.parse
from dataclasses import dataclass
from typing import Optional
import httpx
# Default pairing API base URL (Nous-hosted Cloudflare Worker).
# Override for PoC/staging with TELEGRAM_ONBOARDING_URL.
DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com"
TELEGRAM_ONBOARDING_URL_ENV = "TELEGRAM_ONBOARDING_URL"
# The Nous-hosted manager bot username (without @). The backend returns the
# actual deep link, so this is only used by local helpers/tests.
DEFAULT_MANAGER_BOT = "HermesSetupBot"
DEFAULT_BOT_NAME = "Hermes Agent"
DEFAULT_POLL_TIMEOUT = 180
POLL_INTERVAL = 2
_USERNAME_SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
@dataclass(frozen=True)
class TelegramPairing:
"""Pairing record returned by the Telegram onboarding service."""
pairing_id: str
poll_token: str
suggested_username: str
deep_link: str
qr_payload: str
expires_at: str | None = None
@dataclass(frozen=True)
class TelegramBotSetupResult:
"""Successful Telegram onboarding result returned by the setup service."""
token: str
bot_username: str | None = None
owner_user_id: int | None = None
def _api_url(api_url: str | None = None) -> str:
"""Resolve the onboarding API URL, honoring the PoC env override."""
return (
api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL
).rstrip("/")
def is_valid_telegram_bot_token(token: object) -> bool:
"""Return True when *token* has Telegram's bot-token shape."""
return isinstance(token, str) and bool(_TELEGRAM_BOT_TOKEN_RE.match(token))
def _parse_owner_user_id(value: object) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str) and value.isdecimal():
parsed = int(value)
return parsed if parsed > 0 else None
return None
def render_qr_terminal(url: str) -> str:
"""Render a URL as a QR code string suitable for terminal output."""
try:
import io
import qrcode # type: ignore[import-untyped]
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.print_ascii(out=buf, invert=True)
return buf.getvalue()
except ImportError:
return ""
def print_qr_code(url: str, *, include_link: bool = True) -> None:
"""Print a QR code to stdout, with URL fallback if qrcode is missing."""
qr_text = render_qr_terminal(url)
if qr_text:
print(qr_text)
else:
print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)")
if include_link:
print(f" Link: {url}")
def generate_username_slug(length: int = 16) -> str:
"""Generate a base32-ish slug for Telegram username correlation.
Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while
keeping ``hermes_<slug>_bot`` under Telegram's 32-character username limit.
"""
return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length))
def generate_bot_username(profile_name: Optional[str] = None) -> str:
"""Generate a secure suggested bot username like ``hermes_<slug>_bot``.
``profile_name`` is accepted for backward compatibility with the original
PoC, but is intentionally not embedded in the username. The username has to
carry enough entropy for backend correlation.
"""
_ = profile_name
return f"hermes_{generate_username_slug()}_bot"
def generate_deep_link(
manager_bot: str = DEFAULT_MANAGER_BOT,
suggested_username: Optional[str] = None,
suggested_name: Optional[str] = None,
) -> str:
"""Build a ``t.me/newbot`` deep link for managed bot creation."""
manager = manager_bot.lstrip("@")
username = suggested_username or generate_bot_username()
base_url = (
"https://t.me/newbot/"
f"{urllib.parse.quote(manager)}/"
f"{urllib.parse.quote(username)}"
)
if suggested_name:
params = urllib.parse.urlencode({"name": suggested_name})
return f"{base_url}?{params}"
return base_url
def generate_pairing_nonce() -> str:
"""Generate a legacy-compatible random nonce string.
The new protocol uses service-created ``pairing_id`` + bearer
``poll_token`` instead of a path nonce, but this helper is harmless and
still useful for callers/tests that need a generic random id.
"""
return secrets.token_hex(16)
def create_pairing(
api_url: str | None = None,
bot_name: str = DEFAULT_BOT_NAME,
timeout: float = 10.0,
) -> TelegramPairing | None:
"""Create a Telegram onboarding pairing.
``POST /v1/telegram/pairings`` returns the deep link, QR payload, public
pairing id, and secret poll token. The token is only used as a bearer
credential while polling.
"""
try:
resp = httpx.post(
f"{_api_url(api_url)}/v1/telegram/pairings",
json={"bot_name": bot_name},
timeout=timeout,
)
if resp.status_code not in (200, 201):
return None
data = resp.json()
except (httpx.HTTPError, ValueError):
return None
required = ("pairing_id", "poll_token", "suggested_username", "deep_link")
if not all(isinstance(data.get(key), str) and data.get(key) for key in required):
return None
qr_payload = data.get("qr_payload") or data["deep_link"]
if not isinstance(qr_payload, str):
return None
expires_at = data.get("expires_at")
return TelegramPairing(
pairing_id=data["pairing_id"],
poll_token=data["poll_token"],
suggested_username=data["suggested_username"],
deep_link=data["deep_link"],
qr_payload=qr_payload,
expires_at=expires_at if isinstance(expires_at, str) else None,
)
def poll_pairing_result_once(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = 10.0,
) -> TelegramBotSetupResult | None:
"""Poll the onboarding service once. Returns setup metadata when ready."""
resp = httpx.get(
f"{_api_url(api_url)}/v1/telegram/pairings/{pairing.pairing_id}",
headers={"Authorization": f"Bearer {pairing.poll_token}"},
timeout=timeout,
)
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != "ready":
return None
token = data.get("token")
if not is_valid_telegram_bot_token(token):
return None
bot_username = data.get("bot_username")
return TelegramBotSetupResult(
token=token,
bot_username=bot_username
if isinstance(bot_username, str) and bot_username
else None,
owner_user_id=_parse_owner_user_id(data.get("owner_user_id")),
)
def poll_pairing_once(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = 10.0,
) -> str | None:
"""Poll the onboarding service once. Returns the token when ready."""
result = poll_pairing_result_once(api_url, pairing, timeout=timeout)
return result.token if result else None
def poll_for_setup_result(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = DEFAULT_POLL_TIMEOUT,
interval: float = POLL_INTERVAL,
) -> Optional[TelegramBotSetupResult]:
"""Poll the pairing API until setup metadata is available or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
result = poll_pairing_result_once(api_url, pairing)
if result:
return result
except (httpx.HTTPError, ValueError):
pass
time.sleep(interval)
return None
def poll_for_token(
api_url: str | None,
pairing: TelegramPairing,
timeout: float = DEFAULT_POLL_TIMEOUT,
interval: float = POLL_INTERVAL,
) -> Optional[str]:
"""Poll the pairing API until the bot token is available or timeout."""
result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval)
return result.token if result else None
def auto_setup_telegram_bot_result(
api_url: str | None = None,
manager_bot: str = DEFAULT_MANAGER_BOT,
profile_name: Optional[str] = None,
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
) -> Optional[TelegramBotSetupResult]:
"""Run the full automatic Telegram bot creation flow."""
_ = manager_bot, profile_name
resolved_api_url = _api_url(api_url)
print()
print(f" Contacting Hermes Telegram onboarding service: {resolved_api_url}")
sys.stdout.flush()
pairing = create_pairing(resolved_api_url)
if not pairing:
print(" ✗ Could not reach the Hermes Telegram onboarding service.")
print(" Try the manual setup instead, or check your network.")
return None
print(" ✓ Pairing created")
print(" Rendering QR code...")
sys.stdout.flush()
print()
print(" Scan this QR code with your phone, or open the link below:")
print()
print_qr_code(pairing.qr_payload, include_link=False)
print()
print(f" Link: {pairing.deep_link}")
print()
print(" When Telegram opens, tap 'Create Bot' to confirm.")
print(" (You can edit the bot display name before confirming)")
print()
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
start = time.monotonic()
deadline = start + poll_timeout
idx = 0
while time.monotonic() < deadline:
char = spinner_chars[idx % len(spinner_chars)]
elapsed = int(time.monotonic() - start)
remaining = max(0, int(poll_timeout - elapsed))
sys.stdout.write(
f"\r {char} Waiting for bot creation... ({remaining}s remaining) "
)
sys.stdout.flush()
idx += 1
try:
result = poll_pairing_result_once(resolved_api_url, pairing)
if result:
sys.stdout.write(
"\r ✓ Bot created successfully! \n"
)
sys.stdout.flush()
return result
except (httpx.HTTPError, ValueError):
pass
time.sleep(POLL_INTERVAL)
sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n")
sys.stdout.flush()
print(" The bot may still be created — check Telegram.")
print(" You can paste the token manually below, or re-run setup.")
return None
def auto_setup_telegram_bot(
api_url: str | None = None,
manager_bot: str = DEFAULT_MANAGER_BOT,
profile_name: Optional[str] = None,
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
) -> Optional[str]:
"""Run automatic Telegram bot creation and return only the bot token."""
result = auto_setup_telegram_bot_result(
api_url=api_url,
manager_bot=manager_bot,
profile_name=profile_name,
poll_timeout=poll_timeout,
)
return result.token if result else None
+16 -3
View File
@@ -439,6 +439,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"description": "Reasoning effort for delegated subagents",
"options": ["", "low", "medium", "high"],
},
"updates.non_interactive_local_changes": {
"type": "select",
"description": (
"When the chat app / gateway updates Hermes (no terminal prompt), "
"what to do with uncommitted local source edits. 'stash' keeps them "
"and re-applies them after the update; 'discard' throws them away. "
"Terminal updates always ask, regardless of this setting."
),
"options": ["stash", "discard"],
},
}
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
@@ -455,6 +465,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
"code_execution": "agent",
"prompt_caching": "agent",
"goals": "agent",
"updates": "general",
# Only `telegram.reactions` currently lives under telegram — fold it in
# with the other messaging-platform config (discord) so it isn't an
# orphan tab of one field.
@@ -5246,9 +5257,11 @@ async def get_session_messages(session_id: str, profile: Optional[str] = None):
@app.delete("/api/sessions/{session_id}")
async def delete_session_endpoint(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
# ``profile`` deletes a session belonging to another (local) profile by
# opening its state.db directly. Remote profiles never reach here — the
# desktop routes their DELETE to the remote backend. Omit for current/default.
db = _open_session_db_for_profile(profile)
try:
if not db.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Af (slegs teks)"
label_voice_only: "Aan (stemantwoord op stemboodskappe)"
label_all: "TTS (stemantwoord op alle boodskappe)"
help: "{toggle}\n\n**Hoe /voice werk**\n• `/voice on` — stemantwoord wanneer jy 'n stemboodskap stuur\n• `/voice tts` — stemantwoord op *elke* boodskap\n• `/voice off` — terug na slegs-teks antwoorde\n• `/voice status` — wys die huidige modus\n• `/voice` (geen argument) — wissel vinnig tussen aan en af{channels}"
help_channels: "\n\n**Lewendige stemkanale (Discord)**\n• Sluit eers by 'n stemkanaal aan, dan `/voice channel` — ek sluit aan, luister en praat my antwoorde\n• `/voice leave` — ontkoppel van die stemkanaal"
yolo:
disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Aus (nur Text)"
label_voice_only: "An (Sprachantwort auf Sprachnachrichten)"
label_all: "TTS (Sprachantwort auf alle Nachrichten)"
help: "{toggle}\n\n**So funktioniert /voice**\n• `/voice on` — Sprachantwort, wenn du eine Sprachnachricht sendest\n• `/voice tts` — Sprachantwort auf *jede* Nachricht\n• `/voice off` — zurück zu reinen Textantworten\n• `/voice status` — aktuellen Modus anzeigen\n• `/voice` (ohne Argument) — schnelles Umschalten zwischen an und aus{channels}"
help_channels: "\n\n**Live-Sprachkanäle (Discord)**\n• Tritt zuerst einem Sprachkanal bei, dann `/voice channel` — ich trete bei, höre zu und spreche meine Antworten\n• `/voice leave` — vom Sprachkanal trennen"
yolo:
disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung."
+2
View File
@@ -358,6 +358,8 @@ gateway:
label_off: "Off (text only)"
label_voice_only: "On (voice reply to voice messages)"
label_all: "TTS (voice reply to all messages)"
help: "{toggle}\n\n**How /voice works**\n• `/voice on` — voice reply when you send a voice message\n• `/voice tts` — voice reply to *every* message\n• `/voice off` — back to text-only replies\n• `/voice status` — show the current mode\n• `/voice` (no argument) — quick toggle between on and off{channels}"
help_channels: "\n\n**Live voice channels (Discord)**\n• Join a voice channel first, then `/voice channel` — I'll join, listen, and speak my replies\n• `/voice leave` — disconnect from the voice channel"
yolo:
disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Desactivado (solo texto)"
label_voice_only: "Activado (responder con voz a mensajes de voz)"
label_all: "TTS (responder con voz a todos los mensajes)"
help: "{toggle}\n\n**Cómo funciona /voice**\n• `/voice on` — respuesta de voz cuando envías un mensaje de voz\n• `/voice tts` — respuesta de voz a *cada* mensaje\n• `/voice off` — volver a respuestas solo de texto\n• `/voice status` — mostrar el modo actual\n• `/voice` (sin argumento) — alternar rápido entre activado y desactivado{channels}"
help_channels: "\n\n**Canales de voz en vivo (Discord)**\n• Únete primero a un canal de voz, luego `/voice channel` — me uno, escucho y digo mis respuestas\n• `/voice leave` — desconectar del canal de voz"
yolo:
disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Désactivé (texte seulement)"
label_voice_only: "Activé (réponse vocale aux messages vocaux)"
label_all: "TTS (réponse vocale à tous les messages)"
help: "{toggle}\n\n**Comment fonctionne /voice**\n• `/voice on` — réponse vocale quand vous envoyez un message vocal\n• `/voice tts` — réponse vocale à *chaque* message\n• `/voice off` — retour aux réponses texte uniquement\n• `/voice status` — afficher le mode actuel\n• `/voice` (sans argument) — bascule rapide entre activé et désactivé{channels}"
help_channels: "\n\n**Salons vocaux en direct (Discord)**\n• Rejoignez d'abord un salon vocal, puis `/voice channel` — je rejoins, j'écoute et je parle mes réponses\n• `/voice leave` — se déconnecter du salon vocal"
yolo:
disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation."
+2
View File
@@ -347,6 +347,8 @@ gateway:
label_off: "As (téacs amháin)"
label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)"
label_all: "TTS (freagra gutha do gach teachtaireacht)"
help: "{toggle}\n\n**Conas a oibríonn /voice**\n• `/voice on` — freagra gutha nuair a sheolann tú teachtaireacht gutha\n• `/voice tts` — freagra gutha do *gach* teachtaireacht\n• `/voice off` — ar ais go freagraí téacs amháin\n• `/voice status` — taispeáin an mód reatha\n• `/voice` (gan argóint) — scoránaigh go tapa idir air agus as{channels}"
help_channels: "\n\n**Cainéil gutha bheo (Discord)**\n• Téigh isteach i gcainéal gutha ar dtús, ansin `/voice channel` — téim isteach, éistim agus labhraím mo fhreagraí\n• `/voice leave` — dícheangail ón gcainéal gutha"
yolo:
disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Ki (csak szöveg)"
label_voice_only: "Be (hangválasz hangüzenetekre)"
label_all: "TTS (hangválasz minden üzenetre)"
help: "{toggle}\n\n**Hogyan működik a /voice**\n• `/voice on` — hangválasz, amikor hangüzenetet küldesz\n• `/voice tts` — hangválasz *minden* üzenetre\n• `/voice off` — vissza a csak szöveges válaszokhoz\n• `/voice status` — az aktuális mód megjelenítése\n• `/voice` (argumentum nélkül) — gyors váltás be és ki között{channels}"
help_channels: "\n\n**Élő hangcsatornák (Discord)**\n• Először lépj be egy hangcsatornába, majd `/voice channel` — csatlakozom, hallgatok és hangosan válaszolok\n• `/voice leave` — lecsatlakozás a hangcsatornáról"
yolo:
disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "Off (solo testo)"
label_voice_only: "On (risposta vocale ai messaggi vocali)"
label_all: "TTS (risposta vocale a tutti i messaggi)"
help: "{toggle}\n\n**Come funziona /voice**\n• `/voice on` — risposta vocale quando invii un messaggio vocale\n• `/voice tts` — risposta vocale a *ogni* messaggio\n• `/voice off` — torna alle risposte solo testo\n• `/voice status` — mostra la modalità attuale\n• `/voice` (senza argomento) — alterna rapidamente tra attivo e disattivo{channels}"
help_channels: "\n\n**Canali vocali dal vivo (Discord)**\n• Entra prima in un canale vocale, poi `/voice channel` — mi unisco, ascolto e parlo le mie risposte\n• `/voice leave` — disconnetti dal canale vocale"
yolo:
disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione."
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "オフ (テキストのみ)"
label_voice_only: "オン (音声メッセージにのみ音声で返信)"
label_all: "TTS (すべてのメッセージに音声で返信)"
help: "{toggle}\n\n**/voice の使い方**\n• `/voice on` — 音声メッセージを送ると音声で返信\n• `/voice tts` — *すべての*メッセージに音声で返信\n• `/voice off` — テキストのみの返信に戻す\n• `/voice status` — 現在のモードを表示\n• `/voice`(引数なし)— オンとオフをすばやく切り替え{channels}"
help_channels: "\n\n**ライブ音声チャンネル (Discord)**\n• 先に音声チャンネルに参加してから `/voice channel` — 参加して聞き取り、音声で返信します\n• `/voice leave` — 音声チャンネルから切断"
yolo:
disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。"
+2
View File
@@ -343,6 +343,8 @@ gateway:
label_off: "꺼짐 (텍스트 전용)"
label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)"
label_all: "TTS (모든 메시지에 음성으로 응답)"
help: "{toggle}\n\n**/voice 사용법**\n• `/voice on` — 음성 메시지를 보내면 음성으로 답변\n• `/voice tts` — *모든* 메시지에 음성으로 답변\n• `/voice off` — 텍스트 전용 답변으로 복귀\n• `/voice status` — 현재 모드 표시\n• `/voice` (인자 없음) — 켜기와 끄기를 빠르게 전환{channels}"
help_channels: "\n\n**라이브 음성 채널 (Discord)**\n• 먼저 음성 채널에 들어간 다음 `/voice channel` — 제가 참여해 듣고 음성으로 답변합니다\n• `/voice leave` — 음성 채널에서 연결 해제"
yolo:
disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다."

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