Commit graph

169 commits

Author SHA1 Message Date
brooklyn!
14fee4f112
fix(update/windows): retry handoff hermes update once on first-run crash (#39831)
The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily
imports the freshly-pulled modules — but the dependency-install step runs the
already-in-memory PRE-pull code for one invocation. When a release changes an
updater-path contract across that boundary, the FIRST update on the parked
population crashes even though the fix is already on disk.

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

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

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

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

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

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

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

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

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

* review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill
2026-06-05 08:33:53 -05:00
brooklyn!
ca8c78e588
fix(desktop): heal stale runtime-id cache + model on profile switch (#39819)
Two switch-time regressions from the multi-profile rail work:

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

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

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

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

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

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

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

Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
2026-06-05 12:14:18 +00:00
Teknium
72eb42d9ec
feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean"

This reverts commit c8e80cd0bf.

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

This reverts commit 8a19884bf3.

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-06-05 17:30:10 +05:30
Ben
439f53cab8 fix(desktop): gate OAuth remote connect on AT-or-RT, not access token alone
The desktop OAuth remote-gateway path gated connectivity on
hasOauthSessionCookie(), which checks only the access-token cookie
(hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age
lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend()
and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing
a full IDP re-login every ~15 min — even though a valid 24h refresh-token
cookie (hermes_session_rt) was sitting in the same jar.

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

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

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

Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/)
are comment/docstring-only here, but this touches outside apps/desktop/ so
it needs Teknium review.
2026-06-04 22:18:46 -07:00
Ben
46c16b9288 fix(deps): bump react-router-dom to 7.17.0 (GHSA-8x6r-g9mw-2r78)
Clears the npm-audit React Router advisory CVE-2026-42342 in the web
and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0
(patched in 7.15.0; both react-router and react-router-dom now resolve
to 7.17.0 in the root lockfile).

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

npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
2026-06-04 21:30:23 -07:00
ethernet
7f016f5f33 change(desktop): show up to 50 models in list per provider by default 2026-06-05 00:00:19 -04:00
shannonsands
ab706a3346
Clear stale desktop onboarding errors (#38844) 2026-06-04 22:59:55 -05:00
teknium1
bcb024ad48 fix(desktop): fail remote test when OAuth ws-ticket mint fails
Youssef's review caught a residual false-positive: resolveTestWsUrl
swallowed an OAuth ticket-mint failure and returned null, so the caller
skipped the WS probe and reported the remote test as reachable. But the
real boot path (resolveRemoteBackend) treats a mint failure as a hard
'session expired' auth error and refuses to connect — so an expired OAuth
session passed the test then failed boot, the exact false-positive this
PR exists to kill.

Extract resolveTestWsUrl into the electron-free connection-config.cjs
(injectable mintTicket) so it's unit-testable, and make OAuth mint
failure throw an actionable needsOauthLogin error instead of skipping.
Adds the three cases Youssef requested plus a mintTicket-required guard.
2026-06-04 19:49:06 -07:00
xxxigm
500cf537b7 fix(desktop): validate live WebSocket in remote gateway connection test
The "Test remote" button only checked HTTP GET /api/status, but the chat
surface depends on the renderer opening a live WebSocket to /api/ws — a
separate transport with separate server-side guards (Host/Origin checks,
ws-ticket/token auth, peer-IP checks). A gateway could pass the HTTP check yet
reject the WebSocket, so the test reported "reachable" while boot still failed
with the opaque "Could not connect to Hermes gateway".

testDesktopConnectionConfig now mirrors the renderer's connect: after the
status check it opens the WS URL (token/local) or a freshly minted ws-ticket
(OAuth) and confirms the upgrade is accepted and not immediately torn down by
a post-handshake auth rejection. Failures surface an actionable message instead
of a false-positive. The WS leg is skipped when the runtime lacks a global
WebSocket so it never fails spuriously.
2026-06-04 19:49:06 -07:00
xxxigm
10c78bf625 test(desktop): add injectable gateway WebSocket probe + unit tests
Adds electron/gateway-ws-probe.cjs: a small helper that opens a gateway
WebSocket URL and classifies the handshake (open/frame → ok; error or close
before open → fail; open-then-early-close → credential rejected; never-opens →
timeout). The WebSocket implementation is injected so it can be unit-tested
without a real socket.

Wires gateway-ws-probe.test.cjs into test:desktop:platforms, covering every
handshake outcome plus constructor-throw and missing-impl.
2026-06-04 19:49:06 -07:00
Teknium
9cc47b20cb
feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483)
The first-run provider picker was a hard gate — the only way out was
connecting a provider. Add an 'I'll choose a provider later' link that
dismisses the overlay and persists the skip to localStorage so it never
re-nags on subsequent launches. Users connect a provider any time from
Settings -> Providers (manual onboarding already bypasses the skip gate).

- onboarding.ts: firstRunSkipped state seeded from localStorage
  (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action;
  completeDesktopOnboarding clears the flag once a provider connects.
- overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink
  rendered in both the OAuth picker footer and the API-key fallback, first-run only.
- tests: skip persists + hidden in manual mode; full-state fixtures updated.
2026-06-04 19:40:54 -07:00
Brooklyn Nicholson
7b4acadfe7 feat(desktop): per-profile "+" to start a session in the all-profiles view
Mirror the workspace-group "+": each profile header in the all-profiles
session list gets a new-session button. Unlike selecting the profile, it
leaves the browse scope untouched (newSessionInProfile keeps
$showAllProfiles), so creating a chat doesn't collapse the unified view.
2026-06-04 20:44:22 -05:00
Brooklyn Nicholson
4891f9ae78 feat(desktop): concurrent multi-profile gateway sockets
Keep one persistent socket per profile with live work instead of closing
the single socket on every profile swap, so background sessions across
profiles keep streaming at once. A gateway registry owns the primary
(window) socket plus lazy secondaries (own backoff/reconnect); all feed
the same session-keyed event handler. Secondaries are pruned to profiles
with a working/needs-input session, the keepalive pings every open
backend, and LRU eviction spares freshly-touched backends so the soft cap
can't abort a running agent. Approval/sudo/secret prompts are parked
per-session (surfaced via the needs-input badge) so a background turn can
block without hijacking the foreground. Single-profile users only ever
have the primary, so their path is unchanged.
2026-06-04 20:44:19 -05:00
Brooklyn Nicholson
89baf02919 Merge origin/main into bb/desktop-profile-support
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's
ListRow + actions-menu refactors for credential rows; keep our profileColor
import on the sidebar. Drop the now-orphaned Tip-based helpers.
2026-06-04 20:17:07 -05:00
Brooklyn Nicholson
1b01fa3acf feat(desktop): long-press a rail profile to pick its color
Hold (~450ms) a profile square — or right-click → Color… — to open a
shadcn Popover of swatches and override its rail color, with Auto to fall
back to the deterministic hue. The hold timer rides alongside the dnd
pointer listener (a real drag cancels it, the trailing click is
suppressed), so reorder/select/recolor stay distinct gestures.

Overrides persist in localStorage ($profileColors), resolved via
resolveProfileColor (override wins, else the name-hashed hue). Cosmetic
and gated on the multi-profile rail, so single-profile users are
unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
2026-06-04 20:12:37 -05:00
Brooklyn Nicholson
86371e6cd8 style(desktop): drop border + radius from the profile-swap overlay 2026-06-04 20:12:37 -05:00
ethernet
80672754a8 fix(docs): update all install instructions everywhere 2026-06-04 21:07:45 -04:00
asill-livestream
ea44011d15 fix(desktop): prevent thinking block from closing mid-streaming
When reasoning text grows during streaming, new parts can be appended
beyond endIndex.  The pending check used slice(startIndex, endIndex)
which excluded these new parts — if the original part completed, the
block would close while new reasoning was still streaming.

Fix: remove the endIndex cap from slice() so all parts from startIndex
onward are checked.  During non-streaming, the array is stable and
all parts are within range anyway.
2026-06-04 21:05:45 -04:00
Brooklyn Nicholson
f764b0400a fix(desktop): deleting the active profile reliably falls back to default
Centralize the fallback in DeleteProfileDialog (the single delete choke
point) so both the rail and the Profiles view inherit it. Reset *after*
the host's onDeleted refresh so a refreshActiveProfile racing the dying
backend can't clobber the pill back to the deleted profile, and set
$activeProfile too (selectProfile only moved the gateway, leaving the
statusbar pill stranded on the dead profile).
2026-06-04 19:49:11 -05:00
Brooklyn Nicholson
9dbd3c57d7 feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.

Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
2026-06-04 19:41:51 -05:00
Ben Barclay
c54b935873
fix(desktop): rename session via session.title RPC so /title works (#39410)
The desktop `/title <name>` command 404s with "Session not found" on
every platform (reported on Windows in #38508).

Root cause: `session.create` returns two distinct ids — a *runtime*
session id (held in `activeSessionIdRef`) and a `stored_session_id` (the
DB `sessions.id`) — and deliberately does NOT persist a DB row until the
first turn. Routing `/title` through the REST `PATCH /api/sessions/{id}`
endpoint (as #38576 proposed) resolves the id against the `sessions`
table, so the runtime id — or any brand-new, not-yet-persisted session —
never resolves and returns 404. This is an id-type mismatch, not a
Windows file-locking quirk, so it fails on macOS and Linux too.

Fix: route `/title <name>` through the gateway's `session.title` RPC —
the exact path the TUI already uses (`ui-tui/.../slash/commands/core.ts`).
The RPC maps the runtime id to the in-memory session, writes through the
gateway's own DB connection, and queues the title (`pending: true`) when
the row isn't persisted yet, so it works for a fresh chat. The sidebar is
then refreshed via the existing `refreshSessions()` plumbing.

Keeps the sidebar-refresh wiring and `refreshSessions` threading from
#38576; replaces only the broken REST/slash-worker write path. A bare
`/title` (no arg) still falls through to the worker to show the current
title.

Tests rewritten to assert `session.title` routing with the runtime-vs-
stored id distinction (which the original mock collapsed), plus the
queued/`pending` fresh-chat case and the error path.

Supersedes #38576. Fixes #38508.

Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
2026-06-04 19:32:24 -05:00
Teknium
2c98dc0a96
fix(desktop): offer remote sign-in on a gated-gateway boot failure (#39402)
When a remote gateway with username/password (or OAuth) auth restarts, its
session cookie lapses and Desktop boots into the recovery overlay with a
session-expired error. That overlay only exposed local-recovery actions —
Retry (resets the local bootstrap latch) and Repair (re-runs the installer) —
neither of which can re-establish a remote session, so the user is stuck in a
no-op Retry loop with no way to sign in again.

The overlay now detects a remote-reauth boot failure from the saved connection
config (remote + gated + not currently connected + has a URL) and surfaces a
primary 'Sign in to remote gateway' button that opens the gateway login window
(the username/password form for a basic gateway, the OAuth redirect otherwise)
and reloads on success. Button copy is driven by a best-effort provider probe,
matching the gateway-settings page. Detection and copy logic live in a pure
helper module with unit coverage.
2026-06-04 17:28:29 -07:00
Brooklyn Nicholson
a40e20e136 feat(desktop): profile rail rename/delete + context-switch polish
- right-click a profile square to rename or delete it, via shared
  self-contained dialogs (also reused by the profiles page)
- switching or creating a profile now resets to a fresh new-session
  draft so the prior session doesn't stay sticky across contexts
- deleting the profile you're currently in falls back to default
  instead of stranding the gateway on a dead profile
- shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog;
  profile-delete and cron-delete both route through it
2026-06-04 18:24:39 -05:00
Brooklyn Nicholson
cf9dc366dd refactor(desktop): drop per-session icons, read-only cross-profile reads
The per-session icon picker added more noise than value — rip it out end
to end (sessions.icon column, set_session_icon, the PATCH field, the
picker UI, and the SessionInfo.icon type).

The cross-profile session aggregator now opens each profile's state.db
read-only (mode=ro, no schema init), so listing other profiles on every
sidebar refresh never DDLs or takes a write lock on their live DBs. The
single-profile hot path stays on par with /api/sessions.
2026-06-04 18:24:35 -05:00
Austin Pickett
dfd6bcf1ff
fix(desktop): restore accordion expand for credential settings rows (#39327)
* fix(desktop): restore accordion expand for credential settings rows

Reintroduce collapsible provider and tool key rows so descriptions, docs
links, and advanced fields stay hidden until a row is expanded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(desktop): add credential settings accordion screenshots for PR 39327

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:10:44 -04:00
Brooklyn Nicholson
48d8d80771 feat(desktop): single-profile rail shows default icon + create
Left-align the default's home icon next to the create "+" in the
single-profile state (toggle/squares/Manage still appear only once a
second profile exists).
2026-06-04 17:40:35 -05:00
Brooklyn Nicholson
0c7def31aa feat(desktop): show "+" in the rail for single-profile users
Always mount the profile rail, but when only the default profile exists
render just the create-profile "+" (hide the default/all toggle, the
draggable squares, and Manage). Gives a first-profile affordance without
the full switcher chrome; everything else appears once a 2nd profile exists.
2026-06-04 17:38:10 -05:00
Brooklyn Nicholson
76b98f43ca fix(desktop): gate ALL-profiles grouping on multiProfile
If a user drops back to a single profile while scope is still ALL
(persisted), the rail is hidden — they'd be stuck in the grouped view
with no toggle out. Fall back to the scoped view when only one profile.
2026-06-04 17:35:34 -05:00
Brooklyn Nicholson
fb18bde897 feat(desktop): fluid, haptic profile-rail reordering
- Wheel maps vertical scroll → horizontal so the rail is navigable with a
  plain mouse (trackpad x-scroll still passes through).
- Springy easeOutBack reflow; dragged square glides between snapped cells
  (no scale — overflow-x strip would clip it) with a subtle lift.
- Haptic 'selection' tick per crossed cell + 'success' on a committed reorder.
2026-06-04 17:33:44 -05:00
Brooklyn Nicholson
9915665e4c fix(desktop): step profile-rail drags cell-by-cell, clamp to strip
Snap the drag transform to whole cells (no free glide) and clamp it to the
occupied squares strip via a relative wrapper as offsetParent, so a square
can't float past the last profile onto the "+" and break the layout.
2026-06-04 17:10:05 -05:00
Brooklyn Nicholson
3e4fa8ca9c fix(desktop): lock profile-rail drag to the x-axis
overflow-x-auto makes overflow-y compute to auto, so a vertical drag
translate faulted in a cross-axis scrollbar. Pin the drag transform to
y:0 with a modifier — squares only slide horizontally now.
2026-06-04 17:07:48 -05:00
Brooklyn Nicholson
cfbc47d893 feat(desktop): open command palette with Cmd/Ctrl+P too
Bind Cmd/Ctrl+P to the command palette alongside Cmd+K (VS Code quick-open
muscle memory); Cmd+. stays the command center. No Print accelerator
competes, so the renderer preventDefault is enough.
2026-06-04 17:01:44 -05:00
Brooklyn Nicholson
e0121c59d3 feat(desktop): drag-sort profiles in the rail
Make the named-profile squares reorderable via dnd-kit (horizontal sort,
4px activation so a tap still selects). Order persists in localStorage
($profileOrder); unordered/new profiles alphabetize at the tail.
2026-06-04 16:59:39 -05:00
helix4u
d29caf3828 fix(desktop): satisfy slash metadata typecheck 2026-06-04 17:56:36 -04:00
Brooklyn Nicholson
5df732a355 feat(desktop): quick-create profile from rail + pin rail on empty sidebar
- Add a "+" in the profile rail that opens a self-contained CreateProfileDialog
  (name + clone toggle + optional SOUL.md); extract it and ActionStatus from
  the profiles view so both surfaces share one flow.
- Keep the profile rail pinned to the bottom when a profile has no sessions by
  rendering a flex-1 spacer (previously the rail floated up to the nav).
2026-06-04 16:55:16 -05:00
Brooklyn Nicholson
b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
2026-06-04 16:35:34 -05:00
ethernet
1eeb7da2e6
fix(desktop): slash commands bypass queue when busy and chip id suffix leak (#39289)
Two fixes for desktop app slash command handling:

1. Slash commands submitted while the agent is busy now execute
   immediately instead of being queued. Previously submitDraft()
   unconditionally queued any draft when busy, but slash commands
   are client-side operations or self-contained gateway RPCs that
   should run regardless of busy state (matching TUI behavior).
   executeSlashCommand already has its own per-command busy guard
   for commands that genuinely need an idle session.

2. Slash command trigger items no longer leak the "|index" suffix
   from their item.id into the serialized chip text. The
   toItem callback now sets rawText in metadata so
   hermesDirectiveFormatter.serialize takes the direct-insertion
   path instead of the legacy @type:id fallback. This also means
   slash commands enter the composer as plain text (not chips),
   matching selectSkinSlashCommand and TUI behavior.
2026-06-04 16:06:45 -05:00
Austin Pickett
acce1a2452
feat(desktop): polish credentials settings and messaging env routing (#39217)
* feat(desktop): polish credentials settings and messaging env routing

Align Provider API Keys and Tools & Keys with Advanced ListRow inputs,
add Tools & Keys sidebar subnav, move platform env vars to Messaging via
channel_managed discovery, strip toolset emojis, and condense cron actions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): align Messaging credential inputs with settings ListRow style

Remove monospace inputs and use CREDENTIAL_CONTROL_CLASS + ListRow layout
to match Provider API Keys and Tools & Keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:01:15 -04:00
Teknium
580d924097 perf(desktop): make session-id search SQL-bounded, not O(n)
search_sessions_by_id previously fetched up to 10k sessions via
list_sessions_rich and filtered them in Python — O(n) per keystroke.
Push the id match into SQL instead.

- list_sessions_rich gains an optional id_query param: a case-insensitive
  LIKE pushed into the outer WHERE, matched against each surfaced row's id
  AND every id in its forward compression chain (via the existing chain
  CTE). Searching a compression root id or a tip id both resolve to the
  same projected conversation. LIKE wildcards in the needle are escaped.
- search_sessions_by_id now fetches only matching rows (limit*4) and ranks
  exact > prefix > substring in Python over that small set.
- web_server /api/sessions/search: route ID matches and content matches
  through one lineage-keyed dedup helper so an id-hit and a content-hit on
  the same conversation collapse to a single result (the contributor's
  version keyed ID hits by raw sid and content hits by root, which could
  double-list a compression tip).
- command-center haystack also matches _lineage_root_id for parity.

E2E verified against a real DB: exact match over 3000+ sessions
materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip;
LIKE-wildcard escaping holds.

Follow-up to @0xharryriddle's feat(desktop): search sessions by id.
2026-06-04 07:49:34 -07:00
Harry Riddle
9ecc331be8 feat(desktop): search sessions by id 2026-06-04 07:49:34 -07:00
AhmetArif0
6feb40e702 fix(desktop): wait for backend exit before reloading on connection-config apply
The apply handler sent SIGTERM then fired a 150 ms setTimeout to reload
the renderer. If the backend took longer to shut down the port was still
bound when startHermes() ran after reload, causing an "address already
in use" failure.

Capture the process reference before resetHermesConnection() nulls it,
then await the actual exit event. A 5 s SIGKILL fallback ensures the
wait never hangs if the backend ignores SIGTERM.
2026-06-04 07:23:22 -07:00
Harry Riddle
f583c6ebd5 fix(desktop): recover from corrupt cached Electron download on build
hermes desktop failed on Linux with an ENOENT renaming
release/linux-unpacked/electron -> Hermes. Root cause is a corrupt
cached Electron zip (~/.cache/electron/electron-*.zip): app-builder
unpack-electron extracts a partial tree from the bad zip that is
missing the electron binary, so electron-builder dies on the final
rename. Re-running repeats the broken extraction, leaving the desktop
app permanently unlaunchable until the cache is manually purged.

- Add _electron_download_cache_dirs() + _purge_corrupt_electron_cache()
  to hermes_cli/main.py: validate every electron-*.zip via
  zipfile.testzip() and delete corrupt ones; honor electron_config_cache
  / ELECTRON_CACHE overrides with per-OS defaults.
- Wire purge + single retry into cmd_gui packaged-build failure path so
  a poisoned download self-heals (electron re-downloads clean).
- Add beforePack hook (apps/desktop/scripts/before-pack.cjs) to wipe the
  target unpacked dir before staging, making packaging idempotent across
  interrupted runs. Cross-platform, best-effort.
- Tests: corrupt-zip detector, cmd_gui purge/retry/launch path,
  no-retry-when-clean path, and node --test for the cleanup helper.
2026-06-04 07:17:33 -07:00
brooklyn!
e003c53b06
chore(desktop): zero eslint/typecheck debt + prettier pass (#39100)
- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding)
- flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg
- add setMutableRef helper for imperative ref writes (react-compiler clean)
- move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
2026-06-04 14:10:38 +00:00
scubamount
b6206020d3 fix(desktop): remove session search aux model 2026-06-04 05:46:13 -07:00
Ben
cae6b5486f feat(dashboard): always enable embedded chat; remove dashboard --tui flag
The dashboard's embedded Chat surface (/chat, /api/ws, /api/pty) was gated
behind `hermes dashboard --tui` / HERMES_DASHBOARD_TUI=1. The desktop app and
the dashboard's own Chat tab both drive the agent over the /api/ws + /api/pty
WebSockets, so a dashboard started without the flag would pass the /api/status
health check but slam the chat WebSocket shut with WS code 4403 — the app
connects, reports "ready", and chat stays dead. This was the root cause behind
multiple user reports of the desktop app failing to connect to a self-hosted
gateway/dashboard, and it bit Docker and host installs alike.

Make the embedded chat unconditional:

- web_server.py: _DASHBOARD_EMBEDDED_CHAT_ENABLED defaults to True; drop the
  embedded_chat parameter and the runtime reassignment from start_server().
  The WS gates still read the constant (now always true) so the seam — and its
  "rejects when disabled" contract test — stays meaningful.
- main.py: remove the `--tui` argument from the dashboard subparser and the
  `embedded_chat = args.tui or HERMES_DASHBOARD_TUI==1` derivation.
- web/: isDashboardEmbeddedChatEnabled() returns true unconditionally; drop the
  deprecated __HERMES_DASHBOARD_TUI__ alias and the dead LEGACY_TUI_RE scrape in
  the vite dev-token plugin.
- apps/desktop/electron/main.cjs: drop `--tui` from the spawned dashboardArgs
  (it would now error with "unrecognized arguments: --tui") and the redundant
  HERMES_DASHBOARD_TUI env injection.
- Docker: no s6 run-script change needed — the script never passed --tui; the
  HERMES_DASHBOARD_TUI env var is now simply a no-op, so the image works out of
  the box with no extra var.
- Docs: remove every dashboard --tui / HERMES_DASHBOARD_TUI reference across the
  CLI reference, env-var reference, docker/desktop/web-dashboard guides, in-app
  tips, and the zh-Hans translations. The terminal `hermes --tui` / HERMES_TUI
  references are intentionally left untouched.

Tests: 270 passing across web_server, dashboard lifecycle, host-header,
auth-gate, and docker-override-scripts suites.
2026-06-04 03:03:35 -07:00
Teknium
b1b0f4b668
fix(desktop): surface command approval even when its tool is in a collapsed group (#38829)
The desktop command-approval ApprovalBar renders inline inside ToolEntry,
which lives inside ToolGroupSlot. When 2+ tools group, the group body is
hidden until expanded, so an approval raised by a pending terminal/
execute_code call was buried behind "Tool actions · N steps" and required
manual expansion to act on (sudo/secret were unaffected — they use modal
overlays).

ToolGroupSlot now subscribes to $approvalRequest and force-opens its body
while an approval targeting one of its pending approval-eligible tools is in
flight, so the inline controls surface with nothing expanded. The group
reverts to the user's stored collapse state once the approval resolves.
2026-06-04 02:29:46 -07:00
Teknium
0175be3aa7
chore(desktop): silence Vite chunk-size warning for intentional single bundle (#38888)
The desktop renderer is bundled as one chunk on purpose (codeSplitting:
false) because Shiki's many dynamic chunks make electron-builder OOM
scanning thousands of files. That makes the ~22 MB bundle expected, but
Vite still nags with 'Some chunks are larger than 500 kB' on every build.

Raise chunkSizeWarningLimit to 25000 kB so the cosmetic warning stays
quiet while still firing as a regression alarm if the bundle grows well
past today's size. Config-only; codeSplitting:false is untouched.
2026-06-04 02:28:57 -07:00
Teknium
928f1ac0e1
fix(desktop): re-mint OAuth WS ticket on gateway reconnect (#38886)
attemptReconnect() connected with the stale cached conn.wsUrl. OAuth WS
tickets are single-use with a ~30s TTL, so the first sign-in (which goes
through boot() and re-mints via resolveGatewayWsUrl) succeeds, but every
reconnect (sleep/wake, network online, window refocus, socket drop, app
restart) reused a dead ticket and failed the WS upgrade with an opaque
"Could not connect to Hermes gateway" — even though backend resolution
(cookie + REST) reported ready.

attemptReconnect now mints a fresh ticket before connecting, mirroring
use-gateway-request.ts, and surfaces the reauth "sign in again" message
once on OAuth expiry instead of silently looping backoff against a dead
ticket. Local/token gateways are unaffected (re-mint is a no-op).
2026-06-04 02:28:43 -07:00