Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 350f4a6448 fix(update): use subprocess hand-off instead of os.execve (cross-platform)
The exec-based re-exec was sketchy (replaces the running interpreter
mid-update on the common CLI path that already works) and, worse, had to
disable itself on Windows because os.exec* there spawns a new PID and
breaks the desktop installer's exit-code wait — so it didn't fix the
"do it again" trap for Windows users at all.

Replace it with a plain subprocess hand-off: after a successful pull + dep
install, spawn `hermes update` again with HERMES_UPDATE_FINALIZE=1, inherit
stdio, and forward the child's exit code via sys.exit(). The parent PID
stays intact everywhere, so the hand-off now stays on for Windows too. This
is the same `[sys.executable, "-m", "hermes_cli.main", ...]` subprocess
pattern the updater already uses for the desktop build.

Renames: _should_reexec_after_pull -> _should_handoff_after_pull (drops the
Windows gate), _reexec_into_updated_code -> _handoff_update_to_refreshed_code
(returns child rc / None), HERMES_UPDATE_NO_REEXEC -> HERMES_UPDATE_NO_HANDOFF.
On spawn failure we still fall through and finish in-process.
2026-06-05 06:11:17 -05:00
Brooklyn Nicholson a6c60a2902 fix(update): re-exec into pulled code so update finishes in one run
`hermes update` runs from the *old* install, but its post-pull steps
(dependency install, config migration, skills sync, gateway restart) ran
from the modules this process imported at startup — even though the new
source was already on disk after the git pull. Any bug fixed in the pulled
version that lived in an already-imported post-pull path still crashed the
first run, so users had to run `hermes update` a second time (the retry
loaded the fresh code and succeeded). The desktop installer drives the same
old `hermes update`, so it hit the same "do it again" trap.

After a successful pull AND dependency install (so new source and its deps
are present), re-exec into the refreshed code via POSIX `execve`, which
keeps the same PID/fds so the installer's stdout/stderr streaming and
exit-code wait are unaffected. The re-exec'd run sets
`HERMES_UPDATE_FINALIZE=1`, skips the fetch/pull/snapshot/backup work and
the re-exec itself, and runs only the post-pull finalize steps with new
code.

Gated off on the finalize pass (loop-breaker), on Windows (`os.exec*`
spawns a new PID and would break the installer's exit-code wait), under
pytest (never replace the test interpreter mid-suite), and via the
`HERMES_UPDATE_NO_REEXEC` escape hatch. On any `execve` failure we fall
through and finish in-process — the historical behavior.
2026-06-05 06:07:05 -05:00
164 changed files with 2528 additions and 10504 deletions
-6
View File
@@ -108,12 +108,6 @@ 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,61 +2720,6 @@ 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,13 +33,6 @@ 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."""
@@ -421,18 +414,6 @@ 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:
+1 -22
View File
@@ -571,28 +571,7 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs[k] = v
if 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
api_kwargs["extra_body"] = extra_body
return api_kwargs
@@ -171,19 +171,12 @@ 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 mut update = run_streamed(
let update = run_streamed(
&app,
&hermes,
&update_args,
@@ -192,38 +185,6 @@ 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 {
@@ -405,77 +366,18 @@ 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] Hermes still holding the venv shim; force-killing stragglers…",
"[update] timed out waiting for Hermes to exit; proceeding anyway",
);
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,6 +24,12 @@
### 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
+2 -84
View File
@@ -18,24 +18,11 @@
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the session cookies the gateway may set,
// Bare + prefixed variants of the access-token cookie 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()
@@ -131,41 +118,6 @@ 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 || '')
@@ -198,56 +150,22 @@ 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,81 +15,16 @@ 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', () => {
@@ -196,10 +131,7 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
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).
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
@@ -214,56 +146,6 @@ 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', () => {
+94 -537
View File
@@ -32,12 +32,8 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -1313,111 +1309,6 @@ 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
@@ -1484,12 +1375,6 @@ 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, {
@@ -3282,16 +3167,8 @@ 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 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.
// * 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.
// ---------------------------------------------------------------------------
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
@@ -3302,9 +3179,8 @@ function getOauthSession() {
return oauthSession
}
// Bare + prefixed variants of the session cookies live in
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
// that module for details.
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
@@ -3325,30 +3201,6 @@ 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
@@ -3595,38 +3447,6 @@ 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
@@ -3642,7 +3462,7 @@ function readDesktopConnectionConfig() {
return connectionConfigCache
}
let config = { mode: 'local', remote: {}, profiles: {} }
let config = { mode: 'local', remote: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
@@ -3656,11 +3476,7 @@ function readDesktopConnectionConfig() {
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
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)
remote
}
}
} catch {
@@ -3712,114 +3528,104 @@ function writeActiveDesktopProfile(name) {
return value || null
}
// 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'
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 || '')
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
// 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)
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode,
// Echo the scope back so the UI knows which profile (if any) this reflects.
profile: key,
mode: config.mode === 'remote' ? 'remote' : 'local',
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
// 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)
envOverride: 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'
// 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()
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const nextToken = incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingBlock.token
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]
}
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
authMode,
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
// 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.')
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
}
return { mode, remote: nextRemote }
}
// 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)
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'
if (authMode === 'oauth') {
// 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))) {
// 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))) {
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.'
@@ -3843,7 +3649,7 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
return {
baseUrl,
mode: 'remote',
source,
source: 'settings',
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
@@ -3851,6 +3657,8 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
@@ -3861,92 +3669,13 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
return {
baseUrl,
mode: 'remote',
source,
source: 'settings',
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
@@ -4014,11 +3743,6 @@ 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;
@@ -4026,17 +3750,17 @@ async function testDesktopConnectionConfig(input = {}) {
let baseUrl
let token = null
let authMode = 'token'
if (wantRemote && block?.url) {
baseUrl = normalizeRemoteBaseUrl(block.url)
authMode = normAuthMode(block.authMode)
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
if (authMode !== 'oauth') {
token = decryptDesktopSecret(block.token)
token = decryptDesktopSecret(config.remote.token)
}
} else {
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
const remote = (await resolveRemoteBackend()) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
authMode = normAuthMode(remote.authMode)
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
@@ -4208,21 +3932,10 @@ 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) {
// 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)
// Remote deployments are single-tenant; profiles only apply to local backends.
const remote = await resolveRemoteBackend()
if (remote) {
await waitForHermes(remote.baseUrl, remote.token)
return {
...remote,
profile,
logs: hermesLog.slice(-80),
...getWindowState()
}
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
}
const port = await pickPort()
@@ -4323,9 +4036,7 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// 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())
const remote = await resolveRemoteBackend()
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@@ -4662,9 +4373,7 @@ 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 (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
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) => {
@@ -4679,36 +4388,22 @@ 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)
// 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 }
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config, payload?.profile)
return sanitizeDesktopConnectionConfig(config)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
const key = connectionScopeKey(payload?.profile)
await teardownPrimaryBackendAndWait()
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)
mainWindow?.reload()
return sanitizeDesktopConnectionConfig(config)
})
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
@@ -4736,145 +4431,7 @@ 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: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
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.17.0",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",
+40 -46
View File
@@ -5,7 +5,6 @@ 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'
@@ -22,11 +21,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, a: Translations['agents']): ReactNode {
function statusGlyph(status: SubagentStatus): ReactNode {
if (status === 'running' || status === 'queued') {
return (
<BrailleSpinner
ariaLabel={a.running}
ariaLabel="Running"
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe"
/>
@@ -34,10 +33,10 @@ function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNo
}
if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
}
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
@@ -76,7 +75,6 @@ interface AgentsViewProps {
}
export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
@@ -89,61 +87,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
return (
<OverlayView
closeLabel={t.agents.close}
closeLabel="Close agents"
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">{t.agents.title}</h2>
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
<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>
</header>
<SubagentTree tree={tree} />
</OverlayView>
)
}
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
const fmtDuration = (seconds?: number) => {
if (!seconds || seconds <= 0) {
return ''
}
if (seconds < 60) {
return a.durationSeconds(seconds.toFixed(1))
return `${seconds.toFixed(1)}s`
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return a.durationMinutes(m, s)
return `${m}m ${s}s`
}
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
const fmtTokens = (value?: number) => {
if (!value) {
return ''
}
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
}
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
const fmtAge = (updatedAt: number, nowMs: number) => {
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
if (s < 2) {
return a.ageNow
return 'now'
}
if (s < 60) {
return a.ageSeconds(s)
return `${s}s ago`
}
const m = Math.floor(s / 60)
if (m < 60) {
return a.ageMinutes(m)
return `${m}m ago`
}
return a.ageHours(Math.floor(m / 60))
return `${Math.floor(m / 60)}h ago`
}
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
@@ -151,7 +149,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
interface RootGroup {
id: string
delegationIndex: number
label: string
nodes: SubagentNode[]
taskCount: number
}
@@ -175,19 +173,18 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
if (node.taskCount > 1) {
n += 1
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
continue
}
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
groups.push({ id: node.id, label: '', 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())
@@ -213,19 +210,21 @@ 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">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
<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>
</div>
)
}
const summary = [
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) : '',
`${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) : '',
cost > 0 ? `$${cost.toFixed(2)}` : ''
].filter(Boolean)
@@ -244,8 +243,6 @@ 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} />
}
@@ -255,9 +252,8 @@ 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.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}
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
</p>
<div className="grid min-w-0 gap-4">
{group.nodes.map(node => (
@@ -279,7 +275,6 @@ 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]
@@ -291,7 +286,7 @@ function StreamLine({
{entry.text}
{active ? (
<BrailleSpinner
ariaLabel={t.agents.streaming}
ariaLabel="Streaming"
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe"
/>
@@ -302,7 +297,6 @@ 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}`)
@@ -323,10 +317,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
const subtitle = [
node.model,
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))
fmtDuration(durationSeconds),
node.toolCount ? `${node.toolCount} tools` : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
`updated ${fmtAge(node.updatedAt, nowMs)}`
].filter(Boolean)
return (
@@ -337,7 +331,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, t.agents)}</span>
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn(
@@ -372,7 +366,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">{t.agents.files}</p>
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">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}
@@ -380,7 +374,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">
{t.agents.moreFiles(fileLines.length - 8)}
+{fileLines.length - 8} more files
</p>
) : null}
</div>
+31 -63
View File
@@ -5,7 +5,6 @@ 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,
@@ -19,7 +18,6 @@ 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'
@@ -313,15 +311,15 @@ function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
function pageRangeLabel(total: number, page: number, pageSize: number): string {
if (total === 0) {
return a.zero
return '0'
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return a.rangeOf(start, end, total)
return `${start}-${end} of ${total}`
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
@@ -358,25 +356,21 @@ type CellCtx = {
interface ArtifactColumn {
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
bodyClassName: string
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
header: (filter: ArtifactFilter) => string
id: 'location' | 'primary' | 'session'
width: (filter: ArtifactFilter) => string
}
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
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')
@@ -385,8 +379,6 @@ 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)))
@@ -401,14 +393,12 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
})
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
} catch (err) {
notifyError(err, a.failedLoad)
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [a])
}, [])
useRefreshHotkey(refreshArtifacts)
@@ -489,9 +479,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, a.openFailed)
notifyError(err, 'Open failed')
}
}, [a])
}, [])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -513,46 +503,34 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={counts.all === 0}
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>
}
searchPlaceholder="Search artifacts..."
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
All <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
Images <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
Files <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
Links <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
>
{!artifacts ? (
<PageLoader label={a.indexing} />
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</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>
</div>
) : (
@@ -569,7 +547,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={a.itemsImage}
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
@@ -601,7 +579,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter, a)}
itemLabel={itemsLabel(kindFilter)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
@@ -630,14 +608,12 @@ 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, a)} {itemLabel}
{pageRangeLabel(total, page, pageSize)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
@@ -651,7 +627,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={a.goToPage(itemLabel, item)}
aria-label={`Go to ${itemLabel} page ${item}`}
isActive={page === item}
onClick={() => onPageChange(item)}
>
@@ -681,10 +657,6 @@ 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
@@ -711,7 +683,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" />
{kindLabel}
{artifact.kind}
</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label}
@@ -726,7 +698,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" />
{a.chat}
Chat
</Button>
</div>
</div>
@@ -796,10 +768,9 @@ 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 ? t.artifacts.copyUrl : t.artifacts.copyPath
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
@@ -843,22 +814,21 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
{
Cell: LocationCell,
bodyClassName: 'px-2.5 py-1.5',
header: (filter, a) =>
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
id: 'location',
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
},
{
Cell: SessionCell,
bodyClassName: 'p-0',
header: (_filter, a) => a.colSession,
header: () => 'Session',
id: 'session',
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
}
@@ -873,15 +843,13 @@ 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, t.artifacts)}
{col.header(filter)}
</th>
))}
</tr>
@@ -2,7 +2,6 @@ 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'
@@ -27,8 +26,6 @@ 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'
@@ -56,12 +53,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(c.couldNotPreview(attachment.label))
throw new Error(`Could not preview ${attachment.label}`)
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
notifyError(error, 'Preview unavailable')
}
}
@@ -69,7 +66,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 ? c.previewLabel(attachment.label) : attachment.label}
aria-label={canPreview ? `Preview ${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()}
@@ -100,7 +97,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
</button>
{onRemove && (
<button
aria-label={c.removeAttachment(attachment.label)}
aria-label={`Remove ${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,14 +11,29 @@ 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 SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
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.'
}
]
export function ContextMenu({
state,
@@ -29,8 +44,6 @@ 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 —
@@ -58,81 +71,78 @@ 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">
{c.attachLabel}
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
{c.files}
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
{c.folder}
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
{c.images}
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
{c.pasteImage}
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
{c.url}
URL
</ContextMenuItem>
<DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
{c.promptSnippets}
Prompt snippets
</ContextMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
{c.tipPre}
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
{c.tipPost}
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
<PromptSnippetsDialog
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
</>
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
const { t } = useI18n()
const c = t.composer
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
<DialogHeader>
<DialogTitle>{c.snippetsTitle}</DialogTitle>
<DialogDescription>{c.snippetsDesc}</DialogDescription>
<DialogTitle>Prompt snippets</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{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>
{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}
</span>
</button>
</li>
)
})}
</span>
</button>
</li>
))}
</ul>
</DialogContent>
</Dialog>
@@ -165,8 +175,15 @@ 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[]
}
+16 -26
View File
@@ -1,7 +1,6 @@
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'
@@ -56,9 +55,6 @@ export function ComposerControls({
voiceStatus: VoiceStatus
onDictate: () => void
}) {
const { t } = useI18n()
const c = t.composer
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
@@ -69,9 +65,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={c.startVoice}>
<Tip label="Start voice conversation">
<Button
aria-label={c.startVoice}
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
@@ -85,9 +81,9 @@ export function ComposerControls({
</Button>
</Tip>
) : (
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Button
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
@@ -117,27 +113,25 @@ 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'
? c.speaking
? 'Speaking'
: status === 'transcribing'
? c.transcribing
? 'Transcribing'
: status === 'thinking'
? c.thinking
? 'Thinking'
: muted
? c.muted
: c.listening
? 'Muted'
: 'Listening'
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
@@ -154,34 +148,32 @@ function ConversationPill({
</Tip>
{listening && (
<Button
aria-label={c.stopListening}
aria-label="Stop listening and send"
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>{c.stopShort}</span>
<span>Stop</span>
</Button>
)}
<Button
aria-label={c.endConversation}
aria-label="End voice conversation"
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>{c.endShort}</span>
<span>End</span>
</Button>
<span className="sr-only" role="status">
{label}
@@ -228,12 +220,10 @@ function DictationButton({
status: VoiceStatus
onToggle: () => void
}) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Tip label={aria}>
@@ -1,32 +1,44 @@
import type { ReactNode } from 'react'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
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']
]
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={c.commonCommands}>
{COMMON_COMMAND_KEYS.map(key => (
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
<Section title="Common commands">
{COMMON_COMMANDS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} mono />
))}
</Section>
<Section title={c.hotkeys}>
{HOTKEY_KEYS.map(key => (
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
<Section title="Hotkeys">
{HOTKEYS.map(([key, desc]) => (
<Row description={desc} 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> {c.helpFooter}
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
</p>
</div>
)
+29 -10
View File
@@ -17,7 +17,6 @@ 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'
@@ -85,6 +84,29 @@ 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 {
@@ -168,10 +190,7 @@ 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
@@ -179,7 +198,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 ? followUpPlaceholders : newSessionPlaceholders)
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
)
const prevSessionIdRef = useRef(sessionId)
@@ -198,16 +217,16 @@ export function ChatBar({
return
}
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
}, [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'
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
? 'Reconnecting to Hermes…'
: 'Starting Hermes...'
: restingPlaceholder
const focusInput = useCallback(() => {
@@ -1194,7 +1213,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={t.composer.message}
aria-label="Message"
autoCapitalize="off"
autoCorrect="off"
className={cn(
@@ -3,7 +3,6 @@ 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'
@@ -17,12 +16,10 @@ interface QueuePanelProps {
onSendNow: (id: string) => void
}
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
const entryPreview = (entry: QueuedPromptEntry) =>
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
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) {
@@ -37,7 +34,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{c.queued(entries.length)}</span>
<span className="truncate">{entries.length} Queued</span>
</button>
{!collapsed && (
@@ -60,17 +57,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, c)}</p>
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</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>
{c.attachments(attachmentsCount)}
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
</span>
)}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
Editing in composer
</span>
)}
</div>
@@ -84,9 +81,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={c.editQueued}>
<Tip label="Edit queued turn">
<Button
aria-label={c.editQueued}
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
@@ -97,9 +94,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label={c.sendQueuedNow}>
<Tip label="Send queued turn now">
<Button
aria-label={c.sendQueuedNow}
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
@@ -110,9 +107,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.deleteQueued}>
<Tip label="Delete queued turn">
<Button
aria-label={c.deleteQueued}
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
@@ -1,4 +1,3 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
@@ -11,8 +10,6 @@ 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)
@@ -24,7 +21,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
return (
<div
aria-label={c.themeSuggestions}
aria-label="Desktop theme suggestions"
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
@@ -32,10 +29,8 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
<CompletionDrawerEmpty title="No matching themes.">
Try <span className="font-mono text-foreground/80">/skin list</span>.
</CompletionDrawerEmpty>
) : (
items.map(item => (
@@ -10,7 +10,6 @@ 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
@@ -30,8 +29,6 @@ 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)
@@ -46,8 +43,8 @@ export function UrlDialog({
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
<DialogTitle>Attach a URL</DialogTitle>
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
</div>
</DialogHeader>
<form
@@ -63,24 +60,23 @@ export function UrlDialog({
autoCorrect="off"
inputMode="url"
onChange={e => onChange(e.target.value)}
placeholder={c.urlPlaceholder}
placeholder="https://example.com/post"
ref={inputRef}
spellCheck={false}
value={value}
/>
{trimmed.length > 0 && !looksLikeUrl && (
<p className="text-xs text-muted-foreground/85">
{c.urlHintPre}
<span className="font-mono">https://…</span>
Include the full URL, e.g. <span className="font-mono">https://…</span>
</p>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
Cancel
</Button>
<Button disabled={!looksLikeUrl} type="submit">
{c.attach}
Attach
</Button>
</DialogFooter>
</form>
@@ -2,7 +2,6 @@ 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'
@@ -164,14 +163,12 @@ 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 ? t.composer.dictating : t.composer.transcribing
const title = recording ? 'Dictating' : 'Transcribing'
return (
<div
@@ -204,7 +201,6 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
}
export function VoicePlaybackActivity() {
const { t } = useI18n()
const playback = useStore($voicePlayback)
if (playback.status === 'idle') {
@@ -214,10 +210,10 @@ export function VoicePlaybackActivity() {
const preparing = playback.status === 'preparing'
const title = preparing
? t.composer.preparingAudio
? 'Preparing audio'
: playback.source === 'voice-conversation'
? t.composer.speakingResponse
: t.composer.readingAloud
? 'Speaking response'
: 'Reading aloud'
return (
<div
+26 -40
View File
@@ -36,7 +36,6 @@ 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'
@@ -177,13 +176,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
function workspaceGroupsFor(sessions: SessionInfo[]): 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 || noWorkspaceLabel
const label = baseName(path) || path || 'No workspace'
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
@@ -234,8 +233,6 @@ 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)
@@ -405,8 +402,8 @@ export function ChatSidebar({
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -592,15 +589,13 @@ export function ChatSidebar({
onNavigate(item)
}}
tooltip={s.nav[item.id] ?? item.label}
tooltip={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">
{s.nav[item.id] ?? item.label}
</span>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
@@ -620,9 +615,9 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
aria-label="Search sessions"
onChange={setSearchQuery}
placeholder={s.searchPlaceholder}
placeholder="Search sessions…"
value={searchQuery}
/>
</div>
@@ -634,10 +629,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)">
{s.noMatch(trimmedQuery)}
No sessions match {trimmedQuery}.
</div>
}
label={s.results}
label="Results"
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -658,7 +653,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={s.pinned}
label="Pinned"
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
@@ -708,9 +703,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 ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
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'
@@ -729,7 +724,7 @@ export function ChatSidebar({
) : null}
</div>
}
label={s.sessions}
label="Sessions"
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -800,25 +795,19 @@ function SidebarSessionSkeletons() {
)
}
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>
)
}
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 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>{t.sidebar.shiftClickHint}</span>
<span>Shift-click a chat to pin</span>
</div>
)
}
@@ -1017,8 +1006,6 @@ 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)
@@ -1065,9 +1052,9 @@ function SidebarWorkspaceGroup({
/>
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={s.newSessionIn(group.label)}>
<Tip label={`New session in ${group.label}`}>
<button
aria-label={s.newSessionIn(group.label)}
aria-label={`New session in ${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
@@ -1082,7 +1069,7 @@ function SidebarWorkspaceGroup({
{reorderable && (
<span
{...dragHandleProps}
aria-label={s.reorderWorkspace(group.label)}
aria-label={`Reorder workspace ${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()}
>
@@ -1104,9 +1091,9 @@ function SidebarWorkspaceGroup({
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<button
aria-label={s.showMoreIn(nextCount, group.label)}
aria-label={`Show ${nextCount} more in ${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"
@@ -1157,8 +1144,7 @@ interface SidebarLoadMoreRowProps {
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
return (
<button
@@ -16,7 +16,6 @@ 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'
@@ -44,15 +43,13 @@ 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 ? r.unpin : r.pin,
label: pinned ? 'Unpin' : 'Pin',
onSelect: () => {
triggerHaptic('selection')
onPin?.()
@@ -61,17 +58,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'copy',
label: r.copyId,
label: 'Copy ID',
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
}
},
{
disabled: !sessionId,
icon: 'cloud-download',
label: r.export,
label: 'Export',
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
@@ -80,7 +77,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'edit',
label: r.rename,
label: 'Rename',
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
@@ -89,7 +86,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !onArchive,
icon: 'archive',
label: r.archive,
label: 'Archive',
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
@@ -99,7 +96,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
icon: 'trash',
label: t.common.delete,
label: 'Delete',
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
@@ -135,7 +132,6 @@ interface SessionActionsMenuProps
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
return (
@@ -144,7 +140,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={t.sidebar.row.actionsFor(actions.title)}
aria-label={`Actions for ${actions.title}`}
className="w-40"
sideOffset={sideOffset}
>
@@ -161,14 +157,13 @@ 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={t.sidebar.row.actionsFor(actions.title)} className="w-40">
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
@@ -186,8 +181,6 @@ 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)
@@ -218,10 +211,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: r.renamed })
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
onOpenChange(false)
} catch (err) {
notifyError(err, r.renameFailed)
notifyError(err, 'Rename failed')
} finally {
setSubmitting(false)
}
@@ -231,8 +224,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{r.renameTitle}</DialogTitle>
<DialogDescription>{r.renameDesc}</DialogDescription>
<DialogTitle>Rename session</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
</DialogHeader>
<Input
autoFocus
@@ -246,16 +239,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
onOpenChange(false)
}
}}
placeholder={r.untitledPlaceholder}
placeholder="Untitled session"
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
Cancel
</Button>
<Button disabled={submitting} onClick={() => void submit()} type="button">
{t.common.save}
Save
</Button>
</DialogFooter>
</DialogContent>
@@ -5,7 +5,6 @@ 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'
@@ -27,22 +26,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
[86_400_000, 'ageDay'],
[3_600_000, 'ageHour'],
[60_000, 'ageMin']
const AGE_TICKS: ReadonlyArray<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm']
]
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
function formatAge(seconds: number): string {
const delta = Math.max(0, Date.now() - seconds * 1000)
for (const [ms, key] of AGE_TICKS) {
for (const [ms, suffix] of AGE_TICKS) {
if (delta >= ms) {
return `${Math.floor(delta / ms)}${r[key]}`
return `${Math.floor(delta / ms)}${suffix}`
}
}
return r.ageNow
return 'now'
}
export function SidebarSessionRow({
@@ -62,10 +61,8 @@ 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, r)
const age = formatAge(session.last_active || session.started_at)
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
@@ -199,10 +196,10 @@ export function SidebarSessionRow({
title={title}
>
<Button
aria-label={r.actionsFor(title)}
aria-label={`Actions for ${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={r.sessionActions}
title="Session actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
@@ -223,9 +220,6 @@ 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
@@ -233,17 +227,17 @@ function SidebarRowDot({
if (needsInput) {
return (
<span
aria-label={r.needsInput}
aria-label="Needs your input"
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title={r.waitingForAnswer}
title="Waiting for your answer"
/>
)
}
return (
<span
aria-label={isWorking ? r.sessionRunning : undefined}
aria-label={isWorking ? 'Session running' : undefined}
className={cn(
'rounded-full',
isWorking
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,6 @@ 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 {
@@ -33,15 +32,12 @@ 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={c.actionsFor(title)}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
@@ -53,7 +49,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -64,7 +60,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="zap" size="0.875rem" />
<span>{c.triggerNow}</span>
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -74,7 +70,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{c.edit}</span>
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -85,7 +81,7 @@ export function CronJobActionsMenu({
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -97,14 +93,12 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return (
<Button
aria-label={t.cron.actionsFor(title)}
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title={t.cron.actionsTitle}
title="Cron job actions"
variant="ghost"
{...props}
>
+181 -180
View File
@@ -1,9 +1,10 @@
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,
@@ -13,6 +14,7 @@ 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 {
@@ -25,49 +27,78 @@ 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_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ 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 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 STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
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'
}
]
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
enabled: 'default',
scheduled: 'default',
running: 'default',
paused: 'warn',
disabled: 'muted',
error: 'bad',
error: 'destructive',
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)
@@ -124,8 +155,19 @@ function cronParts(expr: string): null | string[] {
return parts.length === 5 ? parts : null
}
function dayName(value: string, c: Translations['cron']): string {
return c.days[value] ?? c.dayFallback(value)
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 formatCronTime(minute: string, hour: string): string {
@@ -201,36 +243,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
}
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
function scheduleSummary(option: ScheduleOption, expr: string): string {
const parts = cronParts(expr)
if (!parts) {
return c.scheduleHints[option.value] ?? ''
return option.hint
}
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
if (option.value === 'daily') {
return c.everyDayAt(formatCronTime(minute, hour))
return `Every day at ${formatCronTime(minute, hour)}`
}
if (option.value === 'weekdays') {
return c.weekdaysAt(formatCronTime(minute, hour))
return `Weekdays at ${formatCronTime(minute, hour)}`
}
if (option.value === 'weekly') {
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
}
if (option.value === 'monthly') {
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
}
if (option.value === 'hourly') {
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
}
return c.scheduleHints[option.value] ?? ''
return option.hint
}
function formatTime(iso?: null | string): string {
@@ -259,35 +301,26 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps extends React.ComponentProps<'section'> {
interface CronViewProps {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
const { t } = useI18n()
const c = t.cron
export function CronView({ onClose }: CronViewProps) {
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, c.failedLoad)
} finally {
setRefreshing(false)
notifyError(err, 'Failed to load cron jobs')
}
}, [c])
}, [])
useRefreshHotkey(refresh)
@@ -315,11 +348,11 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({
kind: 'success',
title: isPaused ? c.resumed : c.paused,
title: isPaused ? 'Cron resumed' : 'Cron paused',
message: truncate(jobTitle(job), 60)
})
} catch (err) {
notifyError(err, c.failedUpdate)
notifyError(err, 'Failed to update cron job')
} finally {
setBusyJobId(null)
}
@@ -331,33 +364,14 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
try {
const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, c.failedTrigger)
notifyError(err, 'Failed to trigger cron job')
} 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({
@@ -368,7 +382,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
})
setJobs(current => (current ? [...current, created] : [created]))
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
prompt: values.prompt,
@@ -378,67 +392,61 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
})
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
}
setEditor({ mode: 'closed' })
}
return (
<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}
>
<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>
)}
{!jobs ? (
<PageLoader label={c.loading} />
<PageLoader label="Loading cron jobs..." />
) : 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 ? c.createFirst : undefined}
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
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.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 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">
{c.active(enabledCount, totalCount)}
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
{c.newCron}
New cron
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
<div>
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
c={c}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
@@ -450,40 +458,42 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
</div>
</div>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<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>
<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?"
/>
</OverlayView>
)
}
function CronJobRow({
busy,
c,
job,
onDelete,
onEdit,
@@ -491,7 +501,6 @@ function CronJobRow({
onTrigger
}: {
busy: boolean
c: Translations['cron']
job: CronJob
onDelete: () => void
onEdit: () => void
@@ -507,15 +516,19 @@ 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 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="min-w-0 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>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
{state}
</Badge>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
@@ -524,12 +537,8 @@ function CronJobRow({
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>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">
@@ -560,16 +569,6 @@ 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,
@@ -606,8 +605,6 @@ 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
@@ -650,7 +647,7 @@ function CronEditorDialog({
}
}
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -658,7 +655,7 @@ function CronEditorDialog({
const trimmedSchedule = schedule.trim()
if (!trimmedPrompt || !trimmedSchedule) {
setError(c.promptScheduleRequired)
setError('Prompt and schedule are required.')
return
}
@@ -674,7 +671,7 @@ function CronEditorDialog({
schedule: trimmedSchedule
})
} catch (err) {
setError(err instanceof Error ? err.message : c.failedSave)
setError(err instanceof Error ? err.message : 'Failed to save cron job')
} finally {
setSaving(false)
}
@@ -684,56 +681,60 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
<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>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Field htmlFor="cron-name" label="Name" optional>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder={c.namePlaceholder}
placeholder="Morning briefing"
value={name}
/>
</Field>
<Field htmlFor="cron-prompt" label={c.promptLabel}>
<Field htmlFor="cron-prompt" label="Prompt">
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder={c.promptPlaceholder}
placeholder="Summarize my unread Slack threads and email me the top 5..."
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Field htmlFor="cron-frequency" label="Frequency">
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectTrigger id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{c.scheduleLabels[option.value]}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectTrigger id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_VALUES.map(value => (
<SelectItem key={value} value={value}>
{c.deliveryLabels[value]}
{DELIVERY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -742,15 +743,15 @@ function CronEditorDialog({
</div>
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Field htmlFor="cron-schedule" label="Custom schedule">
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder={c.customPlaceholder}
placeholder="0 9 * * * or weekdays at 9am"
value={schedule}
/>
<FieldHint>{c.customHint}</FieldHint>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
</Field>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
@@ -770,10 +771,10 @@ function CronEditorDialog({
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
Cancel
</Button>
<Button disabled={saving} type="submit">
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
</Button>
</DialogFooter>
</form>
@@ -786,20 +787,18 @@ function Field({
children,
htmlFor,
label,
optional,
optionalLabel
optional
}: {
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">{optionalLabel}</span>}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
</label>
{children}
</div>
@@ -821,5 +820,7 @@ interface EditorValues {
interface ScheduleOption {
expr?: string
hint: string
label: string
value: string
}
+1 -21
View File
@@ -29,7 +29,7 @@ import {
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -506,25 +506,6 @@ 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,
@@ -700,7 +681,6 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -2,7 +2,6 @@ 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,
@@ -152,7 +151,7 @@ export function useGatewayBoot({
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
notifyError(err, 'Gateway sign-in required')
}
} finally {
reconnecting = false
+77 -94
View File
@@ -3,6 +3,7 @@ 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'
@@ -13,7 +14,6 @@ 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,15 +33,31 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
type EditMap = Record<string, Record<string, string>>
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 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 stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
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')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {
@@ -70,7 +86,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: 'Paste Telegram bot token'
placeholder: '123456:ABC...'
},
TELEGRAM_ALLOWED_USERS: {
label: 'Allowed Telegram user IDs',
@@ -137,13 +153,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
placeholder: 'Paste Slack bot token'
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
placeholder: 'xoxb-...'
},
SLACK_APP_TOKEN: {
label: 'Slack app token',
help: 'Use the app-level token required for Socket Mode.',
placeholder: 'Paste Slack app token'
help: 'Starts with xapp-. Required for Socket Mode.',
placeholder: 'xapp-...'
},
SLACK_ALLOWED_USERS: {
label: 'Allowed Slack user IDs',
@@ -203,21 +219,18 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
}
}
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
function fieldCopy(field: MessagingEnvVarInfo) {
const copy = FIELD_COPY[field.key] || {}
const localized = m.fieldCopy[field.key] || {}
return {
label: localized.label || copy.label || field.prompt || field.key,
help: localized.help || copy.help || field.description,
placeholder: localized.placeholder || copy.placeholder || field.prompt,
label: copy.label || field.prompt || field.key,
help: copy.help || field.description,
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('')
@@ -236,14 +249,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, m.loadFailed)
notifyError(err, 'Messaging platforms failed to load')
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
}, [m])
}, [])
useRefreshHotkey(() => void refreshPlatforms())
@@ -317,11 +330,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
)
notify({
kind: 'success',
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
message: 'Restart the gateway for this change to take effect.'
})
} catch (err) {
notifyError(err, m.failedUpdate(platform.name))
notifyError(err, `Failed to update ${platform.name}`)
} finally {
setSaving(null)
}
@@ -342,11 +355,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
await refreshPlatforms()
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect
title: `${platform.name} setup saved`,
message: 'Restart the gateway to reconnect with the new credentials.'
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))
notifyError(err, `Failed to save ${platform.name}`)
} finally {
setSaving(null)
}
@@ -365,9 +378,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
await refreshPlatforms()
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
} catch (err) {
notifyError(err, m.failedClear(key))
notifyError(err, `Failed to clear ${key}`)
} finally {
setSaving(null)
}
@@ -378,11 +391,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder={m.search}
searchPlaceholder="Search messaging..."
searchValue={query}
>
{!platforms ? (
<PageLoader label={m.loading} />
<PageLoader label="Loading messaging platforms..." />
) : (
<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">
@@ -472,14 +485,12 @@ 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, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
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 hiddenCount = advancedFields.length
const isSavingEnv = saving === `env:${platform.id}`
@@ -495,11 +506,11 @@ function PlatformDetail({
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<SetupPill active={platform.configured}>
{platform.configured ? m.credentialsSet : m.needsSetup}
{platform.configured ? 'Credentials set' : 'Needs setup'}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
@@ -513,14 +524,14 @@ function PlatformDetail({
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform, m)}
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
{m.openSetupGuide}
Open setup guide
<ExternalLink className="size-3.5" />
</a>
</Button>
@@ -528,7 +539,7 @@ function PlatformDetail({
</section>
<section>
<SectionTitle>{m.required}</SectionTitle>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
@@ -543,7 +554,7 @@ function PlatformDetail({
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{m.noTokenNeeded}
This platform does not need a token here. Use the setup guide above, then enable it below.
</p>
)}
</div>
@@ -551,7 +562,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>{m.recommended}</SectionTitle>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
@@ -574,7 +585,7 @@ function PlatformDetail({
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>{m.advanced(hiddenCount)}</span>
<span>Advanced ({hiddenCount})</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
@@ -598,23 +609,19 @@ 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">
<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>
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? m.saving : m.saveChanges}
{isSavingEnv ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
@@ -629,7 +636,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 and app-level token.',
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
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.',
@@ -660,8 +667,7 @@ 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, m: Translations['messaging']) =>
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
function MessagingField({
edits,
@@ -676,9 +682,7 @@ function MessagingField({
onEdit: (key: string, value: string) => void
saving: string | null
}) {
const { t } = useI18n()
const m = t.messaging
const copy = fieldCopy(field, m)
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
@@ -689,12 +693,12 @@ function MessagingField({
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
@@ -705,7 +709,7 @@ function MessagingField({
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={m.clearField(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
@@ -717,7 +721,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">{m.saved}</span>}
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
@@ -729,45 +733,24 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
const { t } = useI18n()
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint =
platform.state === 'pending_restart'
? t.messaging.hintPendingRestart
: platform.gateway_running
? null
: t.messaging.hintGatewayStopped
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
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 (
<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]
)}
>
<Badge variant={TONE_VARIANT[tone]}>
<StatusDot tone={tone} />
{children}
</span>
</Badge>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
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>
)
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
}
@@ -1,37 +0,0 @@
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,7 +11,6 @@ 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
@@ -24,7 +23,6 @@ export function PageSearchShell({
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
searchHidden = false,
...props
@@ -60,7 +58,6 @@ 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,14 +331,7 @@ export function useSessionActions({
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
// 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 created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
if (
@@ -460,31 +453,15 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
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)
}
return
}
setFreshDraftReady(false)
@@ -536,11 +513,7 @@ export function useSessionActions({
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
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 } : {})
cols: 96
})
if (!isCurrentResume()) {
@@ -774,7 +747,7 @@ export function useSessionActions({
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
await deleteSession(storedSessionId, removed?.profile)
await deleteSession(storedSessionId)
clearQueuedPrompts(storedSessionId)
if (closingRuntimeId) {
@@ -850,7 +823,7 @@ export function useSessionActions({
}
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
await setSessionArchived(storedSessionId, true)
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
} catch (err) {
if (archived) {
@@ -2,8 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@@ -19,31 +18,29 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
function relativeTime(ms: number | undefined) {
if (!ms) {
return a.never
return 'never'
}
const diff = Date.now() - ms
if (diff < 60_000) {
return a.justNow
return 'just now'
}
if (diff < 3_600_000) {
return a.minAgo(Math.round(diff / 60_000))
return `${Math.round(diff / 60_000)} min ago`
}
if (diff < 86_400_000) {
return a.hoursAgo(Math.round(diff / 3_600_000))
return `${Math.round(diff / 3_600_000)} hours ago`
}
return a.daysAgo(Math.round(diff / 86_400_000))
return `${Math.round(diff / 86_400_000)} days ago`
}
export function AboutSettings() {
const { t } = useI18n()
const a = t.settings.about
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
@@ -72,21 +69,21 @@ export function AboutSettings() {
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? a.cantUpdate
statusLine = status?.message ?? "This build can't update itself from inside the app."
statusTone = 'error'
} else if (status?.error) {
statusLine = a.cantReach
statusLine = "We couldn't reach the update server."
statusTone = 'error'
} else if (applying) {
statusLine = a.installing
statusLine = 'An update is currently installing.'
statusTone = 'available'
} else if (behind > 0) {
statusLine = a.updateReady(behind)
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
statusTone = 'available'
} else if (status) {
statusLine = a.onLatest
statusLine = "You're on the latest version."
} else {
statusLine = a.tapCheck
statusLine = 'Tap "Check now" to look for updates.'
}
return (
@@ -96,15 +93,15 @@ export function AboutSettings() {
<Sparkles className="size-8" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
</p>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
<SectionHeading icon={RefreshCw} title="Updates" />
<div
className={cn(
@@ -114,19 +111,12 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<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 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>
<div className="mt-3 flex flex-wrap items-center gap-4">
@@ -136,13 +126,13 @@ export function AboutSettings() {
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
{checking && <Loader2 className="size-3 animate-spin" />}
{checking ? 'Checking…' : 'Check now'}
</Button>
{behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm">
{a.seeWhatsNew}
See what&apos;s new
</Button>
)}
@@ -156,17 +146,16 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
Release notes
</a>
</Button>
</div>
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
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"
/>
</div>
</SettingsContent>
@@ -1,16 +1,16 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { Check } 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 { Pill, SectionHeading, SettingsContent } from './primitives'
import { SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -52,193 +52,80 @@ 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="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>
<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>
<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>
<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>
<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>
<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>
<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">
<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">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
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)'
)}
className="group text-left"
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
@@ -246,8 +133,17 @@ export function AppearanceSettings() {
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<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">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
@@ -256,11 +152,7 @@ export function AppearanceSettings() {
{theme.description}
</div>
</div>
{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>
)}
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
</div>
</button>
)
@@ -13,7 +13,6 @@ 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'
@@ -38,20 +37,9 @@ function ConfigField({
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
}) {
const { t } = useI18n()
const label =
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (
t.settings.fieldDescriptions[schemaKey] ??
FIELD_DESCRIPTIONS[schemaKey] ??
schema.description ??
''
).trim()
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
const normalizedDesc = normalize(rawDescription)
const description =
+4 -9
View File
@@ -241,8 +241,7 @@ 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'],
'updates.non_interactive_local_changes': ['stash', 'discard']
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
}
export const FIELD_LABELS: Record<string, string> = {
@@ -310,8 +309,7 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -338,9 +336,7 @@ 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.',
'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.'
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
}
// Curated desktop config surface: only fields a user might tune from the app.
@@ -453,8 +449,7 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
'delegation.reasoning_effort'
]
}
]
@@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -7,7 +6,6 @@ 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'
@@ -76,23 +74,6 @@ 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)
@@ -102,16 +83,6 @@ 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).
@@ -129,14 +100,8 @@ 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(scope)
.getConnectionConfig()
.then(config => {
if (cancelled) {
return
@@ -152,7 +117,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
@@ -258,10 +223,6 @@ 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(() => {
@@ -278,7 +239,6 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -336,7 +296,6 @@ 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
})
@@ -346,7 +305,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@@ -368,7 +327,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@@ -398,7 +357,6 @@ 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
@@ -437,35 +395,10 @@ 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. Pick a profile below to give it
its own remote host.
an already-running Hermes backend on another machine or behind a trusted proxy.
</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" />
+13 -15
View File
@@ -3,7 +3,6 @@ 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'
@@ -35,7 +34,6 @@ 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.
@@ -66,12 +64,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, t.settings.exportFailed)
notifyError(err, 'Export failed')
}
}
const resetConfig = async () => {
if (!window.confirm(t.settings.resetConfirm)) {
if (!window.confirm('Reset all settings to Hermes defaults?')) {
return
}
@@ -80,12 +78,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, t.settings.resetFailed)
notifyError(err, 'Reset failed')
}
}
return (
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@@ -96,7 +94,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
active={activeView === view}
icon={s.icon}
key={s.id}
label={t.settings.sections[s.id] ?? s.label}
label={s.label}
onClick={() => setActiveView(view)}
/>
)
@@ -129,13 +127,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label={t.settings.nav.gateway}
label="Gateway"
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label={t.settings.nav.apiKeys}
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
@@ -159,29 +157,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label={t.settings.nav.mcp}
label="MCP"
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label={t.settings.nav.archivedChats}
label="Archived Chats"
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label={t.settings.nav.about}
label="About"
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label={t.settings.exportConfig}>
<Tip label="Export config">
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
<Tip label="Import config">
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
@@ -191,7 +189,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<IconUpload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
<Tip label="Reset to defaults">
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
@@ -57,7 +57,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false, session.profile)
await setSessionArchived(session.id, false)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
@@ -78,7 +78,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await deleteSession(session.id, session.profile)
await deleteSession(session.id)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
@@ -4,7 +4,6 @@ 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'
@@ -45,7 +44,6 @@ 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)
@@ -78,7 +76,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
@@ -87,12 +85,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
label: 'Swap sidebar sides',
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
},
title: t.titlebar.swapSidebarSidesTitle
title: 'Swap the sessions and file browser sides'
},
...leftTools
]
@@ -100,7 +98,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = {
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
@@ -113,13 +111,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
label: 'Open settings',
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
@@ -201,7 +199,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
@@ -224,7 +221,6 @@ 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(await screen.findByText('Cron Jobs')).toBeTruthy()
expect(screen.getByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull()
})
+57 -77
View File
@@ -3,18 +3,16 @@ 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'
@@ -72,39 +70,33 @@ 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, t.skills.skillsLoadFailed)
} finally {
setRefreshing(false)
notifyError(err, 'Skills failed to load')
}
}, [t])
}, [])
useRefreshHotkey(refreshCapabilities)
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
}, [t])
useRefreshHotkey(refreshCapabilities)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
useEffect(() => {
void refreshCapabilities()
@@ -156,11 +148,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 ? t.skills.skillEnabled : t.skills.skillDisabled,
message: t.skills.appliesToNewSessions(skill.name)
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, t.skills.failedToUpdate(skill.name))
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
@@ -177,11 +169,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)
notify({
kind: 'success',
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
})
} catch (err) {
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
} finally {
setSavingToolset(null)
}
@@ -191,66 +183,54 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
<>
<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}
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
<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>
{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>
{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>
)}
</>
))}
</>
) : undefined
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
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>
}
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>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleSkills.length === 0 ? (
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<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)">
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -259,7 +239,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) || t.skills.noDescription}
{asText(skill.description) || 'No description.'}
</p>
</div>
<Switch
@@ -276,15 +256,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleToolsets.length === 0 ? (
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
@@ -297,19 +277,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={t.skills.configureToolset(label)}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label={`Configure ${label}`}
className="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 ? t.skills.configured : t.skills.needsKeys}
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</button>
<Switch
aria-label={t.skills.toggleToolset(label)}
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
@@ -317,7 +297,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || t.skills.noDescription}
{asText(toolset.description) || 'No description.'}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
@@ -3,7 +3,6 @@ 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'
@@ -28,7 +27,6 @@ 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)
@@ -143,7 +141,7 @@ export function BootFailureOverlay() {
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
if (result?.connected) {
notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage })
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
window.location.reload()
return
@@ -151,24 +149,19 @@ export function BootFailureOverlay() {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
} catch (err) {
notifyError(err, t.boot.failure.signInFailed)
notifyError(err, 'Sign-in failed')
} finally {
setBusy(null)
}
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const copy = t.boot.failure
const label = signInLabel(remoteReauth, {
identityProvider: copy.identityProvider,
remoteGateway: copy.signInToRemoteGateway,
withProvider: copy.signInWithProvider
})
const label = signInLabel(remoteReauth)
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
@@ -179,10 +172,12 @@ export function BootFailureOverlay() {
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? copy.remoteTitle : copy.title}
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{remoteReauth ? copy.remoteDescription : copy.description}
{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."}
</p>
</div>
</div>
@@ -202,26 +197,28 @@ export function BootFailureOverlay() {
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{copy.retry}
Retry
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
{copy.repairInstall}
Repair install
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.useLocalGateway}
Use local gateway
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
{copy.openLogs}
Open logs
</Button>
</div>
<p className="text-xs text-muted-foreground">
{remoteReauth ? copy.remoteSignInHint : copy.repairHint}
{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.'}
</p>
</div>
@@ -232,7 +229,7 @@ export function BootFailureOverlay() {
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
{showLogs ? 'Hide' : 'Show'} recent logs
</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,7 +8,6 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
@@ -14,18 +14,6 @@ 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
@@ -70,12 +58,10 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
}
// Button copy for the remote sign-in action.
export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
export function signInLabel(reauth: RemoteReauth | null): string {
if (reauth?.isPassword) {
return copy.remoteGateway
return 'Sign in to remote gateway'
}
const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
return copy.withProvider(provider ?? copy.identityProvider)
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
}
+8 -16
View File
@@ -5,7 +5,6 @@ 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'
@@ -31,10 +30,8 @@ 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) {
@@ -75,7 +72,7 @@ export function NotificationStack() {
// scope, so fall back to its constant (34px) when mounted on <body>.
return createPortal(
<div
aria-label={copy.region}
aria-label="Notifications"
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"
>
@@ -84,10 +81,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 ? copy.hide : copy.show} {copy.more(overflowCount)}
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
</button>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
{copy.clearAll}
Clear all
</button>
</div>
)}
@@ -100,8 +97,6 @@ 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
@@ -131,7 +126,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
</AlertDescription>
</div>
<button
aria-label={copy.dismiss}
aria-label="Dismiss notification"
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"
@@ -143,12 +138,9 @@ 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">{copy.details}</summary>
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">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}
@@ -156,12 +148,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={copy.copyDetailFailed}
errorMessage="Could not copy notification detail"
iconClassName="size-3"
label={copy.copyDetail}
label="Copy detail"
text={detail}
>
{copy.copyDetail}
Copy detail
</CopyButton>
</div>
</details>
+1 -7
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: (profile?: null | string) => Promise<DesktopConnectionConfig>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
@@ -190,9 +190,6 @@ 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
@@ -202,9 +199,6 @@ 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
+4 -21
View File
@@ -29,7 +29,6 @@ import type {
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionMessagesResponse,
@@ -81,7 +80,6 @@ export type {
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
RpcEvent,
@@ -168,13 +166,8 @@ export async function listAllProfileSessions(
}
}
// 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 }> {
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
@@ -187,10 +180,8 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
// 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.
// `profile` reads another profile's transcript straight off its state.db via the
// primary backend (no spawn). Omit for the current/default profile.
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
@@ -199,9 +190,8 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
})
}
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
export function deleteSession(id: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
@@ -213,7 +203,6 @@ 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 } : {}) }
@@ -565,12 +554,6 @@ 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
@@ -1,8 +0,0 @@
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
@@ -1,168 +0,0 @@
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
@@ -1,183 +0,0 @@
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
@@ -1,744 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,38 +0,0 @@
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
@@ -1,56 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,53 +0,0 @@
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
@@ -1,610 +0,0 @@
// 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
@@ -1,873 +0,0 @@
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,7 +15,6 @@ 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,7 +43,6 @@ 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
+7 -10
View File
@@ -8,7 +8,6 @@ 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'
@@ -28,15 +27,13 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
+3 -4
View File
@@ -1,7 +1,6 @@
import { atom } from 'nanostores'
import type { DesktopBootProgress } from '@/global'
import { translateNow } from '@/i18n'
export interface DesktopBootState extends DesktopBootProgress {
visible: boolean
@@ -10,7 +9,7 @@ export interface DesktopBootState extends DesktopBootProgress {
const INITIAL_BOOT_STATE: DesktopBootState = {
error: null,
fakeMode: false,
message: translateNow('boot.steps.startingHermesDesktop'),
message: 'Starting Hermes Desktop',
phase: 'renderer.init',
progress: 2,
running: true,
@@ -62,7 +61,7 @@ export function setDesktopBootStep(step: {
})
}
export function completeDesktopBoot(message = translateNow('boot.ready')) {
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
@@ -81,7 +80,7 @@ export function failDesktopBoot(message: string) {
$desktopBoot.set({
...current,
error: message,
message: translateNow('boot.desktopBootFailedWithMessage', message),
message: `Desktop boot failed: ${message}`,
phase: 'renderer.error',
progress: clampProgress(current.progress),
running: false,
+7 -10
View File
@@ -1,7 +1,5 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
export interface NotificationAction {
@@ -54,29 +52,28 @@ 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 status
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
: translateNow('notifications.errors.openaiRejectedApiKey')
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
}
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
},
{
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)
? translateNow('notifications.errors.elevenLabsNeedsKey')
: translateNow('notifications.errors.elevenLabsRejectedKey')
? 'ElevenLabs STT needs ELEVENLABS_API_KEY.'
: 'ElevenLabs rejected the API key (401).'
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () => translateNow('notifications.errors.methodNotAllowed')
summarize: () =>
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
},
{
test: msg => /microphone permission/i.test(msg),
summarize: () => translateNow('notifications.errors.microphonePermission')
summarize: () => 'Microphone permission was denied.'
}
]
+6 -7
View File
@@ -13,7 +13,6 @@ import type {
DesktopUpdateStatus,
DesktopVersionInfo
} from '@/global'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
@@ -86,12 +85,12 @@ export function reportBackendContract(contract: number | undefined): void {
}
notify({
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'),
title: translateNow('notifications.backendOutOfDateTitle')
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
title: 'Backend out of date'
})
}
@@ -122,7 +121,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
notify({
action: {
label: translateNow('notifications.seeWhatsNew'),
label: "See what's new",
onClick: () => {
snoozeUpdateToast()
openUpdatesWindow()
@@ -131,9 +130,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
durationMs: 0,
id: UPDATE_TOAST_ID,
kind: 'info',
message: translateNow('notifications.updateReadyMessage', behind),
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
onDismiss: () => snoozeUpdateToast(),
title: translateNow('notifications.updateReadyTitle')
title: 'Update ready'
})
}
-4
View File
@@ -466,10 +466,6 @@ 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 (floor 1, no ceiling; default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, 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).
+5 -33
View File
@@ -3194,18 +3194,6 @@ 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 ""
@@ -5109,9 +5097,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.[/dim]"
f"transcript.[/]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
@@ -5180,7 +5168,6 @@ 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,
@@ -5391,7 +5378,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[dim]{_escape(msg)}[/dim]")
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
return
try:
@@ -5401,7 +5388,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[dim]{_escape(msg)}[/dim]")
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
return
# Retarget the terminal/code-exec tools to match the process cwd.
@@ -5411,7 +5398,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[dim]{_escape(msg)}[/dim]")
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
@@ -9015,10 +9002,6 @@ 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":
@@ -9301,7 +9284,6 @@ 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,
@@ -13112,16 +13094,6 @@ 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,17 +3016,6 @@ 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:
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
print(f"[{self.name}] Failed to send document: {e}")
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:
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
print(f"[{self.name}] Failed to send video: {e}")
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
async def send_image(
+2 -94
View File
@@ -1179,7 +1179,6 @@ 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
@@ -1201,26 +1200,6 @@ 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"),
@@ -1229,7 +1208,6 @@ 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,
}
@@ -2618,7 +2596,6 @@ 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(
@@ -2716,7 +2693,6 @@ 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,
@@ -7932,8 +7908,6 @@ 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
@@ -8290,9 +8264,6 @@ 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)
@@ -10918,12 +10889,6 @@ 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
@@ -12033,24 +11998,13 @@ class GatewayRunner:
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
toggle_line = t("gateway.voice.enabled_short")
return 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)
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)
return t("gateway.voice.disabled_short")
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
"""Join the user's current Discord voice channel."""
@@ -17070,47 +17024,6 @@ 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
@@ -17921,11 +17834,6 @@ 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
+3 -7
View File
@@ -227,10 +227,7 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
return None
try:
raw = path.read_text(encoding="utf-8").strip()
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.
except OSError:
return None
if not raw:
return None
@@ -252,9 +249,8 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
try:
raw = pid_path.read_text().strip()
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
except OSError:
# File was deleted between exists() and read_text(), or permission flipped.
return None
if not raw:
return None
-19
View File
@@ -225,25 +225,6 @@ 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`
+85 -3
View File
@@ -216,7 +216,6 @@ 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
@@ -350,7 +349,6 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
"steer",
"stop",
"update",
"version",
}
)
@@ -1150,6 +1148,41 @@ 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."""
@@ -1566,6 +1599,52 @@ 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("/"):
@@ -1589,6 +1668,9 @@ 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
@@ -1706,7 +1788,7 @@ class SlashCommandAutoSuggest(AutoSuggest):
return Suggestion(cmd_name[len(word):])
return None
# Command is complete — suggest subcommands
# Command is complete — suggest subcommands or model names
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
+5 -43
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). 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)
# 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)
"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,28 +1841,6 @@ 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)
@@ -2275,22 +2253,6 @@ 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
@@ -2420,7 +2382,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 27,
"_config_version": 26,
}
# =============================================================================
@@ -3975,7 +3937,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions", "streaming", "updates",
"sessions", "streaming",
}
# Valid fields inside a custom_providers list entry
+1 -4
View File
@@ -81,10 +81,7 @@ 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` 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_info = job.get("repeat", {})
repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0)
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else ""
+19 -32
View File
@@ -2,18 +2,13 @@
Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL, ~15 min)
(HttpOnly, lifetime = token TTL)
- hermes_session_rt: the OAuth refresh token
(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.
(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.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
@@ -44,15 +39,13 @@ 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.
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``.
.. 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.
"""
from __future__ import annotations
@@ -73,13 +66,7 @@ PKCE_COOKIE = "hermes_session_pkce"
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# 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.)
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
@@ -139,11 +126,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 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.
``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.
``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,35 +4382,6 @@ 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"]:
@@ -4420,30 +4391,8 @@ 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.")
+219 -207
View File
@@ -1264,32 +1264,6 @@ 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.
@@ -1550,43 +1524,16 @@ 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.
# 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.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
did_install = False
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)
):
if _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",
*npm_workspace_args,
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
],
cwd=str(npm_cwd),
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(_workspace_root(tui_dir)),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -1632,8 +1579,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 termux_startup:
should_build = did_install or termux_need_rebuild
if _is_termux_startup_environment():
should_build = did_install or _tui_need_rebuild(tui_dir)
if should_build:
npm = _node_bin("npm")
@@ -2610,8 +2557,6 @@ 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(
@@ -4794,45 +4739,17 @@ 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()
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
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 models:
default_idx = 0
@@ -6647,9 +6564,7 @@ def cmd_import(args):
def _print_version_info(*, check_updates: bool = True) -> None:
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
print(f"Hermes Agent v{__version__} ({__release_date__})")
print(f"Project: {PROJECT_ROOT}")
# Show Python version
@@ -7089,14 +7004,10 @@ 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,
npm_cwd,
extra_args=(*npm_workspace_args, "--silent"),
_workspace_root(web_dir),
extra_args=("--silent",),
)
if r1.returncode != 0:
_say(
@@ -8237,6 +8148,59 @@ 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]:
@@ -8377,54 +8341,6 @@ 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`
# =========================================================================
@@ -10215,6 +10131,62 @@ 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``."""
@@ -10226,31 +10198,18 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
assume_yes = bool(getattr(args, "yes", False))
# 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
# 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"
_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...")
if finalize_only:
print("⚕ Finalizing update with refreshed code...")
else:
print("⚕ Updating Hermes Agent...")
print()
# On Windows, abort early if another hermes.exe is holding the venv shim
@@ -10266,8 +10225,10 @@ 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.
_run_pre_update_backup(args)
# 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)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
@@ -10311,6 +10272,21 @@ 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.),
@@ -10388,8 +10364,14 @@ 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
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
# 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)
checkout_result = subprocess.run(
git_cmd + ["checkout", branch],
cwd=PROJECT_ROOT,
@@ -10423,7 +10405,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f" {track_result.stderr.strip().splitlines()[0]}")
sys.exit(1)
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
# 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)
prompt_for_restore = (
auto_stash_ref is not None
@@ -10441,7 +10432,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
commit_count = int(result.stdout.strip())
if commit_count == 0:
# 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:
_invalidate_update_cache()
# Even if origin is up to date, the fork may be behind upstream
@@ -10468,24 +10463,30 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("✓ Already up to date!")
return
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>`.
# 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.
pre_update_snapshot_id = None
try:
from hermes_cli.backup import create_quick_snapshot
if not finalize_only:
print(f"→ Found {commit_count} new commit(s)")
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)
# 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)
print("→ Pulling updates...")
update_succeeded = False
@@ -10575,15 +10576,6 @@ 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,
@@ -10666,6 +10658,26 @@ 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")
+4 -71
View File
@@ -51,48 +51,12 @@ def resolve_uv() -> Optional[str]:
return None
class _UvResult(str):
"""``ensure_uv()`` return value that survives an update boundary.
def ensure_uv() -> Optional[str]:
"""Return the managed uv path, installing it first if necessary.
``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.
On failure returns ``None`` (never raises) so callers can fall
back to pip gracefully.
"""
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
@@ -124,37 +88,6 @@ def _ensure_uv_path() -> 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.
+3 -81
View File
@@ -1117,62 +1117,6 @@ 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 = "",
@@ -1790,13 +1734,6 @@ 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
@@ -1817,15 +1754,9 @@ def list_authenticated_providers(
"api_url": api_url,
"api_key": api_key,
"models": [],
"discover_models": discover,
}
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
elif api_key and not groups[group_key].get("api_key"):
groups[group_key]["api_key"] = api_key
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@@ -1914,16 +1845,7 @@ 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.
# - 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)
)
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
if should_probe:
try:
from hermes_cli.models import fetch_api_models
+6 -35
View File
@@ -1150,46 +1150,17 @@ _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 a cost-safe default model for a provider, or "" if unknown.
"""Return the default model for a provider, or empty string if unknown.
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``).
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.
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.
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``).
"""
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 ""
+3 -42
View File
@@ -159,33 +159,6 @@ 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",
@@ -215,23 +188,13 @@ 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.
``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.
"""
"""Resolve browser availability using the same precedence as runtime."""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
@@ -260,7 +223,7 @@ def _resolve_browser_feature_state(
return current_provider, False, False, False
current_provider = "local"
available = bool(browser_local_runnable)
available = bool(browser_local_available)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
@@ -280,7 +243,7 @@ def _resolve_browser_feature_state(
active = bool(browser_tool_enabled and available)
return "browserbase", available, active, False
available = bool(browser_local_runnable)
available = bool(browser_local_available)
active = bool(browser_tool_enabled and available)
return "local", available, active, False
@@ -482,7 +445,6 @@ 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,
@@ -493,7 +455,6 @@ 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,21 +474,6 @@ 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":
@@ -556,7 +541,6 @@ 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", "")
@@ -578,7 +562,6 @@ 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)
@@ -628,7 +611,6 @@ 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
@@ -717,8 +699,6 @@ 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"] = {
@@ -756,8 +736,6 @@ 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
+17 -104
View File
@@ -415,9 +415,7 @@ 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 && agent-browser install --with-deps"
)
missing_browser_hint = "npm install -g agent-browser"
tool_status.append(
("Browser Automation", False, missing_browser_hint)
)
@@ -1639,52 +1637,6 @@ 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")
@@ -1703,40 +1655,20 @@ def _setup_telegram():
print_success("Telegram allowlist configured")
return
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()
print_info("Create a bot via @BotFather on Telegram")
import re
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
while True:
token = prompt("Telegram bot token", password=True)
if not token:
print()
print_info("Falling back to manual setup...")
print()
if not token:
token = _prompt_telegram_bot_token()
if not token:
return
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
save_env_value("TELEGRAM_BOT_TOKEN", token)
print_success("Telegram token saved")
@@ -1746,30 +1678,11 @@ def _setup_telegram():
print_info(" 1. Message @userinfobot on Telegram")
print_info(" 2. It will reply with your numeric ID (e.g., 123456789)")
print()
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)"
)
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for open access)"
)
if allowed_users:
allowed_users = allowed_users.replace(" ", "")
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users)
save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", ""))
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
@@ -1,358 +0,0 @@
"""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
+3 -16
View File
@@ -439,16 +439,6 @@ _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.
@@ -465,7 +455,6 @@ _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.
@@ -5257,11 +5246,9 @@ 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, 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)
async def delete_session_endpoint(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
try:
if not db.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
-2
View File
@@ -343,8 +343,6 @@ 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,8 +343,6 @@ 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,8 +358,6 @@ 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,8 +343,6 @@ 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,8 +343,6 @@ 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,8 +347,6 @@ 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,8 +343,6 @@ 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,8 +343,6 @@ 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,8 +343,6 @@ 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,8 +343,6 @@ 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