Commit Graph

76 Commits

Author SHA1 Message Date
Brooklyn Nicholson 5fe9e209dc fix(desktop): offer a restart when the updated app is already on disk
When the bundle was swapped under a running process, the About banner sent the
user to the installer — a download and a reinstall for a state that a plain
restart repairs, and the reason reinstalling never helped these reports.

Report bundleSwapPending on hermes:version and give that case its own copy and
a "Restart Hermes" button. It gets its own headline too: reusing "App build out
of date" over a body that says the app is already installed repeats the
contradiction with the Updates card that the banner is supposed to resolve. The
installer link stays for the genuinely-stale-bundle case.

Packaged builds only — a dev `--build-only` rewrites the stamp under a running
`npm start`, and that is a rebuild the developer asked for, not a torn install.

Co-authored-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
2026-09-01 22:33:29 -05:00
emozilla 43e67d872f feat: local models — managed llama.cpp runtime with one-click desktop setup
Run models locally as a first-class provider. The CLI grows a managed
llama.cpp runtime (engine install, model download, server supervision);
the desktop app grows the full setup and management story on top of it.
GUI surfaces ship behind the desktop --local launch flag (hermes desktop
--local, or the flag on the packaged app); backend routes and the CLI
are always live.

Runtime (hermes_cli/local_runtime/):
- curated GGUF catalog with per-machine variant selection: hardware
  probe (VRAM/RAM/UMA), fit planning with spill accounting, quant choice
  by context window
- derived recommendation: quality-ranked picks gated by a predicted
  decode-speed floor, bandwidth-aware on unified memory; the decision
  table is pinned as a test (pick AND reason per memory class), and the
  Recommended badge explains its pick in a tooltip fed by the resolver's
  actual branch
- engine install + model download with resumable split parts, cumulative
  plan-level progress, and staged-model integrity (a split GGUF counts
  only when every part is present)
- server supervision: spawn/adopt/stop, router mode with per-model load
  progress relayed over SSE, abandoned-request cleanup

Desktop:
- Settings -> Providers -> Local models: one-click quickstart (install
  engine, download the recommended model, boot) plus per-model download/
  activate/eject, fit-ranked catalog with context pills
- model pickers (composer dropdown + Cmd+K) show staged local models,
  in-flight downloads as live progress rows, and load-into-memory bars
- local-setup campaign tip for eligible hardware; System resources
  statusbar widget (GPU/VRAM/RAM); in-chat load progress during sends
- friendly dead-server errors, and failed agent builds retry on the next
  send instead of wedging the session

Co-developed with NVIDIA field feedback on RTX 5090 and DGX Spark.
2026-09-01 16:01:53 -04:00
Adolanium 10f2a20966 feat(desktop): add comment mode to the in-app browser
Click or drag on the preview page to pin a numbered comment. Saving a pin only adds it to the stack. Add comments drops the crops and a short prompt into the composer and never sends the turn.

Capture takes the visible page then crops in bitmap space, because Electron's rect crop on guest webviews is empty on Windows and shifted on high-DPI.
2026-09-01 00:30:56 -07:00
xxxigm bcecd675f7 fix(desktop): unwrap Models-page code-skew 503 and recycle the owned backend (#97046)
Show a Restart backend action that kills the SSH serve before the local child
so reconnect cannot reuse a stale lockfile, instead of dumping raw IPC JSON.
2026-08-31 20:43:36 -05:00
Gille f98f5e74e0 fix(desktop): preserve terminal startup output 2026-08-31 19:43:31 -05:00
Teknium 0f5dd5c46e feat(mcp-oauth): Desktop MCP OAuth now completes against remote backends (client-side callback relay)
The gateway's session-backed MCP OAuth flow (mcp.servers.oauth.start) binds
its browser-callback listener on the BACKEND machine's 127.0.0.1. When the
Desktop app connects to a remote backend (SSH/Tailscale), the user's browser
resolves that loopback to the user's machine, the redirect dies, and every
OAuth catalog server (ClickUp, Hospitable, ...) fails in-app with no working
path — the exact topology from the 'MCP Recurring erros' support thread.

Fix mirrors the Desktop's native gateway login (native-oauth-login.ts):

- gateway: mcp.servers.oauth.start accepts client_redirect_uri (loopback-only,
  RFC 8252-style validation); when supplied no gateway listener is bound and
  the OAuth redirect_uri pins to the client's listener.
- gateway: new mcp.servers.oauth.callback RPC relays the client-captured
  code/state into the flow; state verification stays in
  DashboardOAuthFlow.deliver_callback (constant-time compare, replay-safe).
- desktop: mcp-oauth-callback-ipc.ts hosts a one-shot 127.0.0.1 listener in
  the main process (hermes:mcp-oauth:listen/wait/cancel via preload bridge).
- desktop: hermes-bots mcp-setup.tsx prefers the client listener for local
  AND remote backends, falling back to the legacy gateway-listener flow on
  older gateways (feature-detect via start rejection).
- docs: remote-host MCP OAuth section documents the automatic Desktop path.

Validation: 19 new gateway tests (validator allowlist, listener skip, relay
accept/reject/replay) — sabotage-verified; 5 new desktop tests against a real
ephemeral listener; E2E through the real session registry + flow bridge with
a stubbed provider probe; tsc electron+renderer builds clean.
2026-08-29 19:18:01 -07:00
Brooklyn Nicholson 240790af60 fix(desktop): let HUD prompts take clicks on solid X11
Ignore-mouse cannot restore on X11, so a visible band that still has
pointer-events:none swallows clarify options and links. Held prompts
and solid-window bands now take the pointer without composer focus.
2026-08-29 00:51:43 -05:00
Brooklyn Nicholson e60983a697 fix(desktop): let the HUD drag onto another monitor
Composer drag added renderer CSS-pixel deltas onto a window AppKit
clamps to the current display, so the bar could not follow the cursor
onto a second monitor (and drifted on mixed-DPI Windows). Track the OS
cursor in main and lift that clamp.

Co-authored-by: Biotrioo <biotrioo@protonmail.com>
2026-08-28 15:33:19 -05:00
Teknium 65335549a6 feat(desktop): wire managed SSH update engine into main process
Re-implements the #93042 main.ts wiring against current main (post-#94724
drift), making the extracted managed-ssh-update engine reachable:

- ManagedConnectionUpdateGate instance + owner-only recovery journal at
  DESKTOP_MANAGED_SSH_RECOVERY_PATH (read/write/persist/mark/clear with
  strict record validation).
- IPC: hermes:connections:update-managed (requestManagedSshUpdate with
  correlation-id claim + in-flight dedupe); update-all's ssh rows now route
  through the transactional drain/update/restore lifecycle instead of
  POSTing the remote backend updater.
- Gate enforcement at every dial/mutate seam: bootstrapSshConnectionInner
  (pre-dial + publication fence with exact-serve rollback via
  rollbackSshBootstrapResult), resolveRemoteBackend, ensureRegistryBackend,
  saveRegistryConnection dial-field edits, connections:remove, and
  primary-routing mutations (set-primary, set-launch-mode,
  connection-config save/apply, profile:set) via
  assertCanMutateManagedPrimaryRouting.
- Scope capture/drain/restore drivers: captureManagedSshScopes (pool +
  primary discovery, bootstrap fence join), drainManagedSshScope (exact
  identity-re-proof termination, no-kill forward recovery),
  ensureManagedSshBackend(AtKey)/restoreManagedPrimarySshBackend restores,
  openManagedSshUpdateTransport for serve-less connections.
- Startup recovery (resumeManagedSshRecoveries before createWindow) and
  before-quit join of in-flight update/recovery operations BEFORE the SSH
  coordinator is sealed, so restore dials are not refused during quit.
- Extended sshConnections state (spawnNonce/creationTime(Ns)/startedAt/
  hermesPath/hermesHome/pythonPath/remoteProfile/registryConnectionId/
  primaryRegistryScope) so drain can prove the exact serve it owns;
  bootstrap coordinator entries carry metadata for the update fence;
  persistSshConnectionToken mirrors tokens per managedSshTokenPersistencePlan.
- preload/global.d.ts: connections.updateManaged +
  DesktopManagedConnectionUpdateResult/Receipt types.

Renderer UI (fleet-updates store, about-settings, system.ts ProfileScope
plumbing, i18n) intentionally NOT wired — it belongs to the deferred fleet
rollout UI and follows separately.

Wiring re-implemented against current main; design from #93042 by @andrexibiza

tsc -p apps/desktop clean; electron project 1924/1924 passed (133 files,
incl. 131/131 across the three engine suites); eslint clean on touched files.
2026-08-26 17:42:44 -07:00
Tilly-YL 2952119bce fix(desktop): remember selected profile across restarts
The profile rail's live workspace switch never persisted the selection,
so the Desktop always booted back into the previous startup profile
(#79886). Route the successful primary-backend activation through a new
persistence-only hermes:profile:remember IPC (validated
writeActiveDesktopProfile) that records the choice WITHOUT tearing down
the backend or reloading the window like hermes:profile:set does.
Registry-source picks name another source's profiles and do not touch
the startup preference. Reapplied semantically over three weeks of
main.ts/preload.ts drift (selectProfile now routes through
activateOnCurrentSource, #91349/#91365 seams).

Fixes #79886
Salvaged from #79888.
2026-08-26 00:22:27 -07:00
Mabolla 7944ad2168 fix(desktop): isolate SSH routing by window 2026-08-25 23:15:49 -07:00
Brooklyn Nicholson a608799689 feat(desktop): give the in-app Browser its own OS window
A ?win=browser renderer (query before hash) so HashRouter cannot swallow the flag, and so one window per tab can focus instead of cloning.
2026-08-25 18:23:43 -05:00
Teknium 6a6e16fa5d feat(desktop): OS-keychain encryption for stored secrets is now opt-in — no more macOS Keychain password prompt on every launch
Electron safeStorage parks a per-app key ('Hermes Key') in the macOS login
keychain; on machines with a locked/missing/corrupted default keychain that
turned every Hermes Desktop launch into a blocking 'Keychain Not Found' /
password dialog. Keychain-backed encryption is now an explicit opt-in:

- electron/secret-storage-policy.ts: standalone policy seam (default OFF,
  strict === true coercion, one-shot migration flag) + unit tests
- default path never calls any safeStorage API (including
  isEncryptionAvailable, which itself touches the keychain)
- one-shot legacy migration decrypts existing safeStorage blobs to plain
  0600 files at first launch; undecryptable blobs are kept but read as
  absent afterward (classify 'drop') so a dead keychain prompts at most once
- Settings -> Gateway toggle (all 5 locales) re-encodes every stored secret
  store in place when flipped (v1 connection.json, v2 connections.json,
  native-oauth-tokens.json)
- e2e: at-rest spec now covers both postures (opted-in unchanged contract,
  default saves without secure storage, owner-only bits, restart round-trip)
- docs: multi-connection-desktop + desktop-native-signin updated
2026-08-25 14:10:19 -07:00
Brooklyn Nicholson d7202d64ef refactor(desktop): derive HUD OS behavior from one windowing profile
Move, ignore-mouse, placement, resize edges, snap, cursor feed, and
overlay promote all read the same Ozone-normalized capabilities instead
of re-deriving linux/Wayland/X11 at each call site.
2026-08-24 14:11:49 -05:00
howdeploy 81baae6bc3 fix(desktop): polish HUD movement and resizing on X11 2026-08-24 06:48:10 -05:00
Brooklyn Nicholson b595fcd5e1 fix(desktop): keep the Linux HUD clickable and recoverable
X11 cannot restore a window that has ignored the mouse, so stay solid
there. Native Wayland keeps click-through via the cursor poll. Add
desktop.ozone_platform_hint so COSMIC users can opt into XWayland for
always-on-top, and a layout reset that restores the default size.

Co-authored-by: Codex Metatron <47930664+BlakeB254@users.noreply.github.com>
Co-authored-by: DeseretSaint <202557515+DeseretSaint@users.noreply.github.com>
Co-authored-by: Shawn Wang <32839114+enwaiax@users.noreply.github.com>
2026-08-24 06:48:10 -05:00
Brooklyn Nicholson 3c19644419 feat(desktop): HUD game-overlay mode
While a fullscreen app owns the screen, the HUD becomes an in-game chat frame:
the idle bar steps back to a glanceable opacity, and the transcript is held
open for as long as the game is there rather than fading on a timer — you look
back at a chat log during a lull, not while the text happens to be fresh.

Detection is a pure pass over the same front-to-back window enumeration
read_window_below uses (electron/hud-game-overlay.ts); main polls it while the
HUD is open and pushes changes to the renderer, which owns the treatment. Two
details the enumeration forced:

- Hysteresis. Entering needs the game to be what the user is actually looking
  at, so a windowed app on top vetoes it. Staying only needs the game to still
  exist: clicking the HUD to type de-foregrounds the game and floats every
  other open window above it, which otherwise dropped overlay mode at the
  moment the user engaged with it.
- The last state is replayed on did-finish-load. The watch pushes only on
  change and its first tick fires at window creation, before the renderer has
  mounted its listener, so a HUD opened over an already-fullscreen game
  consumed its only message and sat at 'no game' forever.

The band itself is reworked for living over someone else's window:

- Light-on-dark unconditionally. The theme's near-black body ink is unreadable
  over a dark game, and every attempt to gate the light ink on some
  condition — focus, then the game flag — produced a state where it evaluated
  false and the words went black on black. The sheet is a dark scrim in every
  theme so white is always right; anything that paints its own light surface
  (a clarify question, an approval card, a code block, a form control) opts
  back into theme ink by re-pointing the ink variable, matched on the fill it
  paints rather than the feature it belongs to.
- Your own lines are gold rather than bubbled. With no card the log otherwise
  reads as one voice; blue and purple are what most game UIs use for their own
  text, so they disappear into the background.
- The scrollback ramps out at the top instead of being cut off, masked on the
  scroller (the band is a static box — its rows overflow the thread viewport
  nested inside it, so a mask on the band ramps over empty space).
- The sheet is inset under the bar, so its square top corners no longer poke
  out past the bar's rounded ones.
2026-08-23 22:34:38 -05:00
Teknium ecf63ad3f3 fix: desktop plugins over 512 KiB no longer load truncated
Runtime desktop plugins read their source through hermes:readFileText,
the preview IPC that silently truncates at TEXT_PREVIEW_MAX_BYTES
(512 KiB) — a larger plugin.js evaluated as a partial file (cryptic
syntax error, or worse, a half-module that parses).

- electron/main.ts: dedicated hermes:readPluginSource handler — full
  read, 16 MiB cap enforced as a hard EFBIG via resolveReadableFileForIpc
  (same path hardening + sensitive-file blocking), never truncation.
- preload.ts / global.d.ts: bridge + types (optional — older shells).
- runtime-loader.ts: loadDiskPlugin reads via readPluginSource; on older
  shells falls back to readFileText but FAILS LOUDLY on truncated:true
  (error toast + error inventory row) instead of evaluating a partial
  file. Existence probe keeps the preview read (metadata is enough).
- tests: full-read path, loud old-shell truncation failure (sabotage-
  verified), small-plugin fallback.
2026-08-23 01:39:50 -07:00
Teknium 98f6fc549a feat(desktop): failed turns name the failing layer with recovery actions
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.

Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
2026-08-21 15:24:03 -07:00
Brooklyn Nicholson 9ed308f901 refactor(desktop): one identity chip behind every avatar
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.

They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
2026-08-20 14:07:53 -05:00
Teknium bd5b221a61 feat(desktop): updating now updates every target — remote backends, other gateways, and the app itself
Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.

- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
  active backend first (detailed progress), every other eligible
  registered gateway via the existing Electron fan-out (cloud rows skip),
  the client LAST (its apply relaunches the app). startActiveUpdate/
  requestActiveUpdate route through it whenever more than one update
  target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
  and a one-click 'Update desktop app' warning fires if the GUI is still
  behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
  the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
  memoization, nudge on/off).
2026-08-20 01:48:05 -07:00
Brooklyn Nicholson 7f9e79b2e1 feat(desktop): back the HUD band with the same window material the app uses
The HUD asked for vibrancy directly and always with the 'hud' material —
one of the two rungs the macOS census rejected, because it collapses into
under-window on blur and so changed the frost the moment another app took
focus. It also ignored the translucency setting entirely: Glass off still
frosted, and Windows got nothing at all.

hudFrostFor is the mapping for a transparent window, beside vibrancyFor in
the shared module both processes read. Two gates give it its answer: the
renderer's report that the band actually covers the window, and the user's
Glass setting. Off resolves to no material rather than a resting one, since
a transparent window has no opaque page to hide an unwanted frost behind.

Windows 11 rides setBackgroundMaterial through the same call, so the HUD
follows the frost ladder on both platforms. Main self-diffs and keys the
latch to the window, so a Settings change re-frosts a live HUD, a tint drag
touches nothing native, and a HUD respawned on another profile is not
mistaken for the window that already carried the material.
2026-08-19 19:12:09 -05:00
Brooklyn Nicholson a8d87ac1d9 fix(desktop): keep the preload bridge alive under the sandbox
Deciding whether the OS can back glass needs os.release(), but every
Hermes window runs its preload with sandbox: true, where require is a
polyfill limited to electron, events, timers and url. The node:os import
threw before contextBridge ran, so window.hermesDesktop was never defined
and the app booted straight into "Desktop IPC bridge is unavailable".

Main already computes both verdicts, so preload asks for them over a
synchronous channel instead. No reply degrades to no glass, which is an
ordinary opaque window rather than a page thinned over nothing.
2026-08-19 10:50:52 -05:00
Brooklyn Nicholson eb52328857 feat(desktop): back window glass with Windows 11 system materials
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.

Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.

Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
2026-08-19 10:50:51 -05:00
David Dudok de Wit 2228bc7d88 feat(desktop): remember the last used source 2026-08-18 22:03:25 -07:00
ethernet 1314a539c2 refactor(desktop): custom translated context menus across the app
One renderer coordinator owns every right-click and replaces the
native Electron menus. Menus are assembled from what the click
landed on:

- Links and images get open/copy/save sections; chat links add the
  reach-aware resolved-URL copy on remote gateways.
- Editables get spell-check suggestions (async-appended when
  Chromium's facts arrive from main), cut/copy/paste, and select
  all. Cut and copy need a selection; paste needs a non-empty
  clipboard; select all needs field content. The verbs show their
  accelerators instead of icons and dispatch a frame after the menu
  closes, so the radix focus trap cannot steal the target. Select
  all runs renderer-side, scoped to the field, because main's
  selectAll acts on the focused frame and could grab the transcript.
- Terminals answer through registered xterm handles; the read-only
  agent terminal hides paste.
- The in-app browser guest builds the same menus from the webview
  tag's context-menu event: Chromium's editFlags gate the edit
  verbs, spell-check rides the event, and Inspect element closes
  every menu. Coordinates arrive as window-relative device pixels,
  so the handler divides by the window zoom factor; guest edit
  commands focus the webview first, because they act on the focused
  webContents.
- Bare app chrome falls back to the window verbs.

Labels come from the locale files in all five languages. Main keeps
thin IPC verbs: edit commands, copy-image-at-gesture, spell-check
actions, and dictionary-add for guests (the tag has no session API).
The e2e spec exercises the real focus trap; it is blocked today by
the gateway-checking stall that also fails e2e/chat.spec.ts.
2026-08-18 23:53:07 -04:00
seref 359e09fd65 feat(desktop): one-click plugin install via hermes:// deeplinks
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.

Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
2026-08-18 14:45:23 -07:00
seref 73ddf6665c feat(desktop): rich plugin OS notifications with deeplink activation
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.

Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
2026-08-18 14:26:28 -07:00
Brooklyn Nicholson 956642c4fc feat(desktop): resolve preview URLs through main, which knows the transport
Only main can answer whether a loopback address is reachable: it holds
the live SSH connection and the profile precedence that picks it. The
renderer asks over IPC instead of guessing.

Forwards are scoped to the connection that authorized them and dropped
whenever it changes, so a new host never inherits a tunnel into the old
one. A failed forward logs and returns the original URL rather than
breaking the load path.
2026-08-18 08:52:36 -05:00
Brooklyn Nicholson ccabff8976 feat(desktop): route native browser gestures to the in-app browser
Cmd+R over a focused page reloads that page instead of the whole shell,
a trackpad swipe walks its history, and a mouse's back/forward buttons
do the same. Shift+Cmd+R still force-reloads the window.

All three arrive as native input, so main decides. A webview guest is
out-of-process: when the pointer and focus are inside the page, no
renderer signal sees it — not activeElement, not the layout tree's
hover/focus ladder. Main asks Electron for the focused webContents and
acts on it directly, and only falls through to the renderer when the
gesture landed on Hermes' own chrome.

Also adds a Reload window command to the palette, since Cmd+R no longer
always means that.
2026-08-18 06:16:29 -05:00
addel 27e4f09540 feat(desktop): expose connection-aware plugin routing 2026-08-16 16:26:50 -07:00
Teknium 28c729e68f fix(desktop): tear down renderer secondaries when a registry connection is removed
Removing a connection stopped its pooled backends and ssh tunnels but
never told the renderer: for remote/cloud sources there is no local
process to die, so the removed connection's WebSocket stayed open and
kept streaming ghost events into the UI until page reload. If the
socket did drop, openSecondary -> getConnectionFor threw 'No connection
with id' and scheduleReconnect retried forever (backoff caps at 15s,
entry never evicted).

- main now broadcasts 'hermes:connections:changed' on removal (and on
  material edits); preload exposes connections.onChanged.
- use-gateway-boot subscribes and calls the new
  disposeSecondariesForConnection(), which disposes + evicts every
  secondary scoped to the connection id (redialing on edits).
- reconnectSecondary fail-stops: when the Electron main reports the
  connection no longer exists, the entry is disposed and evicted
  instead of retrying forever; ordinary transport errors keep the
  existing backoff behavior.
2026-08-16 03:17:39 -07:00
Teknium aff19d0251 feat(desktop): multi-source agents end-to-end — sockets, roster, SDK, fan-out updates
Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top
of the registry (#86679) and composite-key backend routing (#86839). Agents
from every registered connection are now usable side by side.

Renderer socket registry (phase 3):
- backendScopeKey moves to apps/shared (@hermes/shared) so main-process pool
  keys and renderer socket keys derive from ONE rule; the electron module
  keeps a byte-identical twin (tsconfig project boundaries) pinned by a
  cross-copy contract test.
- store/gateway secondaries are scope-keyed: entries carry (connectionId,
  profile); registry-scoped entries dial through getConnectionFor +
  getGatewayWsUrlFor (fresh per-connect OAuth tickets against the right
  host); events keep the bare profile plus a connectionId tag; touch/idle
  keepalive uses the scope key; pruning keeps entries whose PROFILE has live
  work. New ensureGatewayForAgent/openGatewayForAgent fall through to the
  profile path for local/null sources — single-source behavior byte-identical.

Union roster + plugin SDK (phases 3+4, the Bot Mode door):
- hermes:agents:roster enumerates every connection's /api/profiles
  concurrently (eager REST, lazy sockets; unreachable sources report per-row;
  undialed ssh boxes stay connect-on-demand) and flattens through
  buildAgentRoster — the @name-device duplicate-handle rule applied once
  across all sources, pure + tested.
- SDK: host.connections(), host.agents(), host.warmAgent(),
  host.ensureAgent() — feature-detected so plugins degrade cleanly on older
  Desktop builds.

Fan-out updates (phase 5):
- hermes:connections:update-all dispatches hermes update to every eligible
  source in parallel: local via the app's own applyUpdates pipeline,
  remote/ssh via the backend's own POST /api/hermes/update; cloud skipped as
  platform-managed (updateEligibility, pure + tested); per-connection result
  rows so one dead box can't wedge the batch. Settings → Connections gains
  the "Update all instances" button (shown with 2+ connections).

Also: getJsonForBackend/postJsonForBackend helpers with the token/OAuth-cookie
auth split; docs section updated from "staged rollout" to live behavior.

Tests: +4 pure cases (cross-copy contract, roster handles, unreachable
sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer +
electron + shared clean; eslint clean.
2026-08-15 03:11:32 -07:00
Chen Jin d3193da3b1 fix(desktop): claim Ctrl/Cmd+F in main process so Pop!_OS / GNOME doesn't eat it
On Pop!_OS / GNOME-based Linux distros the GTK compositor grabs Ctrl+F at
the windowing layer (GNOME Files owns it) before the renderer's keydown
listener can fire, so the renderer's own `view.findInPage` keybind is
silently dead even though the binding is registered (#81727).

Claim the chord in the main process via `before-input-event` — that runs
strictly before the compositor shortcut can grab it. The renderer's
find-in-page pipeline still owns the FindBar UI and the actual search; we
just guarantee the press reaches it. The pre-existing `view.findInPage`
keybind stays as the renderer-side fallback for environments where the
compositor doesn't intercept.

Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
AND literal Ctrl on macOS so a non-macOS layout still works.

27/27 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Teknium 883b57cbd8 feat(desktop): route backends by (connection, profile) — registry-scoped pool keys
Phase 2 of the multi-connection campaign (#86679 shipped the registry).
The Electron backend pool can now serve agents from ANY registered
connection concurrently, keyed by composite (connection, profile) scopes.

- connection-registry.ts: backendScopeKey(connectionId, profile) — the
  single home of the composite-key rule. Local/empty connection ids keep
  the BARE profile key, so every legacy pool entry, reaper log line, and
  touch call is byte-identical for single-source users; non-local
  connections get `conn:<id>::<profile>`, which cannot collide with a
  plain profile name. backendScopePrefix() matches the keys a connection
  owns (teardown on remove).
- main.ts ensureRegistryBackend(connectionId, profile): resolves a backend
  against the v2 registry. local kind delegates to ensureBackend()
  untouched; remote/cloud dial the entry's own URL/auth (descriptor carries
  profile + connectionId + sharedRemote for per-request ?profile= scoping);
  ssh bootstraps a tunnel scoped to the composite key, with the served
  dashboard token persisted back onto the REGISTRY entry (not v1
  connection.json). Pool entries reuse the existing LRU/idle-reaper/touch
  lifecycle.
- hermes:connections:remove now stops every pooled backend + ssh scope the
  removed connection owns.
- New IPC hermes:connection:for + preload getConnectionFor + renderer
  types (connectionId/sharedRemote on HermesConnection). No renderer
  behavior change yet — the multi-source roster/socket switchover is PR 3.

Tests: +2 backendScopeKey contract cases (28 total in the registry suite);
electron + settings projects 1357 passed; both tsc configs and eslint clean.
2026-08-15 02:04:30 -07:00
Hermes Agent d4fa3f7352 feat(desktop): add option to disable F12 DevTools shortcut
- Replace built-in menu role 'toggleDevTools' (which had F12 accelerator)
  with explicit menu item using Ctrl+Shift+I / Cmd+Opt+I only
- Add f12Blocked flag in main process, controllable via IPC
- Add 'Disable F12 DevTools' toggle in Settings → Advanced
- F12 still opens DevTools by default; toggle blocks it
- Ctrl+Shift+I (or Cmd+Opt+I on Mac) always works regardless
2026-08-15 01:04:06 -07:00
Paul BlackSwan d5a865882a fix(desktop): save remote gateway files natively 2026-08-15 00:37:00 -07:00
Teknium b54b0521dc feat(desktop): multi-connection registry — named agent sources (schema v2 + IPC + Settings UI)
First slice of multi-source agent support: the desktop can now persist ANY
number of named backends (local runtime, remote gateways, Hermes Cloud
instances, SSH hosts) side by side instead of one global connection plus
per-profile overrides.

- electron/connection-registry.ts: pure v2 registry module — required
  case-insensitively-unique labels (device names), @name-device handle rule
  for duplicate profile names across sources (agentHandle), defensive
  normalizeRegistry for corrupt files, one-time v1→v2 migration that imports
  the global block + per-profile overrides (deduped by URL/host) and leaves
  connection.json untouched for older builds.
- main.ts: connections.json storage beside connection.json (same secret
  posture: safeStorage-encrypted tokens, 0600, tighten-before-parse, mtime
  cache) + hermes:connections:* IPC (list/save/remove/set-primary/test).
  Test maps registry entries onto the existing testDesktopConnectionConfig
  probe stack — no new probe code.
- Settings → Connections: manage the registry (add/edit/remove/test/make
  primary) with forced naming; local entry is non-removable; removing the
  primary retargets to local. en + zh locales.

Storage-level only by design: routing/pool generalization to composite
(connection, profile) keys, the multi-source roster, plugin SDK surface, and
fan-out updates land as follow-up PRs.
2026-08-14 23:06:23 -07:00
Brooklyn Nicholson 867eb1a381 feat(desktop): expose openSessionInTerminal over the capability bridge
Resolves the same backend the app launches (usually a venv python running
-m hermes_cli.main), writes the launcher script, and spawns the terminal
detached so it outlives the app. Resolution only — never ensureRuntime, which
would start a first-run install from a menu click.
2026-08-14 16:27:01 -05:00
Brooklyn Nicholson 4c1365b6c4 feat(desktop): load a desktop half from unified agent-plugin packages
The disk-plugin door now scans two Electron-local roots through one
pipeline: the standalone <HERMES_HOME>/desktop-plugins/<name>/plugin.js
door, and <HERMES_HOME>/plugins/<name>/desktop/plugin.js — the desktop
half of a regular agent-plugin package. A feature that needs both SDKs
ships as one installable folder instead of two co-dependent plugins.

Records are keyed by entry-file path (folder names can collide across
roots), each root gets its own fs watch with the poll staying alive
until every root is covered, and older Electron shells without the new
agentPluginsRoot resolver simply skip the unified root.
2026-08-13 13:18:49 -05:00
Brooklyn Nicholson 071d27d1c3 feat(desktop): paste a PR review comment as structured composer context
A pasted GitHub PR comment deep link (#discussion_r… / #issuecomment-…)
now lands as a typed review attachment instead of a bare url chip. The
card attaches optimistically and resolves through gh in the background —
author, file:line anchor, body, and the diff hunk — expanding at send
into an anchored fenced block, so "address this" carries exactly what
"this" is. When gh can't answer (offline, unauthenticated, foreign repo,
remote gateway) the card downgrades to the plain url ref and nothing is
lost.
2026-08-12 22:48:00 -05:00
Teknium e5e2fb8b2d fix(desktop): persist renderer crashes to desktop.log + finish the render() isolation class
Three-part class closure for the React #310 / lost-renderer-crash family
(#79428, follow-up to #80560 / #82763):

1. Diagnosability (#79428 defect B): error-boundary catches now persist to
   desktop.log with their component stack via a new fire-and-forget
   hermes:logs:renderer-error IPC (synchronous flush — the window may be
   dying). Every renderer-content window (main, secondary session, instance,
   HUD, quick entry, pet overlay) gets the error-level console capture that
   previously only the main window had, labeled per window. 'Open logs' on
   the crash dialog now reveals a file that actually contains the crash.

2. Recurrence guard: eslint no-restricted-syntax rule banning inline
   render() calls in JSX — the mechanism behind #80560. The rule
   immediately caught two live sites #82763's audit missed (floating
   panes, narrow-overlay reveal), both hosting plugin panes.

3. Fix those two missed sites with the same ContribRender mount.

extracted console-capture/report formatting to electron/renderer-log.ts
with unit tests; renderer console lines now carry the window label.
2026-08-10 17:10:53 -07:00
brooklyn! 75dad8b15c fix(desktop): stop HUD window growing on drag; add corner resize handle (#83091)
* fix(desktop): stop HUD window growing on drag; add corner resize handle

The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.

Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
  drag, so the OS can never accumulate drift (verified: 500 moveBy calls
  with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
  hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
  the ability to resize a window that is otherwise non-resizable

* fix(desktop): pin HUD drag size in renderer, not main-process globals

The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.

Supersedes #82455.

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state

The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.

Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.

---------

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
2026-08-10 10:05:14 +00:00
Brooklyn Nicholson 21aaa8b4f8 feat(desktop): resolve a session's pull request
A session row can say whether its work is open, merged or closed, and link
to it. The join is the session's own repo + branch, asked of GitHub in one
batched GraphQL request per repo (branch aliases, not a `gh pr list` page
that a busy repo crowds ours out of), through the remote-aware git facade so
a desktop on a remote gateway asks the backend's `gh`.

Two ways a session's branch can't answer, both covered:

- It ran on trunk. Fork PRs share our branch namespace, so asking about
  `main` badges a stranger's PR onto it — trunk is never asked about, and
  cross-repository PRs are dropped server-side either way.
- It worked in a worktree, so the branch it recorded at start isn't where
  the PR came from. Creating a PR from the review pane binds the session to
  the branch it actually used, and for sessions that predate that, the PR is
  recovered from the transcript: `gh pr create` prints a bare PR url and
  nothing else, so a tool result whose whole output is one is a claim rather
  than a mention. Scanned read-only across profiles, once per session ever.
2026-08-09 06:21:04 -05:00
Brooklyn Nicholson da933bf279 fix(desktop): keep the HUD clickable on Linux
Click-through decides whether to swallow the mouse by hit-testing the document
under the cursor, and it learns where the cursor is from mousemove. Those keep
arriving while the window ignores the mouse only because of
`setIgnoreMouseEvents(true, { forward: true })`, and `forward` is
`@platform darwin,win32`. On Linux the moves stop the instant the HUD turns
click-through, so it never sees the pointer return to the bar: the bar is
visible, and clicking it hits whatever is behind.

Main can still see the cursor, so on Linux it polls and pushes the position to
the renderer, which runs its usual hit test on it. The decision and its rules
stay in one place — only the courier for that one input changes — and off-window
is sent as null, which is already how the renderer hands the mouse back.
2026-08-08 22:15:24 -05:00
Brooklyn Nicholson dd11b8e8e8 fix(desktop): drag the HUD by holding the composer
`-webkit-app-region: drag` and `useHudClickThrough` cannot share a window, and
every previous attempt at a HUD grab handle had them fighting. The window
manager takes a draggable region's mouse input whole, so the page never sees
the cursor arrive on the handle — and click-through, which decides whether the
window is solid from exactly those moves, has already handed the window to the
desktop by the time you press. The handle was unusable (the press fell through
to the app behind) and, having eaten the moves on the way out, it also left the
HUD solid over its own dead space so clicks meant for the app behind died in
empty window.

So no HUD surface declares a drag region any more, and dragging is a press and
hold on the bar: 140ms to arm, then the renderer moves the window through a new
`hud.moveBy`. Deltas are read in screen coordinates, because client coordinates
are relative to the window being moved and report zero once it keeps up with
the cursor. The pointer is captured and the window pinned solid for the
duration, so a fast drag cannot outrun the bar.

Removes the 2rem invisible drag strip along with it — dead window that only
ever swallowed clicks aimed at whatever was behind the HUD.
2026-08-08 18:31:37 -05:00
Brooklyn Nicholson f22ae72921 feat(desktop): answer window.read.request with the window below
New electron/window-below.ts: pure z-order picker (walks past our own pid,
first other-process window whose bounds overlap ours) over get-windows'
front-to-back enumeration, with the Linux xprop stacking order reversed to
match (EWMH _NET_CLIENT_LIST_STACKING is bottom-to-top). Main answers the
hermes:window:readBelow IPC; on macOS other apps' titles pass through only
when Screen Recording is already granted — never prompted for.
2026-08-08 12:17:50 -05:00
Brooklyn Nicholson f444e0c5e7 feat(desktop): point an open HUD at the tab you toggle from
Asking for HUD mode from another tab used to just raise whatever the HUD
already had, so the conversation you were looking at never arrived. Main
now retargets the window and tells every renderer where it is pointed, so
the toggle keeps reading "switch" rather than "dismiss".
2026-08-08 03:51:59 -05:00
Brooklyn Nicholson 7b0dbd2242 feat(desktop): HUD mode window and its session handoff
A transparent, frameless, always-on-top window that renders the real chat
surface, so its composer is the app's composer rather than a lookalike that
drifts. Main owns the window, its remembered geometry, and click-through.

Leaving is a handoff, not a window close. The gateway binds a session's event
stream to exactly one socket, so a turn started in the HUD streams only there
and the app window hears nothing — no deltas, no turn-complete, no draft clear,
and nothing to poll for mid-turn. So the app re-resumes the session the HUD
ended on, which rebinds the transport, reconciles the transcript, and picks up
an in-flight turn. Main carries the session id across, since it is the only
party that outlives the HUD's renderer.
2026-08-08 01:38:57 -05:00
Brooklyn Nicholson 6e7eafc7e8 feat(desktop): share a profile as a portable bundle - theme, layout, skills
Export stages desktop.json (skin + mode, bundled user-theme definitions,
rail color, layout tree) into the CLI's own profile archive; import applies
it, so the receiver gets the whole look as a ready-to-use profile. Doors:
Export/Import profile... in Cmd-K, an import button beside the rail's +,
and Export in each profile square's context menu. New selectSavePath IPC
(native save dialog); credentials never leave the machine (CLI filter).
2026-08-04 12:07:29 -06:00