cff7b365d2cdeeba3513e48fb900897bcf54bf44
52
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bddc5fd087 |
fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing (#41729)
A packaged desktop app launches to a blank page with a bare ERR_FILE_NOT_FOUND when dist/index.html isn't in the bundle (#39484). This happens when the build step fails (e.g. a stale checkout that fails typecheck) but electron-builder packages anyway, shipping an empty dist/. - build-time: scripts/assert-dist-built.cjs runs at the tail of the `build` script and aborts before electron-builder if dist/index.html or the vite JS bundle is missing/empty. Every packaging path (pack, dist*) inherits it via `npm run build &&`. - runtime: resolveRendererIndex() now logs a clear 'packaged without a renderer bundle — rebuild with hermes desktop --force-build' message when no index.html exists, instead of silently loading a missing path. - runtime: resolveWebDist() logs when it falls back to an asar-internal dist that isn't a real directory (the dashboard 404 class, #41327/#39472), rather than returning an unservable path silently. Adds scripts/assert-dist-built.test.cjs (node:test) covering the guard. |
||
|
|
b5a457c033 |
fix(desktop): persist zoom level via renderer localStorage (#41747)
Desktop zoom shortcuts (Cmd/Ctrl +/-/0) and the View menu only called webContents.setZoomLevel(), which mutates the live renderer but persists nothing. On reload, renderer crash/restart, or page recreation the app snapped back to the default zoom, so the shortcuts felt broken for users who need larger text. Persist the selected zoom in the renderer's own localStorage rather than a main-process JSON file. localStorage is per-origin and survives the renderer lifecycle automatically, so there's no atomic-write/userData file machinery to maintain. The main process still owns setZoomLevel: every zoom change is mirrored into localStorage via executeJavaScript, and the value is read back and re-applied on did-finish-load (covering reloads and crash recovery). Clamping to Electron's [-9, 9] range now happens once in setAndPersistZoomLevel instead of at each call site. |
||
|
|
d65b513f23 |
feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670)
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay
When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.
- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
render an edge hot-zone + a side-anchored floating panel (absolute, full
height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
visible + interactive while the overlay is revealed, via an
in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.
* feat(desktop): lower window minWidth 900→400
Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.
* fix(desktop): render full sidebar content in hover-reveal overlay
The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.
Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.
* fix(desktop): drop shadow on hover-reveal sidebar overlay
* feat(desktop): hover-reveal the file-browser sidebar too
The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.
* clean(desktop): tighten hover-reveal pane code
KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.
* fix(desktop): stop hiding sidebar nav labels on narrow windows
The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.
* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal
Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).
desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.
* feat(desktop): hover-intent + refined easing for sidebar reveal
- Gate the reveal on pointer velocity: the full-height edge hot-zone now
only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
the titlebar/statusbar — or off the window — blow past the threshold and
never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
soft-land) for a more serious-app feel.
* fix(desktop): don't reveal sidebar during window resize
Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.
* feat(desktop): hoverIntent-style poll gate + inert contents during slide
Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.
Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.
* fix(desktop): no cursor/hit-test leak before reveal settles
The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.
* fix(desktop): geometry-driven close so revealed panel always retracts
The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.
Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.
* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed
mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.
* refactor(desktop): name the 600px sidebar collapse breakpoint
Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.
* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px
768 is the standard md breakpoint and a more honest 'no room to dock' point.
* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms
* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"
This reverts commit
|
||
|
|
cadb74adad |
fix(desktop): recover chat after sleep/wake by revalidating a stale remote backend
After sleep/wake, a remote (global-remote) primary backend can become unreachable, but it has no child process whose 'exit' clears the main process's cached connectionPromise. The renderer then re-dials the same dead remote forever and the composer stays stuck on "Starting Hermes…"; only a quit+reopen recovered. Fix: the renderer's existing backoff-paced reconnect loop now asks the main process to revalidate the cached connection before re-dialing. The main process liveness-probes the cached REMOTE backend's public /api/status and, if unreachable, drops the cache (resetHermesConnection only nulls connectionPromise for a remote — no child to SIGTERM) so the next getConnection() rebuilds a reachable descriptor. Local backends are never touched here; they self-heal via the child 'exit' handler. The renderer's loop already provides retry pacing and rides out transient blips, so no streak/episode bookkeeping is needed in the main process. The boot hook dismisses the boot-progress overlay on the post-rebuild 'open' so an in-place rebuild can't leave it stuck at ~94%. Reimplements #40135 by @AlchemistChaos on a smaller, more interpretable path (63 added lines vs 555): no extracted helper module, no failure-streak / episode-window state, the renderer's backoff loop is the retry mechanism. Original diagnosis and fix by @AlchemistChaos. Co-authored-by: AlchemistChaos <alchemistchaos@protonmail.com> |
||
|
|
02aad08acf |
fix(desktop): bootstrap falls back to installed agent install.sh on GitHub 404
Packaged Desktop first-launch bootstrap no longer dies with a fatal HTTP 404 when install-stamp.json pins a commit that isn't fetchable from GitHub. This only happens for locally-built desktop apps: write-build-stamp.cjs's fromLocalGit() pins `git rev-parse HEAD`, which can be an unpushed commit or dirty tree. CI builds stamp $GITHUB_SHA and are unaffected. The fix unblocks the dev / self-builder workflow. resolveInstallScript() now wraps the GitHub download in try/catch; on failure it resolves ~/.hermes/hermes-agent/scripts/install.sh (the already-installed agent checkout), copies it into bootstrap-cache, and returns it as source 'installed-agent'. If the cache copy fails (read-only FS), it uses the source path directly. With no installed checkout to fall back to, the original error rethrows unchanged. Download is now injectable via an optional _download param so the fallback path is tested hermetically (no network). Reported with a precise repro and suggested fix by @Tamaz-sujashvili (#40815). Co-authored-by: Tamaz-sujashvili <56168197+Tamaz-sujashvili@users.noreply.github.com> |
||
|
|
846821d8c0 |
Merge pull request #40684 from NousResearch/bb/cron-sessions-sidebar
feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler |
||
|
|
210f4e706a |
fix(desktop): resolve powershell.exe by absolute path in Electron bootstrap
Mirror the bootstrap-installer (Rust) fix in the Electron first-launch runner. spawnPowerShell launched bare 'powershell.exe', trusting PATH to contain %SystemRoot%\System32\WindowsPowerShell\v1.0 — the same latent weakness that stalled the native installer at "0 of 0 steps" when PATH is trimmed/truncated or stored as a non-expanding REG_SZ. Resolve by absolute path first (%SystemRoot%/%windir%), then PATH (powershell 5.1 -> pwsh 7), then bare name as last resort. |
||
|
|
5b43bf7d02 |
feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)
* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) Adds a GUI-only uninstall path so people can remove the desktop Chat GUI while keeping the Hermes agent + their config/sessions/.env, and surfaces the three CLI uninstall modes inside the desktop app's Settings → About. CLI: - New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the desktop GUI's artifacts (source-built dist/release/node_modules + build stamp, the packaged app bundle, and the Electron userData dir) on Linux, macOS, and Windows. Never touches the agent source, venv, or user data. - `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a JSON install snapshot (used by the desktop UI to gate options + detect a missing agent for a future lite client). - `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing the destructive sequence via a new _perform_uninstall() helper. The keep-data and full flows also sweep the GUI artifacts. Desktop: - electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full) to CLI flags, resolving the running app bundle per OS, and building the detached cleanup script that waits for the app to exit, runs the Python uninstall, and removes the bundle. - IPC hermes:uninstall:summary / :run, preload bridge, and types. - Settings → About "Danger zone" with the three options; agent-removing options hide when no local agent is detected. Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md. * fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety) The desktop uninstall cleanup script waited only on the desktop app's own PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can outlive it and keep hermes.exe + venv files mandatory-locked on Windows — making the script's rmdir half-fail and leaving a partial install, the same failure class as the self-update path's #37532. - main.cjs: runDesktopUninstall now awaits releaseBackendLock() before spawning the cleanup script — tree-kills every backend PID the desktop owns (primary + pool) via taskkill /T /F and polls the venv shim until unlocked. Extracted the shared core out of releaseBackendLockForUpdate so both the update hand-off and the uninstaller use the identical, incident-hardened teardown. No-op on macOS/Linux (no mandatory locks). - desktop-uninstall.cjs: Windows cleanup script removes the bundle via a bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows releases directory handles lazily even after the holding process exits. - Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side tree-kill-by-PID is the reliable mechanism. Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass). * fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop) Resolves @OutThisLife's review on #40355: 1. full mode now gated on agent presence (needsAgent: true). It removes the agent + user data, so on a lite client with no local agent it's hidden like lite — no more offering to remove an agent that isn't there. 2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the venv's OWN python. On Windows a running python.exe is mandatory-locked, so that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X' entrypoint (stdlib-only imports) lets the desktop run agent-removing modes under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so import hermes_cli resolves from source while the venv is torn down. Falls back to venv python + logs when no system python (gui-only unaffected). 3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the PID as a whole space-delimited token via findstr (no substring 99->990 trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted. 4. Renamed the misleading 'returns null for dev run' test — the dev-run safety is shouldRemoveAppBundle(isPackaged=false), which the test now asserts. Docs: note that --gui on a source checkout also sweeps node_modules/build output. Tests: 18 python + 19 desktop pass. |
||
|
|
f491260365 |
Merge remote-tracking branch 'origin/main' into bb/cron-sessions-sidebar
# Conflicts: # apps/desktop/src/app/cron/index.tsx |
||
|
|
146e77684b |
fix(desktop): bound desktop.log via cascade rotation + reclaim oversized logs
Supersedes the single-.1 rotation from the prior commit, which only bounded FUTURE growth: rotating a pre-existing oversized desktop.log just renamed the monster to .1 (no disk reclaimed) and left it stranded until a second rotation cycle that a now-healthy app may never reach. The ~326 GB file that motivated this PR would therefore persist as desktop.log.1 after the user updated. Two changes bring desktop.log in line with the Python-side logs (hermes_logging.py RotatingFileHandler, maxBytes x backupCount): 1. Cascade rotation: live -> .1 -> .2 -> .3, dropping the oldest. Steady-state usage is bounded at ~(backupCount + 1) x cap regardless of loop intensity, instead of the old ~2x with a single backup. 2. Pathological-size discard: a file past 4x the cap is a boot-loop artifact with no diagnostic value — delete it (and any equally poisoned backups) outright instead of relocating the disk-exhaustion problem into a sibling. This is what lets an updated app self-heal a disk a stale build filled, on the very next launch, rather than one rotation cycle later. Behavior verified against a real filesystem in a temp dir: under cap -> no rotation; normal overflow -> live becomes .1; repeated overflow keeps exactly backupCount backups (no .4) with total bounded; a pathological live file plus poisoned backups are all reclaimed. node --check passes. Co-authored-by: The Garden <chilltulpa@gmail.com> |
||
|
|
abbf050241 |
fix(desktop): cap desktop.log size to prevent unbounded growth
desktop.log is an append-only forensic log written via appendFileSync / fs.promises.appendFile with no rotation. When the backend enters a boot loop — e.g. the version-skew crash where an old app shell spawns `dashboard --tui`, argparse exits(2) instantly, and the renderer keeps retrying — the full bootstrap transcript plus repeated stack traces are appended on every attempt. In the wild this drove a single desktop.log to ~326 GB, exhausting the disk and breaking `hermes update`/install (git index.lock, venv rebuild, and npm all need scratch space). Rotate to a single .1 sibling once the live file crosses a 10 MB cap, so total on-disk usage stays ~2x the cap while preserving the most recent transcript for diagnostics. The size check runs before each append in both the sync (shutdown) and async (steady-state) flush paths. All filesystem ops stay inside try/catch so logging can never block startup/shutdown or crash the shell — consistent with the existing append error handling. Paired with the CLI --tui back-compat guard in this PR: the guard stops the crash loop from starting, and this stops a crash loop (from any cause) from ever filling the disk. |
||
|
|
3e2d758816 |
feat(desktop): fire cron jobs from the dashboard backend
The cron scheduler tick loop only ran inside `hermes gateway run`, but the desktop app spawns a `hermes dashboard` backend with no gateway — so any cron a user created in the app was saved and never fired (silently). Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard` is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never double-fires alongside a real gateway. |
||
|
|
16beab421f |
fix(desktop): About panel shows live Hermes version, not stale package.json
The native macOS About panel showed the Electron package.json version (e.g. 0.15.1) while the status bar showed the real Hermes version (0.16.0). setAboutPanelOptions() set applicationName + copyright but omitted applicationVersion, so macOS fell back to app.getVersion() = package.json, which drifts (release.py's desktop lockstep bump didn't land for 0.16.0). resolveHermesVersion() already reads the live version from hermes_cli/__init__.py and was built 'so the desktop About panel shows the real Hermes version' per its own comment, but was never wired in. - Seed applicationVersion: resolveHermesVersion() at module load. - Replace the macOS About menu item's role:'about' with a click handler (showAboutPanelFresh) that re-resolves the version on every open, so an in-place `hermes update` is reflected without an app restart. |
||
|
|
0c0a707744 | fix(desktop): repair macOS updater helper (#40217) | ||
|
|
be2c64be02 |
fix(desktop): wire serializeJsonBody into OAuth request path
The salvaged helper exported serializeJsonBody but main.cjs still inline-built the request body, leaving the export dead and the test decoupled from the real path. Use it at the fetchJsonViaOauthSession site so the helper's coverage exercises production body construction. Byte-identical output. |
||
|
|
b8234e7599 | fix(desktop): avoid restricted oauth request header | ||
|
|
02d6bf1c39 |
fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)
* fix(desktop): cross-profile session history in app-global remote mode #39894 made remote-profile sessions first-class for PER-PROFILE remote overrides. But the common setup — Settings → Gateway → "All profiles" → Remote — writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty profiles map), which the intercept didn't recognize. Switching to a non-launch profile then 404'd every session read, so no history showed for it. In global remote mode a SINGLE backend serves every profile via ?profile= (it reads each profile's state.db off the remote host's own disk — verified: one dashboard returns /api/profiles and /api/profiles/sessions?profile=all across all profiles). The fix: when no per-profile override matches but global remote mode is active, route per-session reads/mutations to that one backend and KEEP the ?profile= param so it opens the right state.db (instead of bailing to the local path and dropping the profile scope). - new globalRemoteActive() — true for connection.json mode:'remote' or the HERMES_DESKTOP_REMOTE_URL env override. - per-session branch: per-profile override → route sans profile (own db); global mode → route to the single backend WITH ?profile= preserved. - unified list is unchanged in global mode: it already passes through to the one backend, which aggregates all profiles natively. Verified live against a one-dashboard / multi-profile remote (Austin's topology): cross-profile transcript reads load (was 404), rename/delete route to the right profile, unified list spans both profiles. Known limitation (architectural, not fixed here): LIVE chat as a non-launch profile still needs a per-profile dashboard on the remote — the dashboard binds HERMES_HOME once at process start, so one global backend can't run an agent turn as another profile. Session history/read/mutate now work regardless. * fix(gateway): resume + chat any profile over one global-remote dashboard The REST half of this branch made cross-profile session history visible in app-global remote mode, but resume + chat still went over the WebSocket gateway, which was hard-bound to the dashboard's launch profile. Resuming a non-launch profile's session 404'd ("session not found") and sending spawned a new session — because session.resume/prompt.submit had no profile concept and the live agent + state.db were process-global to the launch profile's HERMES_HOME. Make the WS gateway per-session profile-aware so ONE dashboard can serve every local profile on its host (the app-global remote topology): - session.resume accepts an optional `profile`. _profile_home() resolves that profile's home on this host; resume opens THAT profile's state.db, binds its HERMES_HOME (ContextVar override) while building the agent so config/skills/ model resolve to it, and passes the profile db to the agent so turns persist to the right state.db. The owning profile_home is stored on the session. - prompt.submit re-binds the stored profile_home for the turn thread (mid-turn home reads — memory, skills — resolve to the resumed profile), reset in finally. - _make_agent gains an optional session_db param (defaults to _get_db()). - _load_cfg honors the home override (falls back to _hermes_home) so a resumed profile loads its own config; cache keyed on resolved path. - desktop: session.resume now sends the owning profile. Omitted/launch profile → unchanged (single-profile and per-profile-remote setups are byte-for-byte the same path). Verified live against a one-dashboard / multi-profile remote: resuming a non-launch profile's session loads its history, runs a real turn against THAT profile's home/env, and persists to its state.db. tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param. |
||
|
|
3045d54547 |
fix(desktop): route remote-profile session mutations + fix unified-list pagination
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.
Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.
- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
profile and pass it as request.profile so Electron routes to that profile's
backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
GET/PATCH (local cross-profile delete).
Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.
Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
|
||
|
|
83c13862f1 |
fix(desktop): route remote-profile session reads to the owning remote backend
Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's remote backend, but session list + transcript reads still assumed every profile's state.db is a local file the primary can open. For a remote profile the local file is absent or stale, so the IDs the sidebar shows 404 the moment resume runs against the remote -- the "session not found -> new session" bug. Intercept the three session-read GETs in the hermes:api handler and route them to the owning remote backend (which serves its own state.db natively): GET /api/profiles/sessions -> splice each remote profile's real rows in GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles No remote profiles configured -> untouched local fast path. A dead remote contributes nothing rather than breaking the sidebar. Verified end-to-end against a live remote backend: a remote-profile session resumes from remote history and continues on the remote across turns (history grows in place, no new session spawned). |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
bd12b3c232 |
feat(desktop): username/password login for remote gateways (#38851)
Surface the username/password dashboard-auth provider in Hermes Desktop's remote-gateway connect flow. A password gateway gates the same way an OAuth one does (auth_required + session cookie + ws-ticket), so the desktop already drives it through the existing sign-in window; the only gaps were that the probe dropped supports_password and the UI always said "OAuth". - main.cjs: capture supports_password from /api/auth/providers in the probe. - global.d.ts: add optional supportsPassword to DesktopAuthProvider. - gateway-settings.tsx: derive isPasswordProvider; render a plain "Sign in" button + "username and password" copy instead of an OAuth provider label when every advertised provider is password-based. Login still flows through the gateway's /login credential form (POST /auth/password-login). |
||
|
|
9d07927a23 |
desktop: OAuth-aware remote gateway connection
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.
Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.
OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
Electron session partition (persist:hermes-remote-oauth); main-process
REST routes through electron net bound to that partition so the cookie
attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
getGatewayWsUrl() re-mints before every (re)connect since tickets are
single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
(Nous Portal contract v1 issues no refresh token).
Local and token modes are unchanged.
Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
|
||
|
|
40420a619b |
fix(desktop): attachments on Enter, IME composition, scroll, fetchJson resets (salvage #38502) (#38677)
* fix(desktop): critical fixes — attachments, IME composition, scroll, fetchJson
DC2: Pass attachments to onSubmit() on direct Enter submit and call
clearComposerAttachments(). Previously attachments were silently
dropped — only text was sent while attachment pills remained visible.
DH1: Add 'open' to ThinkingDisclosure ResizeObserver effect deps.
When the disclosure toggles, refs point to new DOM but the observer
wasn't reattached, breaking live-scroll preview after expand/collapse
and leaking detached DOM nodes.
DH3+DH4: Add composition tracking via composingRef (set by
compositionstart/compositionend). Guards handleEditorInput (skip
preedit state writes), handleEditorKeyDown (prefer composingRef over
unreliable isComposing), and form onSubmit (prevent IME Enter from
triggering submission). Fixes IME Enter message splitting and preedit
text leaking into app state on CJK input.
DH6: Add res.on('error', reject) to fetchJson response stream.
Without this, a TCP reset mid-transfer left the promise hanging forever,
freezing the desktop UI.
All TypeScript compiles cleanly.
* chore: add copii.list@gmail.com to AUTHOR_MAP (stremtec)
* fix(desktop): prevent scroll snap-back during streaming, atomic config writes
DH2: Defer pinToBottom() in useLayoutEffect to rAF so that browser
scroll/wheel events from the current frame are processed first.
Previously an immediate pinToBottom() could snap the viewport back
to bottom against the user's trackpad scroll-up intent during
streaming — the wheel event hadn't fired yet so stickyBottomRef was
still true.
DH7: Add writeFileAtomic() helper (write to .tmp then rename) and
use it in writeDesktopConnectionConfig, writeDesktopUpdateConfig,
and writeBootstrapMarker. Prevents partial writes on crash/power
loss that would corrupt JSON config files, requiring manual repair.
* fix(desktop): guard nativeTheme listener from duplicates, invalidate connection config cache
DM9: Guard nativeTheme.on('updated') with a one-shot flag so that
multiple createWindow() calls (e.g. macOS activate after all windows
closed) don't accumulate duplicate listeners on the process-wide
singleton.
DM3: Add mtime-based cache invalidation to readDesktopConnectionConfig.
Previously the cache was populated once and never invalidated — if an
external tool modified connection.json, the desktop ignored the change
until restart. Now re-reads when the file's mtime differs.
* fix(desktop): widen fetchJson res.on('error') to sibling fetch + sort JSX props
Follow-up to salvaged #38502:
- resourceBufferFromUrl had the same mid-stream-reset hang class as
fetchJson (req.on('error') present, res.on('error') missing). Add the
response-stream error handler so a TCP reset during body read rejects
instead of leaving the promise unsettled.
- Sort the new onComposition* JSX props to satisfy perfectionist/sort-jsx-props
(was an introduced eslint error in the composer).
---------
Co-authored-by: asill-livestream <copii.list@gmail.com>
|
||
|
|
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. |
||
|
|
84710995ef |
Merge pull request #38312 from NousResearch/bb/installer-stderr-log-label
fix(installer): stop mislabeling stdout-style progress as stderr |
||
|
|
810e5864db |
fix(installer): stop mislabeling stdout-style progress as stderr
Both installers (Electron bootstrap-runner + Tauri) hardcoded a literal `stderr: ` prefix onto every line that arrived on fd 2. Tools like uv/pip/git/npm write normal progress to stderr by design, so routine install output showed up tagged as "stderr" (and rendered red in the Tauri progress UI), making a healthy install look like it was erroring. Carry the stream as structured metadata (`stream: 'stdout' | 'stderr'`) on the log event instead of mangling the line text. The UI now styles stderr subtly (dimmed) rather than alarmingly, and the persistent forensic logs keep their stdout/stderr distinction. |
||
|
|
a1cda2410b |
fix(desktop): self-update rebuilds and relaunches cleanly on macOS
The macOS DMG / in-app update could leave Hermes unable to relaunch: the
staged updater rebuilt the desktop without managed Node on PATH ("npm not
found"), never installed the rebuilt bundle over the running app, and could
race itself on `git stash`. Child install scripts also inherited a deleted
cwd from the .app bundle replaced during self-update.
- update.rs: prepend $HERMES_HOME/node/bin + venv bin to the rebuild PATH;
read --branch / --target-app from args; add a macOS "install" stage that
dittos the rebuilt bundle over the target app, clears quarantine, and
relaunches via `open` (rolling back on a failed swap); guard start_update
with an AtomicBool so concurrent startUpdate() calls can't race git stash.
- main.cjs: pass --branch <configured> and --target-app <running bundle> to
the staged updater, and spawn it with HERMES_HOME + managed Node/venv on
PATH and cwd=HERMES_HOME.
- bootstrap.rs: launch the desktop via `open <App>.app` on macOS instead of
exec'ing Contents/MacOS/Hermes, avoiding cwd/quarantine issues post-rebuild.
- powershell.rs: pin child install scripts to a stable cwd so they don't emit
getcwd errors when the launching .app is replaced mid-install.
- failure.tsx: in update mode show "Update didn't finish" / "Retry update"
and retry via startUpdate() instead of re-running the installer bootstrap.
|
||
|
|
5fca754ee3 |
fix(desktop): pass live backend PID to in-app update so its own dashboard is spared
The Python half (#37538) reads HERMES_DESKTOP_CHILD_PID to exclude the desktop-managed backend from _kill_stale_dashboard_processes, but nothing set it. applyUpdatesPosixInApp now passes the live backend PID in the `hermes update` env, completing the #37532 fix end-to-end. |
||
|
|
d0ea4caf7f |
fix(desktop): don't treat WSLg as a remote display
WSLg renders Linux GUIs locally through a vGPU surface rather than shipping frames over the wire, so it doesn't show the remote-compositor flicker — confirmed by a WSL user seeing zero flickering. Drop the WSL branch from detectRemoteDisplay so WSLg keeps hardware acceleration; detection now covers only genuinely-remote displays (SSH X11 forwarding, VNC, RDP). The HERMES_DESKTOP_DISABLE_GPU override still works for anyone who does hit it. |
||
|
|
6a2909fe5a |
fix(desktop): disable GPU acceleration on remote displays to stop flicker
Users on remote/forwarded displays (SSH X11 forwarding, VNC, RDP, WSLg) reported the window flickering during scroll/streaming; nobody on native Windows/macOS ever saw it. Root cause: the app shipped with Chromium's default GPU hardware acceleration and no remote-display handling. Over a remote connection the GPU compositor can't present accelerated layers cleanly across the wire, so the surface flashes on repaint. Local sessions composite on the GPU and never hit it. Detect a remote display before app `ready` (detectRemoteDisplay in bootstrap-platform.cjs) and fall back to software rendering via app.disableHardwareAcceleration() + --disable-gpu-compositing. Software compositing is rock-steady over the wire and the CPU cost is negligible next to the connection's latency. HERMES_DESKTOP_DISABLE_GPU overrides detection both ways for VNC/screen-sharing setups we can't sniff or remote hosts that do have working acceleration. |
||
|
|
e618cbee44 |
feat(desktop): custom zoom shortcuts at half default step
Replace Electron's built-in zoomIn/zoomOut/resetZoom menu roles with custom implementations that use a 0.1 zoom-level step instead of Chromium's default 0.2. This makes Ctrl/Cmd + +/-0 zoom feel more granular and less jumpy. Also adds installZoomShortcuts() which intercepts the keyboard shortcuts via before-input-event. This is necessary on Linux/Windows where the application menu is set to null, so Chromium's default handler would otherwise apply the full 0.2 step. |
||
|
|
7d0246ab57 |
Merge pull request #37745 from xxxigm/fix/macos-mic-entitlement-inherit
fix(desktop): inherit microphone entitlement for macOS helpers (#37718) |
||
|
|
afec339e96 |
docs(desktop): sync marker schema comment + default dock note arg
Address Copilot review: document the `adopted` flag and nullable `pinnedCommit`
in the marker schema comment, and default `done(note = {})` so the dock-pinned
marker write is unambiguous (object spread of undefined was already a no-op, but
explicit is clearer).
|
||
|
|
21e172b94a |
fix(desktop): inherit microphone entitlement for macOS helpers
Add com.apple.security.device.audio-input to entitlements.mac.inherit.plist. Under hardenedRuntime the Electron Helper/Setup processes inherit this file, and the missing entitlement made macOS TCC deny the microphone with no prompt, breaking voice chat. Fixes #37718 |
||
|
|
1daecfa4b0 |
fix(desktop): write Dock tile as a file-reference URL
The Dock stores persistent-apps as type-15 file:// URLs; the type-0/raw-path tile we wrote was silently dropped on the next Dock restart (so the pin never took, yet we'd stamped the marker and never retried). Use pathToFileURL + type 15 and flush prefs through cfprefsd before `killall Dock`. Verified end-to-end on a packaged build: move -> adopt -> Dock tile lands as file:///Applications/Hermes.app/. |
||
|
|
7466182179 |
fix(desktop): adopt existing macOS install + auto-place app
First-launch "already installed?" hinged solely on a marker that only the desktop's own bootstrap writes, so a runtime from `install.sh --include-desktop` (or a DMG launch over a prior CLI install) was runnable yet markerless and got the WHOLE installer re-run on top of it. Detect a runnable ACTIVE_HERMES_ROOT (valid source + venv), adopt it (stamp the marker, recording HEAD), and forward straight to the app. Repair keeps forcing a real re-bootstrap. Also: on first packaged macOS launch relocate the bundle into /Applications (Electron relaunches from there) and pin the canonical copy to the Dock once, so users stop re-opening the installer from Downloads/the DMG. |
||
|
|
ac76bbe21f |
fix(desktop): triage batch of GUI quality-of-life fixes (#37536)
* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing
A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.
Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
paint a horizontal scrollbar at the bottom of the window.
Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
@icons-pack/react-simple-icons (telegram, discord, matrix, signal,
whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
owner request).
- Drop the duplicate "Create first cron" button in the empty state.
Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
Blob identity; Chromium hands us the same screenshot via both
clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
spellchecker with the system locale on whenReady, and add
replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
backtick code + fenced ``` blocks) while keeping @file:/@image:
directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
submenu.
- Bake cursor-pointer into the <Button> primitive (with
disabled:cursor-default) and into titlebarButtonClass.
Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
every update check, and on throttled window focus so About reflects
the just-installed binary.
Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
the two streams as separate labeled blocks with stderr in a neutral
tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.
Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
preload bridge + global.d.ts typing + a "Default project directory"
row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
base; ShellFileOperations.delete_file rewritten to run a cross-
platform python3 -c snippet so deletes work on Windows shells (which
have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
and theme-color meta.
Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
8-minute silence on the stream auto-clears stuck $workingSessionIds
entries so "Session Busy" never gets permanently wedged. Wired into
useSessionStateCache so every state update refreshes the timer.
i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
(recommends react-intl, audits IME/RTL/CJK in the composer +
chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
non-English locale).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): replace native OS scrollbar in portaled dropdown menus
Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.
Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle
Two regressions from the previous dropdown-scrollbar fix:
- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
cn() call were being mis-resolved so the `rounded-full` leaked onto the
menu container itself. Replaced the whole tower of arbitrary variants
with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
`.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
--radix-dropdown-menu-content-available-height on Content but NOT on
SubContent, so the `max-h` bound to that variable computed to 0 and the
submenu collapsed to zero height. Switched SubContent to a fixed
max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog
The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.
Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
another round of Radix positioning bugs.
Also extract types/interfaces to the bottom of the file per workspace
convention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): move cron 'New cron' button off the top bar into the body
Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.
- Empty (zero jobs): EmptyState renders the "Create first cron" button
again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
a single "New cron" button (right-aligned). The rows themselves
already cover edit/pause/trigger/delete, so this is the only "create"
affordance.
Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address Copilot review on PR 37536
- sessions-settings: guard the WHOLE bridge call rather than chaining
`?.settings.foo().then(...)` — the latter throws when
`window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
generated delete snippet still works on remote backends running
Python 3.7. The existing FileNotFoundError handler covers the same
case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
(basic/bright colors, bold toggles, default-fg reset, coalescing,
256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
full-reset) so future refactors can't silently regress terminal
rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop/updates): swallow refreshDesktopVersion bridge errors
`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(desktop): drop work duplicated by other in-flight PRs
- composer/text-utils.ts: revert paste-image dedupe — PR #37596
ships the same fix with a cleaner content-key approach and a
Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
has already shipped a working i18n surface (homegrown nanostores
`t()` helper over en/zh dictionaries), so the RFC's framework
recommendation (`react-intl`) is now obsolete and would just
contradict the implementation that's actually landing.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
31c40c72c0 |
fix(desktop): stabilize project folder sessions (#37586)
* fix(desktop): stabilize project folder sessions Keep desktop folder selection aligned with new sessions and scope TUI gateway cwd through session context so prompts and tools resolve against the selected workspace. * fix(desktop): address review feedback on folder sessions Snapshot sessions before iterating to avoid concurrent-mutation crashes, optional-chain the revealLogs catch, and read console-message args from the correct Electron event/messageDetails positions. * fix(desktop): address second review pass on folder sessions Sync the remembered workspace key with the cwd atom (clear on empty), only load tree children for real directory nodes, and throttle renderer auto-reloads so a deterministic startup crash can't loop forever. * fix(desktop): inherit parent workspace for ephemeral agent tasks Background and preview tasks use ephemeral ids absent from the session map, so pass the parent session cwd into the session context explicitly instead of clearing it back to the gateway launch dir. Also correct the set_session_vars docstring about clear_session_vars semantics. * fix(desktop): validate preview cwd before pinning session context A non-empty but non-existent client cwd would pin an unusable override and silently fall back to the launch dir. Validate once, reuse for both the session context and the terminal override, and fall back to the parent session workspace when invalid. * fix(desktop): harden preview cwd normalization and adopt normalized cwd Guard preview cwd normalization against malformed client paths so a bad input can't fail the whole restart, and adopt the backend's normalized config.get cwd in the no-active-session path so the persisted workspace stays consistent with what the agent uses. |
||
|
|
4239230957 |
feat(desktop): cancellable first-launch install
The install overlay had no way to stop a running install — the runner already supported an abortSignal, but nothing drove it. Wire it end to end: - main.cjs holds an AbortController for the active runBootstrap and aborts it on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling mid-install actually kills install.sh/ps1 instead of orphaning it. - runBootstrap bails before spawning anything if the signal is already aborted. - Install overlay gains a "Cancel install" button while a bootstrap is active; a cancel surfaces the recovery overlay (retry/repair). Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early return (no spawn) via `node --test`. |
||
|
|
85b65e29f0 |
feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099)
* feat(desktop): session hygiene, archive, media streaming + connecting overlay
Address a batch of desktop feedback:
- Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB
row on every session.create (i.e. every launch/draft). Persist the row
lazily on first prompt instead, and hide message-less rows in the sidebar.
- Archive/hide sessions: new `archived` column + set_session_archived, web
API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item
in the sidebar, and an "Archived Chats" settings panel to restore/delete.
- Videos load via a streaming `hermes-media://` protocol instead of capped,
in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking.
- Background-process completions route to the session that launched them:
the completion event now carries session_key and each poller only consumes
its own.
- Sidebar: "Group by workspace" toggle is always visible; each workspace
group gets a "+" to start a session in that directory; "New agent"/"Agents"
relabeled to "New session"/"Sessions".
- New gateway connecting overlay (ascii decode → fade out) replacing the bare
skeleton/"starting gateway" state.
* fix(desktop): bail connecting overlay on boot error
The shownRef latch kept the connecting overlay mounted behind
BootFailureOverlay after a hard boot failure. Return null on boot.error
so the failure recovery surface fully owns the screen.
* fix(desktop): address Copilot review
- /api/sessions: validate `archived` (400 on unknown) and return `archived`
as a JSON boolean instead of SQLite's 0/1.
- PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no
updatable fields; stop conflating a no-op with "not found".
- hermes-media protocol: drop `bypassCSP` — streaming only needs
secure/standard/stream/supportFetchAPI.
- Sidebar workspace header: split the toggle and the "+" into sibling buttons
so we no longer nest interactive elements inside a <button>.
* fix(desktop): address Copilot re-review
- hermes-media protocol: restrict streaming to an audio/video extension
allowlist (415 otherwise) so it can't be used to read arbitrary local files.
- Connecting overlay: use z-[1200] instead of the non-standard z-1200 utility.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|