Commit Graph

10458 Commits

Author SHA1 Message Date
35a750eedd feat(desktop): persistent needs-input indicator + icon button consolidation
Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.

Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.
2026-06-03 21:44:30 -05:00
72f556dfc4 Merge remote-tracking branch 'origin/main' into bb/desktop-background-clarify 2026-06-03 21:07:35 -05:00
58eb473baa fix(desktop): surface background-session clarify prompts instead of hanging
clarify.request is a one-shot blocking event: the gateway turn blocks on
clarify.respond. The desktop handler dropped it for any non-focused session
(`if (!isActiveEvent) return`) and stored at most one request in a single
global atom, so a background session that asked a clarifying question hung
forever and re-focusing it could never recover (the event was already gone).

- store/clarify.ts: key pending requests by runtime session id; expose the
  active session's request via a focus-scoped computed view (ClarifyTool is
  unchanged). clearClarifyRequest takes an optional session id for targeted
  clears, with a request-id fallback.
- use-message-stream.ts: park every session's clarify (drop the isActiveEvent
  early return); toast when one lands for a background session since the row
  otherwise just keeps spinning like normal work.
- clarify-tool.tsx: clear by session id so answering one chat can't wipe
  another's pending request.
- store/clarify.test.ts: concurrent independence, focus-scoped view,
  targeted/stale/fallback clears.
2026-06-03 21:07:33 -05:00
f66a929a6b fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out (#38578)
* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell

* feat(desktop): inline tool-call approval bar (Cursor-style "Run")

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).

* style(desktop): make inline approval bar match Cursor's command card

Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.

* style(desktop): shrink inline approval to a tiny Cursor-style button strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.

* style(desktop): drop the loud blue Run button for a quiet outlined control

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.

* style(desktop): make Run a soft primary badge

Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.

* style(desktop): slim the approval chevron and space out Reject

The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.

* feat(desktop): confirm before "Always allow" persists an approval

"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.

* fix(gateway): make sudo + secret prompts actually fire in the desktop

Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.

* docs(desktop): drop Cursor references from approval comments

* docs(desktop): drop Cursor reference from prompt-overlays comment

* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-04 01:53:51 +00:00
04d620d91f fix(docker): run config migrations during container boot (salvage #35508) (#36627)
Salvage of #35508 (@dchenk), rebased onto current main. Resolved the
tests/tools/test_stage2_hook_puid_pgid.py conflict (kept both the
envdir-creation regression test on main and the new config-migration
tests).

Docker image upgrades replace code under $INSTALL_DIR but preserve
$HERMES_HOME on the mounted volume, so the persisted config.yaml never
received the schema migrations that non-Docker `hermes update` runs
(#35406). This adds scripts/docker_config_migrate.py, invoked from
stage2-hook after first-boot seeding and before gateway services start:
it backs up config.yaml + .env, runs migrate_config(interactive=False),
and honors HERMES_SKIP_CONFIG_MIGRATION=1 for manual control.

Also fixes a latent bug in check_config_version(): it called load_config()
which deep-merges DEFAULT_CONFIG, so a legacy config with no raw
_config_version falsely reported as already-current. It now reads the raw
on-disk file so legacy configs are correctly detected for migration.

Differs from #35508 as submitted (Option B cleanup): dropped the
`_config_version` line added to cli-config.yaml.example and removed the
accompanying test_cli_config_example_declares_latest_version change-detector
test. The example is a copy-template and has no business asserting a schema
version; check_config_version() reads the user's real config.yaml, not the
example. This removes a second sync point that drifts on every version bump.

Closes #35508. Fixes #35406.

Co-authored-by: Dmitriy Cherchenko <17372886+dchenk@users.noreply.github.com>
2026-06-04 11:11:27 +10:00
92be989291 Merge pull request #38564 from NousResearch/bb/tui-sgr-mouse-fragment-leak
fix(hermes-ink): reassemble split SGR mouse sequences at the tokenizer (supersedes #29337)
2026-06-03 20:10:48 -05:00
343c54e35b fix(docker): reject unsupported --user <arbitrary-uid> start with clear guidance (#38579)
`docker run --user $(id -u):$(id -g)` was a tini-era trick to make
container-written files match the host user. Under s6-overlay it no longer
works: the bootstrap (UID remap, volume + build-tree chown, config seeding)
needs root, and the baked image dirs (/opt/data, /opt/hermes/.venv, ui-tui,
node_modules) are owned by the hermes build UID (10000). A pinned arbitrary
UID can't write them, so the runtime fails with EACCES on a bind mount or
hard-crashes on a named volume (Docker inits the volume from the image as
10000; the non-root start can't even `cd /opt/data`, and the profile
reconciler dies with PermissionError on gateway_state.json).

Detect that start early in both the cont-init hook (stage2-hook.sh) and the
CMD wrapper (main-wrapper.sh) and fail fast with actionable guidance pointing
at the supported path: root start + HERMES_UID/HERMES_GID (or the PUID/PGID
aliases), which remaps the hermes user and chowns the volume — the same
host-UID-matching outcome --user was used for, without breaking s6.

The guard fires only when the current UID is neither root NOR the hermes UID.
This preserves the supported non-root start from #34648/#34837 (running with
`--user 10000:10000`, i.e. pinned to the hermes UID itself), which is
unaffected — only the arbitrary-UID variant that #34837 never actually made
writable is rejected.

Verified live across five scenarios (built image, bind + named volume):
arbitrary --user on bind -> rejected with guidance, hermes does not run;
arbitrary --user on named volume -> guidance shown, no raw 'can't cd' crash;
--user 10000:10000 -> boots; root + HERMES_UID=4242 remap -> boots, guard not
tripped; default root start -> boots. Pre-fix control reproduces the raw
PermissionError + 'can't cd' crash with no guidance.
2026-06-04 10:51:51 +10:00
b0a52d74ac fix(mcp): resolve ${ENV} in discovery probe so header auth works (#38571)
`hermes mcp add --auth header` built `Authorization: Bearer ${MCP_X_API_KEY}`
and passed it straight to the discovery probe without interpolation, so the
probe sent the literal placeholder and auth-requiring servers (e.g. n8n)
returned 401. Runtime tool loading worked because `_load_mcp_config()`
interpolates, but the four CLI probe call sites (add/test/login/configure)
all used unresolved config.

Resolve `${ENV}` inside `_probe_single_server` via a new
`_resolve_mcp_server_config()` (load_hermes_dotenv + _interpolate_env_vars),
mirroring runtime loading. This covers all four call sites, not just add.

Also strip a leading `Bearer ` from pasted tokens before saving to
`MCP_*_API_KEY`, so a token pasted with the prefix doesn't produce
`Bearer Bearer <jwt>` (also a 401).

Reported with a precise root-cause analysis in #37792.

Co-authored-by: ThyFriendlyFox <116314616+ThyFriendlyFox@users.noreply.github.com>
2026-06-03 17:49:39 -07:00
5a22cd427d fix(desktop): configure local/custom endpoint without an API key or UI changes
Onboarding's "Local / custom endpoint" only wrote the OPENAI_BASE_URL env
var, which runtime resolution ignores — so a self-hosted endpoint was never
wired in and setup failed with "No usable credentials found for custom" even
though local servers need no key.

Route the local option through saveOnboardingLocalEndpoint: probe the
endpoint, auto-discover a model from /v1/models, persist provider=custom +
base_url + model via /api/model/set, then verify the runtime directly
(not via completeWithModelConfirm, which would re-assign the model without
base_url and wipe it). No onboarding form/UI changes — the existing single
URL field is enough.
2026-06-03 17:48:55 -07:00
ca06715721 feat(web): wire local/custom endpoints into model assignment
The runtime resolver reads model.base_url from config and ignores the
OPENAI_BASE_URL env var, so a self-hosted endpoint could not be configured
from the GUI. Two changes enable it:

- POST /api/model/set accepts an optional base_url and persists it as
  model.base_url when provider=custom (still clearing stale base_url for
  hosted providers).
- POST /api/providers/validate now returns the model ids a custom endpoint
  advertises at /v1/models, so the GUI can auto-pick a default without
  asking the user to type a model name.

Refs desktop onboarding "Local / custom endpoint" bug.
2026-06-03 17:48:55 -07:00
d50741af90 fix(onboarding): clarify Anthropic API vs OAuth provider entries and reorder (#38577)
The setup-flow provider list showed two Anthropic/Claude entries with
ambiguous labels ('Anthropic (Claude API)' and 'Claude Code (subscription)')
in no deliberate order. Relabel and reorder so the distinction and the
subscription caveat are explicit:

- 'Anthropic API Key' (PKCE, API path)
- 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' (external)
- Both Anthropic entries moved to the bottom of the list.
- 'OpenAI Codex (ChatGPT)' -> 'OpenAI OAuth (ChatGPT)', now first after Nous.

Applied consistently to the backend OAuth catalog (web_server.py) and the
desktop onboarding overlay's PROVIDER_DISPLAY title/order map; test
assertions updated to the new titles.
2026-06-03 17:46:04 -07:00
725290db63 test(hermes-ink): fuzz the tokenizer flush valve against fragment leaks
Hammer createTokenizer with the worst stalls a terminal can produce —
split + flush at every interior byte, and a 200-report byte-by-byte feed
that flushes after every single byte — and assert the two invariants that
make the SGR-leak class structurally impossible: nothing ever leaks as a
text token, and every complete report reassembles whole. A mixed
mouse+keystroke variant proves real input survives the same storm.
2026-06-03 19:38:08 -05:00
e7bc6189cf feat(cli): resume relaunches in the directory the session was started from (#38562)
hermes -c / --resume now reopen a session in its original working
directory. The sessions table already had a cwd column; the classic CLI
just never wrote or read it.

- run_agent._ensure_db_session stamps cwd for local CLI sessions only
  (new _launch_cwd_for_session gates out gateway/cron and non-local
  terminal backends, where a host cwd is meaningless to restore).
- cli._restore_session_cwd chdir's the process AND retargets TERMINAL_CWD
  so the terminal tool, code-exec tool, and relative-path resolution all
  land in the restored dir. Called from both resume paths (interactive
  run() and the -q single-query path).
- Robust degradation: no-op when no cwd recorded, when already there, or
  when the dir is gone (single dim warning, stays put — no crash).
2026-06-03 17:37:27 -07:00
6efc7eda57 refactor(hermes-ink): delete now-dead SGR mouse fragment recovery
With the tokenizer reassembling split CSI sequences across a flush (prior
commit), no SGR mouse fragment can reach a text token anymore — terminals
write a mouse report as one atomic sequence, and any read/flush split now
re-joins in the tokenizer buffer instead of leaking. That makes the whole
downstream recovery layer dead code:

- SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE
- parseTextWithSgrMouseFragments / parseSgrMouseFragment /
  normalizeSgrMouseFragment
- the whole-text mouse-burst noise fast path in parseMultipleKeypresses

Remove all of it (~185 lines) and the tests that only exercised it. The
narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with
its own test). This retires the #17701#18113#26781#28463#35512
regex hardening chain in favor of the one correct parser fix.
2026-06-03 19:29:42 -05:00
de124800a2 test(hermes-ink): drop input-event SGR guard test
The guard it covered was removed in the previous commit (fragments no
longer reach input-event — they reassemble at the tokenizer). Reassembly
is now covered by termio/tokenize.test.ts and the flush-boundary cases in
parse-keypress.test.ts.
2026-06-03 19:24:51 -05:00
f354323547 fix(hermes-ink): reassemble split mouse sequences at the tokenizer; drop the regex sink
Root-cause fix for the SGR mouse fragment leak (`46M35;40M...` typed into
the prompt). The leak was never really about the fragments — it was the
flush emitting them. When App's 50ms watchdog fires mid-CSI during a render
stall, the tokenizer was force-emitting the buffered partial as a token and
resetting to ground, so both the prefix and the ESC-less remainder surfaced
as unparseable input.

Make the flush state-aware (xterm.js discipline): a bare ESC still flushes
to the Escape key (the legitimate ESCDELAY case), but a buffer still inside
a multi-byte control sequence (csi/osc/dcs/apc/ss3/intermediate) is NOT
emitted — it's kept so the continuation reassembles on the next feed. A
one-tick truncation valve in createTokenizer.flush() drops a partial that
survives a second flush with no progress, so a genuinely truncated write
can't fuse into the next keypress.

With partials never entering the input stream, the downstream scrubber is
dead code: remove the SGR fragment guard from input-event.ts (both the
original `/^\[<\d+;\d+;\d+[Mm]/` and the consolidated form added earlier in
this PR). The parse-keypress burst-recovery regexes (MOUSE_BURST_*) are now
also redundant but left in place as a safety net for one release; they can
be removed in a follow-up once this soaks.

Tests: tokenize.test.ts proves a mid-CSI flush keeps/reassembles and that a
stale partial is dropped after a second flush and a bare ESC still emits;
parse-keypress.test.ts adds the end-to-end split-then-reassemble case
yielding a single clean mouse event with no leaked key.

Supersedes #29337.
2026-06-03 19:24:28 -05:00
5446153c98 fix(docker): chown build trees on UID remap independently of $HERMES_HOME (#35027 regression) (#38556)
The stage2 hook gates the recursive chown of the build trees under
$INSTALL_DIR (.venv, ui-tui, node_modules) so a HERMES_UID/PUID remap
leaves them writable by the new runtime UID — needed for lazy_deps
'uv pip install' of platform extras (#15012, #21100) and the TUI esbuild
rebuild into ui-tui/dist (#28851).

#35027 folded that chown under the $HERMES_HOME ownership check
('stat $HERMES_HOME != hermes_uid'). But 'usermod -u <new> hermes'
re-chowns the hermes home dir ($HERMES_HOME == /opt/data) to the new UID
as a side effect, so after any remap that stat is already satisfied and
needs_chown is false — silently skipping the build-tree chown on the
common PUID/NAS path. The venv stays owned by the build-time UID (10000),
so lazy installs and TUI rebuilds fail with EACCES.

Probe the build trees directly instead: chown only when /opt/hermes/.venv
is not already owned by the runtime hermes UID. Independent of
$HERMES_HOME ownership, idempotent across restarts.

Verified live: built the image, booted with HERMES_UID/HERMES_GID on a
fresh named volume, confirmed .venv/ui-tui/node_modules end up owned by
the remapped UID and 'uv pip install' into the venv succeeds; confirmed
the recursive chown fires once and is skipped on restart.
2026-06-04 10:17:55 +10:00
01c010e233 fix(hermes-ink): collapse SGR mouse fragment guards into one flush-aware rule
When App's 50ms flush watchdog fires mid-CSI during a render stall, an
SGR mouse report (ESC[<btn;col;row M/m) is split across stdin chunks: the
tokenizer force-emits the buffered prefix and resets to ground, so both
the prefix and the ESC-less remainder reach InputEvent as nameless tokens.

The previous guard only matched a full `[<\d+;\d+;\d+[Mm]` fragment, so
the flushed prefixes (`ESC[<0;35;`) and the 1-/2-field and leading-`;`
tails (`46M`, `35;46M`, `;46M`) still leaked into the composer as
`46M35;40M...` during long sessions.

Replace the three would-be narrow regexes with one consolidated rule that
covers every split position. A `(?=...\d)` lookahead keeps typed `<`, `[`,
`;`, and `M` safe (no coordinate digit), and the embedded M/m terminator
in the param class leaves stuck-together fragments / prose intact. The
existing `!keypress.name` gate continues to protect real keystrokes, which
arrive one char per chunk with a name set.

Supersedes #29337 (covers the prefix-leak and leading-`;`/1-/2-field tail
cases that PR's two added guards missed).
2026-06-03 19:05:26 -05:00
f99665f99a feat(prompt): broaden Hermes self-knowledge pointer to docs + skill (#38538)
The HERMES_AGENT_HELP_GUIDANCE block (added #16535) only fired when the
user explicitly asked about configuring/setting up Hermes. Broaden it so
the agent treats the docs as a standing source of self-knowledge for any
Hermes-related help and for understanding its own features/tools, points
to the hermes-agent skill for additional guidance, and treats the docs as
the authoritative/latest source of truth when the two differ.

Static constant in the cache-safe stable tier — no prompt-cache impact.
2026-06-03 17:01:56 -07:00
Ben
a6e47314f9 fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)
Dashboard plugins (kanban, hermes-achievements) read
window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket
URLs with ?token=. That works in loopback/--insecure mode but is
rejected on OAuth-gated deployments, where the session token is absent
and _ws_auth_ok only accepts single-use ?ticket= auth. The result was
401s on plugin REST calls and 1008/403 on the kanban live-events WS
whenever the dashboard ran behind OAuth (e.g. hosted Fly agents).

Make the plugin SDK the single sanctioned auth surface:

- web/src/lib/api.ts: add authedFetch() (raw Response for FormData
  uploads / blob downloads, token-or-cookie auth, no throw / no 401
  redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct
  auth param for the active mode — fresh single-use ticket in gated
  mode, token in loopback).
- web/src/plugins/registry.ts: expose authedFetch, buildWsUrl,
  buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__;
  add SDK_CONTRACT_VERSION.
- web/src/plugins/sdk.d.ts: hand-authored typed contract for the
  plugin SDK + registry globals (single source of truth for the
  Window declarations).
- plugins/kanban + hermes-achievements dist bundles: stop reading the
  session token directly; route uploads/downloads through
  SDK.authedFetch and the live-events WS through SDK.buildWsUrl.
- plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the
  /events WS upgrade to the canonical web_server._ws_auth_ok gate, so
  it transparently accepts loopback token / gated ticket / internal
  credential and can never drift from core auth again.
- tests: guard test asserting no plugin dist reads
  __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test.

Verified live on a gated staging Fly agent: kanban /events upgrades
101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the
old code got 403.
2026-06-03 16:59:36 -07:00
1c88360fed Merge pull request #38546 from NousResearch/bb/disable-provider-key-validation
fix(desktop): disable provider key validation in launch setup
2026-06-03 18:49:22 -05:00
475ecea3d7 fix(install): cap requires-python at <3.14 and pin UV_PYTHON to the venv (#38535)
uv selects the project Python from requires-python and from the UV_PYTHON
env var, both of which override an already-created venv on the next
'uv sync'. With no upper bound on requires-python, an inherited
UV_PYTHON=3.14 (or a fresh distro whose newest interpreter uv auto-picks)
silently recreated the installer's 3.11 venv at 3.14, where Rust-backed
transitives (pydantic-core) have no cp314 wheel and fall back to a maturin
source build that fails. This bit a Windows/WSL user with UV_PYTHON set in
their shell and a fresh WSL-arch box where uv auto-picked 3.14.

Two layers:
- pyproject: requires-python '>=3.11' -> '>=3.11,<3.14' (+ uv lock regen).
  uv now refuses a 3.14 interpreter with a clear error instead of attempting
  the maturin build. Backstop independent of the installer.
- install.sh / install.ps1: pin UV_PYTHON to the venv interpreter after
  creating it (in both the venv step and the deps step, since bootstrap runs
  those stages as separate processes). An inherited UV_PYTHON can no longer
  hijack the sync/pip tiers, so the install just works regardless of shell env.

Verified E2E: hostile UV_PYTHON=3.14 + uv venv --python 3.11 + uv sync now
installs into 3.11 with pydantic-core's 3.11 wheel; without the re-pin the
capped requires-python produces a legible incompatibility error rather than a
cryptic build failure.
2026-06-03 16:45:47 -07:00
e8c3ac2f5c fix: strip extra_content from tool_calls for strict APIs (Fireworks, Mistral)
Fireworks/Mistral reject HTTP 400 'Extra inputs are not permitted, field:
messages[N].tool_calls[M].extra_content' on any session whose history
contains prior Gemini tool calls. Gemini 3 thinking models attach
extra_content (thought_signature) to tool_calls; it survived to the wire
because the sanitize paths only stripped call_id/response_item_id.

Strip extra_content from the outgoing wire copy in both sanitize paths
(ChatCompletionsTransport.convert_messages + _sanitize_tool_calls_for_strict_api),
but gate it on the target model: keep extra_content for Gemini-family
targets (the thought_signature MUST be replayed or Gemini 400s), strip it
for everyone else — including non-Gemini models that inherit a stale Gemini
signature earlier in a mixed-provider session. Native Gemini is unaffected
(GeminiNativeClient bypasses these paths).

Original stored history is never mutated (only the per-call copy).

Fixes #17986.
2026-06-03 16:42:52 -07:00
ec69c767ff docs(desktop): point Chat section to remote-backend + dashboard doc (#38545)
The Desktop Chat section described chat-only and gave no signpost that
remote-hosted Hermes connection is documented. Adds a pointer to the
in-page remote-backend section and to the deeper Web Dashboard doc.
2026-06-03 16:40:47 -07:00
2f523a4691 fix(tui): cgroup-aware V8 heap cap so memory-limited containers stop dying silently (#38541)
The TUI hardcoded --max-old-space-size=8192. V8 is not cgroup-aware, so in a
Docker/k8s container capped below ~9-10GB the heap grows past the container
limit and the cgroup OOM-killer SIGKILLs the Node parent BEFORE V8's own heap
monitor fires. SIGKILL runs no JS handler, writes no [tui-parent] breadcrumb,
and closes the gateway child's stdin — the user sees only a bare gateway
'stdin EOF'. Complements #38224 (trail-text cap), which reduced pressure but
left the 8GB-vs-container mismatch in place.

- _read_cgroup_memory_limit(): read cgroup v2 (memory.max) then v1
  (memory.limit_in_bytes); handle 'max', the v1 unlimited sentinel, blank/zero,
  and >=1PB as unconstrained.
- _resolve_tui_heap_mb(): unconstrained -> 8192; constrained -> 75% of the
  cgroup limit (headroom for non-heap RSS + the Python child sharing the
  cgroup), floored at 1536MB, never above 8192.
- NODE_OPTIONS block uses the sized value; still respects a user-supplied
  --max-old-space-size.

Net: V8 now GCs/exits gracefully (onCritical breadcrumb fires) instead of being
reaped silently. Display/transport only — no agent context or behavior change.

Tests: tests/hermes_cli/test_tui_heap_sizing.py (20 tests).
2026-06-03 16:40:28 -07:00
8a19884bf3 fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)
The stash/restore cycle in the update path was observed to clobber
freshly-pulled source files (apps/desktop/ deletion -> Vite
'[UNRESOLVED_ENTRY] Cannot resolve entry module index.html'). On a
managed clone the user never edits the source tree, so any 'dirty' state
is pure git artifact (CRLF renormalization, npm lockfile churn, files
left behind when a directory was deleted upstream such as
apps/bootstrap-installer/). Stashing that and re-applying it after a pull
is fragile and unnecessary.

- hermes update (hermes_cli/main.py): on a non-fork (managed) clone,
  discard working-tree dirt via reset --hard HEAD + clean -fd instead of
  stash/apply. Forks keep the stash machinery so intentional edits
  survive. Also pin core.autocrlf=false on Windows so the dirt is never
  created (mirrors install.ps1 #38239).
- install.sh: replace the update-path stash/restore dance with a hard
  reset to origin/<branch>; the installer is a managed-only entry point.
- install.sh + install.ps1 desktop stage: prefer 'npm ci' (wipes and
  reinstalls node_modules from the lockfile) over bare 'npm install',
  which can report 'up to date' against a stale marker while node_modules
  is empty -- leaving tsc unresolved so 'npm run pack' fails.

Tests: managed clone cleans instead of stashing; fork still stashes;
existing stash tests force the stash path explicitly.
2026-06-03 16:40:13 -07:00
7ea37cd082 fix(desktop): stop validating provider keys in launch setup
The launch provider setup screen rejected too many legitimate users:
a live credential probe ("key rejected"), a post-save runtime check
("still cannot reach X"), and an 8-char minimum all gated progression.
Corporate proxies, regional blocks, rate-limited/flaky probes, and
self-hosted endpoints all tripped these. Now we just require a
non-empty value and save it; a genuinely bad key surfaces later at
chat time instead of blocking onboarding.
2026-06-03 18:39:00 -05:00
1927ff217e Merge pull request #38517 from NousResearch/bb/desktop-yolo-statusbar-toggle
feat(desktop): YOLO toggle in the status bar (per-session, TUI parity)
2026-06-03 23:33:09 +00:00
63727f32bf docs(dashboard): document connecting Hermes Desktop to a remote backend (#38534)
Desktop's readiness probe only checks GET /api/status (public), but the
live chat rides /api/ws, which is gated by --tui (4403), a matching
session token (4401), and a non-loopback bind. The web-dashboard doc
covered --tui and the OAuth gate but never the Desktop remote-connection
flow, so the three independent failure modes weren't documented together.

Adds a 'Connecting Hermes Desktop to a remote backend' section: pin
HERMES_DASHBOARD_SESSION_TOKEN, run with --host 0.0.0.0 --insecure --tui,
the curl token-verification one-liner, and WS close-code triage.
2026-06-03 16:28:01 -07:00
5c0a1fec0c fix(desktop): surface skill & quick-command slash commands in the palette (#38531)
The desktop chat app's slash curation (desktop-slash-commands.ts) only
suggested the ~19 curated built-ins. isDesktopSlashSuggestion required
membership in DESKTOP_COMMANDS, so every skill-derived command and user
quick_command was silently dropped from both completion paths
(commands.catalog empty-query + complete.slash typed-query) and from
filterDesktopCommandsCatalog — even though isDesktopSlashCommand let them
EXECUTE when typed in full. The tui_gateway backend already includes skills
in both RPCs; the gap was purely renderer-side.

Add isDesktopSlashExtensionCommand() (= not-a-known-Hermes-built-in, the
same predicate that already gates execution) and let extensions through the
suggestion path. The catalog filter routes through isDesktopSlashSuggestion,
so skill/quick-command categories and pairs are kept automatically.
2026-06-03 16:24:06 -07:00
96f0ddc6a9 fix(docker): bake hindsight-client into the image (#38128) (#38530)
The native Hindsight memory provider lazy-installs hindsight-client into
/opt/hermes/.venv at first use (tools/lazy_deps.py: memory.hindsight).
That venv lives inside the immutable image layer, not the mounted
/opt/data volume, so the dependency is wiped on every container recreate
/ image update. After an update, profile config still points at Hindsight
and the Hindsight server is healthy, but recall/retain fails with:

    ModuleNotFoundError: No module named 'hindsight_client'

The manual workaround (uv pip install hindsight-client inside the running
container) doesn't survive the next recreate, and pip-install-into-.venv
is not an officially supported durable Docker workflow.

Fix: add --extra hindsight to the image's uv sync line, same pattern as
the --extra anthropic/bedrock/azure-identity providers (#30504) and
--extra messaging (#24698) — bake the optional dependency into the build
layer so it survives container recreate. The pyproject [hindsight] pin
(hindsight-client==0.6.1) already matches tools/lazy_deps.py and uv.lock,
so this is a pure additive --extra with no lockfile churn.

Verified: 'uv sync --frozen --no-install-project --extra hindsight'
against the committed uv.lock installs hindsight-client 0.6.1 and the
module imports cleanly.

Adds a regression test (mirrors test_dockerfile_preinstalls_gateway_
messaging_dependencies) so a future Dockerfile cleanup can't silently
drop the extra.
2026-06-04 09:17:35 +10:00
51a2c07016 fix(skills): document xurl X Article ingestion 2026-06-03 15:11:57 -07:00
e223503b03 fix(packaging): modernize project.license to PEP 639 SPDX string (#38353)
* fix(packaging): modernize project.license to PEP 639 SPDX string

Drops the SetuptoolsDeprecationWarning ('project.license as a TOML table
is deprecated') emitted on every editable build under setuptools>=77 by
switching license = { text = "MIT" } to the SPDX string form plus an
explicit license-files entry. Bumps build-system requires to
setuptools>=77 so an older build backend can't reject the string form.

The warning was non-fatal (builds succeed with it) but surfaces
prominently in install.ps1 build-failure output, where it gets mistaken
for the cause of unrelated Windows build_editable crashes.

* fix(packaging): bound setuptools build requirement per supply-chain policy

Add the <83 upper bound to setuptools>=77.0 so the dep-bounds supply-chain
gate (>=floor,<next_major) passes.
2026-06-03 14:43:49 -07:00
6fff744158 Merge pull request #38465 from kshitijk4poor/portal-quick-setup-model
feat(cli): make `hermes portal` run the full quick-setup Nous flow (model picker)
2026-06-03 14:09:47 -07:00
26a57467a8 fix(cli): harden hermes portal SystemExit handling + finish model-pick doc sweep
Self-review of #38465 surfaced three real items:

1. SystemExit escape (defense): `_login_nous` raises SystemExit(130)/(1) on
   cancel/failure. The logged-out login path inside `_model_flow_nous` catches
   it, but the expired-session re-login path (main.py) only catches Exception,
   so a Ctrl-C during re-auth could propagate past `_run_portal_one_shot` and
   kill the CLI. Add SystemExit to the portal handler so all cancel/abort cases
   end with the graceful 'Setup cancelled / retry later' message.

2. Doc sweep: the model-pick step was only added to the bare-`hermes portal`
   prose. Propagate it to the surfaces describing `hermes setup --portal`
   behavior that still omitted model selection:
   - `--portal` argparse help (main.py)
   - nous-portal.md intro + the numbered 'what it does' step list (EN + zh-Hans)
   - run-hermes-with-nous-portal.md 'default model after setup --portal' line,
     which was now contradictory (there's a picker, not a forced default) (EN + zh)

3. Test coverage: add parametrized regression test asserting the portal handler
   swallows KeyboardInterrupt / EOFError / SystemExit (returns None, no escape).

Note on 'Skip (keep current)': delegating to _model_flow_nous means picking
Skip preserves the prior provider instead of force-switching to nous — this is
intentional and matches quick setup exactly; docs now say 'sets Nous as your
provider (when you pick a model)' rather than unconditionally.
2026-06-04 02:33:33 +05:30
cd188b814e feat(cli): make hermes portal run the full quick-setup Nous flow (model picker)
`hermes portal` / `hermes setup --portal` previously logged in and set
provider=nous but left the model UNSELECTED (blank -> runtime default) and
never showed a picker — unlike the first-time quick setup, which runs the
model picker.

Route `_run_portal_one_shot` through `_model_flow_nous` — the exact same
routine quick setup (`_run_first_time_quick_setup`) and `hermes model` -> Nous
use. It handles both the logged-out path (device-code OAuth, which picks a
model internally) and the logged-in path (curated Nous model picker), then
offers the Tool Gateway opt-in and sets provider=nous. Net effect: `hermes
portal` now offers a model picker every time and is a true single-command
collapse of quick setup's Nous step.

Removes the hand-rolled auth_add_command + manual provider write + separate
Tool Gateway prompt (now a single source of truth). Re-syncs the in-memory
config from disk afterward so a caller's later save_config can't clobber the
model/provider written by the login flow.

Docs (CLI help, portal_cli docstrings, nous-portal EN + zh-Hans) updated to
mention model selection. New regression test asserts `_run_portal_one_shot`
delegates to `_model_flow_nous`.

Verified live: `hermes portal` now shows the 27-model curated picker, 'Skip
(keep current)' preserves prior provider/model.
2026-06-04 02:20:31 +05:30
d4787d3e2e Merge pull request #38449 from kshitijk4poor/portal-login-alias
feat(cli): make `hermes portal` the human-readable Portal onboarding alias
2026-06-03 13:16:58 -07:00
0caa23788f fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor (#38333)
* fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor

Two fixes for the Hermes Desktop composer:

1. IME composition Enter was treated as message submission. When a Korean/
   Japanese/Chinese IME is composing text and the user presses Enter to
   finalise the preedit, handleEditorKeyDown fired submitDraft() because it
   did not check event.nativeEvent.isComposing. The assistant-ui hidden
   textarea already guards this correctly; the custom contentEditable
   handler was missing it. Added an early return when isComposing is true.

2. Viewport resize (composer expand/collapse, window resize) was disarming
   the scroll sticky-bottom anchor. When the composer grows, the thread
   viewport shrinks, the browser adjusts scrollTop down to keep content
   visible, and the onScroll handler misread this as a user scroll-up.
   Added lastClientHeightRef tracking so the disarm condition now requires
   BOTH stable scrollHeight AND stable clientHeight before treating a
   scrollTop decrease as user intent.

Fixes: random mid-message sends during IME typing; scroll jumps when the
composer resizes or the window changes size.

* fix(desktop): prevent virtualizer measurement adjustments from fighting scroll anchoring

The virtualizer's measureElement callbacks trigger scroll adjustments when
item sizes differ from estimates. These fight our ResizeObserver +
pinToBottom loop, creating visible rubber-banding (view snaps to composer
then jumps back up), even during idle.

Three changes:
1. React.memo on VirtualizedThread to stop parent re-renders cascading
2. Shared stickyBottomRef so scrollToFn can check bottom state
3. scrollToFn override: skip adjustments when user is at bottom

* fix(desktop): use stable useCallback ref instead of inline arrow for onBranchInNewChat

The inline arrow `messageId => void branchInNewChat(messageId)` created a
new function reference on every render. This cascaded through:
  desktop-controller → ChatView → Thread → useMemo([...onBranchInNewChat])
→ new messageComponents object → VirtualizedThread receives new prop
→ React.memo overridden → virtualizer recalculates → measurement
adjustments trigger scroll jumps at the 15-second useStatusSnapshot
interval.

Pass the already-useCallback'd branchInNewChat directly.

* fix(desktop): use ctrlEnter submitMode on hidden textarea + gate ResizeObserver on isRunning

Two root-cause fixes:

1. IME message splitting: The hidden ComposerPrimitive.Input textarea had
   submitMode='enter' (default), so any Enter keydown it received — even
   during IME composition — triggered form.requestSubmit(). Changed to
   submitMode='ctrlEnter' so only the contentEditable div (which correctly
   checks isComposing) handles plain-Enter submission.

2. Scroll jumps during idle: The ResizeObserver auto-follow loop was
   active even when the thread wasn't running, causing spurious
   pinToBottom calls whenever any layout shift occurred (browser reflow,
   font load, GPU cache eviction). Gated the ResizeObserver on
   thread.isRunning so auto-scroll only follows during active streaming.
   User messages still pin via useLayoutEffect, and thread.runStart still
   calls jumpToBottom.

* fix(desktop): keep chat bottom anchor stable through idle layout shifts

* fix(desktop): prevent code block shrink scroll bounce

* fix(desktop): release bottom height lock on run completion

* fix(desktop): keep streaming code blocks rendered

* fix(desktop): keep bottom anchored through final render

* fix(desktop): render streaming reasoning code blocks

* feat(desktop): add subtle streaming block animations
2026-06-03 20:14:52 +00:00
9ba7e5b1b4 fix(setup): point Portal login-failure retry hints at hermes portal
The two retry hints inside _run_portal_one_shot (shown when the OAuth login
fails) still suggested `hermes auth add nous --type oauth`. Since this path
backs both `hermes portal` and `hermes setup --portal`, point users at the
new human-readable `hermes portal` for consistency.
2026-06-04 01:40:11 +05:30
da4f407e51 feat(cli): make hermes portal the human-readable Portal onboarding alias
`hermes portal` (no subcommand) now runs the one-shot Nous Portal onboarding
— OAuth login, switch provider to Nous, offer Tool Gateway — identical to
`hermes setup --portal` and the human-readable alias for
`hermes auth add nous --type oauth` (which still works).

The prior status default moves to `hermes portal info`; `status` is kept as a
hidden back-compat alias. `open`/`tools` subcommands are unchanged.

User-facing hints and docs (status.py, conversation_loop 401 guidance,
SystemPage, README, website docs + zh-Hans) now point at `hermes portal` /
`hermes portal info`. `--manual-paste` references keep the explicit auth
command since `hermes portal` does not expose that flag.
2026-06-04 01:19:28 +05:30
39fee4f3bc test(installer): cover the post-update relaunch/install target derivation
The macOS self-update relaunches and installs over the app it derives via
resolve_hermes_desktop_app (.../Hermes.app/Contents/MacOS/Hermes ->
.../Hermes.app). That derivation is load-bearing for both the ditto
install target and the auto-relaunch (open <app>), but had no test.

Add unit coverage:
- resolve_hermes_desktop_app_finds_built_bundle: a fake built release tree
  resolves to the .app bundle on macOS (and the exe elsewhere).
- resolve_hermes_desktop_app_is_none_without_a_build: no build => None.

Verified the positive test FAILS if the .app parent-walk is wrong (e.g.
one too few .parent() hops), so it's a real guard against a regression
that would break the post-update relaunch target.

cargo test -> 17 passed.
2026-06-03 12:02:07 -07:00
d3b1e43005 fix(installer): never brick the install when a self-update swap fails
The macOS self-update bundle swap (install_macos_app_update, added in
#38296) could leave the user with NO app installed. If moving the
existing /Applications/Hermes.app aside failed, the code deleted the
running app outright and set moved_old=false; if the subsequent move of
the freshly built bundle into place then also failed, the rollback was
gated on moved_old (now false) and skipped — leaving the target deleted
with no replacement.

Extract the swap into swap_in_new_bundle() with a strict invariant: on
ANY failure path the target is left pointing at a working bundle (either
the original, rolled back, or untouched) and is never deleted with no
replacement. Also clean up the staged .hermes-update-new copy on the
failure paths instead of orphaning it.

Add unit tests covering the happy path, the rollback-on-install-failure
path, and the catastrophic both-moves-fail path. The catastrophic-path
test was verified to FAIL against the old code ("original app must NOT
be deleted on failure") and pass against the fix.
2026-06-03 12:01:31 -07:00
c349eca823 fix(packaging): ship locales/ i18n catalogs in wheel, sdist, and Nix (#38383)
* fix(packaging): ship locales/ i18n catalogs in wheel, sdist, and Nix

locales/ is a bare data dir (no __init__.py), invisible to packages.find
and package-data. Sealed installs (pip wheel, Nix store venv) dropped it,
so gateway/CLI commands rendered raw i18n keys like
gateway.reset.header_default.

- pyproject: [tool.setuptools.data-files] locales = ["locales/*.yaml"] (wheel)
- MANIFEST.in: graft locales (sdist)
- agent/i18n._locales_dir: env override -> source -> sysconfig data scheme
- nix/hermes-agent.nix: copy locales into the store + set HERMES_BUNDLED_LOCALES
  as defense-in-depth. The wheel's data-files already materialize into the
  uv2nix venv, so resolution works with no env var; the override pins the
  store path against a future uv2nix change that could drop data-files.
- tests: metadata regression, wheel + sdist build-install smoke tests, and a
  bundled-locales flake check that verifies BOTH the wrapper override and the
  env-var-less data-files path. Smoke test wired into CI.

Closes #23943, #27632, #35374.
Supersedes #23966, #27716, #30261, #33841, #35429, #35494, #35735, #36697.

* test: cap locale e2e timeout, tighten catalog count guard

The two wheel/sdist e2e tests inherit the global --timeout=30 from
addopts; a cold-CI run (isolated build env + venv create + network pip
install) can plausibly exceed it. Add @pytest.mark.timeout(300) so they
don't ride the unit-test budget and flake intermittently.

Also assert the shipped catalog count equals len(SUPPORTED_LANGUAGES)
instead of a hardcoded >=16 floor, so the guard self-updates and trips
on a single dropped catalog (not just a fully-empty graft).
2026-06-03 12:00:27 -07:00
b91c382035 Merge pull request #38393 from NousResearch/bb/desktop-session-fixes
fix(desktop): persist pins, reconnect after sleep, dedupe session search
2026-06-03 13:22:34 -05:00
1b89715e15 fix(desktop): guard reconnect sockets and keep branch search precise
Avoid stale WebSocket events from an old reconnect attempt flipping the gateway state after a newer socket opens. Also limit session-search dedupe to compression edges so branch-specific hits still open the branch instead of collapsing to the parent.
2026-06-03 13:13:21 -05:00
93228d5299 fix(desktop): persist pins, reconnect after sleep, dedupe session search
Four related desktop session-management bugs:

- Pins lost until refresh: pinned sessions are joined against the
  paginated in-memory session list, so a pinned chat that aged off the
  most-recent page got evicted on the next refresh (every message.complete
  triggers one) and the Pinned section went empty. mergeWorkingSessions ->
  mergeSessionPage now also preserves pinned rows (matched by live id or
  lineage root). Pin id checks in the chat header, command center, and
  delete/archive are normalized to the durable sessionPinId so pins survive
  auto-compression.

- Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer
  WebSocket; nothing reconnected on wake so the composer stayed disabled.
  The gateway boot hook now auto-reconnects with backoff on close/error and
  on wake signals (powerMonitor resume/unlock-screen IPC, window online,
  visibilitychange). connect() gains an open timeout so a hung reconnect
  can't deadlock in 'connecting'. Composer placeholder distinguishes
  "Reconnecting to Hermes" from a cold start.

- Loses chats from itself: the same hard-replace that dropped pins also
  dropped loaded sessions; mergeSessionPage keeps them.

- Multiple copies/branches in search: /api/sessions/search deduped only by
  raw session_id, so compression segments and branches surfaced as separate
  hits. It now dedupes by lineage root and returns the live compression tip,
  matching the session_search tool's behavior.
2026-06-03 12:39:31 -05:00
b4b9a93848 Merge pull request #38384 from NousResearch/bb/fix-installer-emit-log-logstream
fix(installer): restore main build — pass LogStream to emit_log calls from #38296
2026-06-03 12:29:11 -05:00
1971b10526 fix(installer): pass LogStream to emit_log calls from #38296
PR #38296 added four emit_log() calls using the old 3-arg signature, but
main had already changed emit_log to take a `stream: LogStream` argument
(#38312, "stop mislabeling stdout-style progress as stderr"). The two PRs
touched different lines, so the merge auto-resolved with no conflict and
left main unable to compile the bootstrap installer (E0061: 4 args expected,
3 supplied).

Supply the missing stream: Stdout for the update/install progress lines and
Stderr for the "could not auto-launch desktop" failure, matching the
convention from #38312. cargo check passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:28:28 -05:00
84710995ef Merge pull request #38312 from NousResearch/bb/installer-stderr-log-label
fix(installer): stop mislabeling stdout-style progress as stderr
2026-06-03 12:17:35 -05:00
9632609447 Merge pull request #38296 from NousResearch/bb/fix-dmg-update-relaunch
fix(desktop): self-update rebuilds and relaunches cleanly on macOS
2026-06-03 12:06:30 -05:00