Commit graph

542 commits

Author SHA1 Message Date
yoniebans
a53e48b4f9 fix(desktop): make the remote pill a far-left colored indicator (VS Code parity)
The connection pill sat at the RIGHT end of the status bar with no color, so it
read like just another muted version pill — not the "you are on a remote host"
cue it is meant to be. Also, variant:link rendered it as an <a href> (with no
href), which silently swallowed the in-app `to:` navigation, so clicking it did
nothing.

- Move the pill to the FAR LEFT (first item in the left group), matching VS
  Code Remote, so it is the dominant ambient cue.
- Give it a solid colored block: primary accent for SSH, a calmer accent for a
  plain URL remote, so the two are distinct and both stand out from the muted
  bar. Hidden in local mode.
- Drop variant:link so the default button path fires navigate(to) → the pill
  now actually opens Settings → Gateway.
2026-06-23 13:22:14 +02:00
yoniebans
2ead3210ea fix(desktop): carry remoteKind/remoteHost through the primary backend so the pill reads SSH
A global SSH connection showed the statusbar pill as a plain token remote
("Remote: 127.0.0.1") instead of "SSH: user@host". startHermes() builds the
renderer-facing connection by hand-copying fields from the resolved descriptor
and dropped remoteHost + remoteKind, so the pill never saw remoteKind === ssh
and fell back to the URL-remote label (and looked like a token connection).

The per-profile pool path already spreads the full descriptor (...remote), so
only the primary/global path was affected — which is exactly the global-SSH
setup. Pass remoteHost + remoteKind through.

(The saved connection.json was already correct: mode:ssh with the encrypted
served dashboard token — that token IS the intended artifact, not a regression.)
2026-06-23 13:22:14 +02:00
yoniebans
a510e1132c fix(desktop): keep SSH ControlPath under sun_path on macOS
First real-hardware connect failed with: unix_listener: path
"/var/folders/8r/.../T/hermes-desktop-ssh/<hash>.sock.VSNDLDxh7gXySb0w" too long
for Unix domain socket.

Root cause: the default control-socket base was os.tmpdir(), which on macOS is
the deeply-nested per-user /var/folders/xx/yyyy.../T/ (~49 bytes). The socket
path itself fit 104, but OpenSSH binds a TEMPORARY listener at
<ControlPath>.<16 random chars> (a 17-byte suffix) while establishing the
master — 89 + 17 = 106 > 104, so bind failed.

Fix: default the POSIX control dir to a short, per-user base
(~/.hermes/desktop-ssh) instead of os.tmpdir(). Worst case is now ~72 bytes incl.
the temp suffix. Per-user (not a shared /tmp) avoids foreign-owned-dir/symlink
surface; still created 0700 in open(). Windows keeps os.tmpdir() (AF_UNIX has no
sun_path limit there). Deliberate divergence from ssh.py, which uses
gettempdir() and would hit this on macOS.

Test: default socket path + 17-byte temp suffix asserted <= 104 and not under
/var/folders/.
2026-06-23 13:22:14 +02:00
yoniebans
e4cf51dbc0 fix(desktop): pill deep-links to Gateway tab + tooltip shows profile
- Connection pill now navigates to /settings?tab=gateway (the settings index
  reads ?tab=), landing the user in the connection panel instead of generic
  settings.
- Tooltip appends the per-profile scope when the connection is profile-scoped,
  so it discloses which profile the host backs.
2026-06-23 13:22:14 +02:00
yoniebans
9622497036 fix(desktop): parse user@host[:port] typed into the SSH host field
normalizeSshConfig only trimmed the host, so user@host worked only by accident
(passed through as the literal target) and user@host:port did not split into
host + port. Worse, typing user@host AND filling the User field could produce
user@user@host.

Now: split a leading user@ and a trailing :port off the host field; explicit
user/port fields win (no doubling); IPv6 literals (multiple colons) and bare
~/.ssh/config aliases are left untouched. Tests cover user@host, user@host:port,
explicit-fields-win, and the alias/IPv6 passthrough.
2026-06-23 13:22:14 +02:00
yoniebans
bb898b80f9 fix(desktop): harden remote lifecycle — skip-build spawn + adoption liveness
- Remote dashboard spawn now passes --skip-build so a headless SSH bootstrap
  never triggers an npm web-UI build; if no built dist exists the backend fails
  loudly (scraped from the readiness log) instead of hanging on a build.
- Served-token adoption no longer asserts childAlive: () => true. Fresh spawn
  confirms the spawned remote pid is still alive (remotePidAlive) at adoption
  time; the reuse path reuses the pid-alive gate it already computed. This
  restores the foreign-backend guard: a served token from a DIFFERENT backend
  that grabbed the same forwarded port after the dashboard exited is rejected.
- Tests: --skip-build asserted in buildSpawnCommand.

(Note: the awaited before-quit SSH teardown landed in cfc0082b2 with the scoping
fixes — preventDefault + bounded await + one-shot guard so local forwards do not
linger after quit.)
2026-06-23 13:22:14 +02:00
yoniebans
cc96ac617a fix(desktop): create control-socket dir before opening the SSH master
OpenSSH does not create intermediate directories for ControlPath, so on a fresh
box (no prior hermes-desktop-ssh dir under \$TMPDIR) the very first connect failed
when ssh tried to create the master socket. Unit tests mock spawn and never hit
real fs, so this was invisible.

open() now mkdir -p (mode 0700 — the socket grants command execution on the
master) the control-socket directory before spawning ssh. Mirrors ssh.py.
Added a test that open() creates a non-existent control dir.
2026-06-23 13:22:14 +02:00
yoniebans
28d7472fb4 fix(desktop): scope global SSH per profile + stop terminal leaking to non-SSH remotes
Two scoping bugs found in audit:

1. Global SSH lost per-profile request scoping. globalRemoteActive() returned
   true only for mode === remote (or the env URL), not mode === ssh, so a global
   SSH connection serving multiple desktop profiles routed every profile to the
   remote DEFAULT profile instead of carrying ?profile=. Treat mode === ssh as a
   global remote for request scoping (one loopback backend, ?profile= per request
   — same contract as a global URL remote).

2. Interim ssh -tt terminal could leak into a token/OAuth remote. activeSshTerminalTarget()
   returned any cached SSH state (primary scope, then GLOBAL scope) without
   checking what the active profile actually resolves to. With a global SSH
   connection AND a per-profile token/OAuth override active, the terminal opened
   ssh -tt on the global SSH host. Rewrite it to mirror resolveRemoteBackend
   precedence: a per-profile non-SSH override (or env URL) returns null — never
   falls through to global SSH.
2026-06-23 13:22:14 +02:00
yoniebans
86c685a862 fix(desktop): make DesktopConnectionConfig contract total (tsc)
The connection-config fields were declared optional to accommodate the SSH-only
shape, but GatewaySettings and boot-failure-overlay read them as required —
tsc -p . --noEmit failed on every setState(config) and on the boot overlay.

- global.d.ts: remoteAuthMode / remoteOauthConnected / remoteTokenPreview /
  remoteUrl AND the five ssh* fields are now required on DesktopConnectionConfig.
  The contract is total; mode just decides which half is meaningful.
- main.cjs sanitizeDesktopConnectionConfig: the ssh branch returns inert
  remote-auth defaults; the local/remote branch returns empty ssh* defaults.
  Both branches now satisfy the total contract.
- boot-failure-reauth.test.ts: fixture carries the ssh* fields.

Verified: npm run typecheck clean; desktop-fs vitest 6/6; .cjs suites 116/116
(node --test). Pre-existing stale-base vitest failures (use-gateway-boot FIX:
tests, Windows-path + model/toolset/pane-shell) are unrelated — none touch any
file in this diff.
2026-06-23 13:22:14 +02:00
yoniebans
85c8848fa9 fix(desktop): lockfile records hermesHome + protocolVersion
The remote reuse lockfile omitted hermesHome and protocolVersion. protocolVersion
is load-bearing: it gates reuse across incompatible dashboard reuse-contracts —
without it, a future desktop could reattach to a dashboard whose token/spawn/
adoption semantics it no longer understands.

- Add PROTOCOL_VERSION (=1); reuse now requires lock.protocolVersion === current
  in addition to pid-alive + fingerprint-match + authenticated probe. A missing
  or mismatched protocolVersion fails closed -> clean respawn.
- probeRemoteHermesHome() records the remote HERMES_HOME (explicit env, else
  ~/.hermes; best-effort) in the lockfile.
- Tests: protocol-mismatch forces respawn; fresh spawn writes both fields. 26 pass.
2026-06-23 13:22:14 +02:00
yoniebans
97b44e5401 fix(desktop): dispose SSH terminals on connection flip
teardownSshConnection cancelled the forward and closed the control master but
left any interim ssh -tt terminals riding that master alive — after the flip
they were pointed at a dead control socket. Quit disposed them globally, but a
live A->B connection switch did not.

- Tag each SSH terminal session with its backing SSH scope at spawn
  (activeSshTerminalTarget now returns { ssh, scope }).
- teardownSshConnection disposes terminalSessions whose sshScope matches the
  scope being torn down, BEFORE closing the master (so the PTY dies while its
  socket is still valid). Local and other-scope terminals are untouched.

Honors the connection-flip teardown invariant (dispose terminal sessions on the
connection being torn down).
2026-06-23 13:22:14 +02:00
yoniebans
f0693a3232 fix(desktop): connectionCacheKey identity includes remote host (fs cache collision)
connectionCacheKey() keyed the desktop-fs cache on mode:profile:baseUrl only.
Local forwarded ports are reusable across different remotes, so two remotes
that map to the same 127.0.0.1:<localPort> (e.g. two SSH hosts whose tunnels
land on the same local port across reconnects) collide — one host's cached
directory listings and file reads get served for the other.

Fold the remote host into the identity: remoteHost (user@host for SSH, the real
backend host for token/oauth), with a baseUrl fallback. This is a latent bug on
main for ANY two remotes sharing a forwarded port, not only SSH — but SSH mode
makes it reachable in normal use.

Adds vitest coverage: two SSH hosts on the same local port get distinct keys;
the no-remoteHost fallback and local key are preserved.

vitest deferred (no node_modules in the worktree on this host); the regression
guard ships with the fix.
2026-06-23 13:22:14 +02:00
yoniebans
7c21034330 feat(desktop): interim ssh -tt remote terminal (SSH mode only; tracked for /api/terminal)
Make the integrated terminal land on the remote host when the window is
SSH-connected, so the chat/files/terminal loop is complete in SSH mode before
the dashboard /api/terminal WebSocket exists.

- ssh-connection.cjs: buildInteractiveSshArgs() — `ssh -tt` over the EXISTING
  control master (no new auth handshake; attaches instantly), cd into the
  remote session cwd best-effort, then exec "$SHELL" -l. Pure + node --test
  covered (PTY flag, master reuse, cwd cd, quote-safety).
- main.cjs: hermes:terminal:start spawns node-pty wrapping that ssh command
  when activeSshTerminalTarget() returns the live SSH connection for the
  window's primary backend; otherwise the existing local-shell path is
  unchanged. Gated to SSH mode ONLY — token/oauth remotes return null and never
  get a remote shell (their trust boundary is a token, not shell access). The
  existing resize IPC already propagates: node-pty.resize() -> SIGWINCH -> ssh
  -> remote PTY, end to end. Remote cwd is NOT run through safeTerminalCwd
  (that stats the local fs).

Clearly marked TODO(remote-terminal): replace with /api/terminal over the
tunnel once specs/desktop-remote-terminal.md lands, so cwd-follows-session
becomes uniform and this interim path is deleted.

ssh-connection node --test: 26 pass. main.cjs node --check OK.
2026-06-23 13:22:14 +02:00
yoniebans
56cded1ae1 feat(desktop): statusbar connection pill (SSH:/Remote: host) + i18n locale parity
Add a persistent connection-identity pill to the right-hand statusbar so the
user always knows WHERE a command lands — VS Code's load-bearing "am I local
or remote?" safety cue. More important in SSH mode precisely because the
experience is meant to feel identical to local.

- use-statusbar-items.tsx: new connectionItem rendered next to the version
  pills when connection.mode === "remote". SSH remotes read "SSH: user@host";
  token/oauth remotes read "Remote: host" — a free win that closes the same
  "where am I?" gap for the existing remote modes. Hidden in local mode.
  Clicking navigates to Settings (SETTINGS_ROUTE), so the pill doubles as the
  switch/disconnect entry point. Network (server) icon, distinct from the
  version Hash icon.
- main.cjs: buildRemoteConnection gains a remoteKind ("ssh" | "url") on every
  descriptor; the SSH bootstrap passes "ssh" so the pill can label it. The host
  is the SSH user@host (or the real backend host for url remotes), never the
  127.0.0.1 tunnel.
- global.d.ts: HermesConnection.remoteKind.
- i18n: connectionSsh / connectionRemote / *Tooltip added to types.ts AND every
  shipped locale (en, ja, zh, zh-hant) — locale parity.

Renderer typecheck/vitest deferred (no node_modules in the worktree on this
host). Delimiter/marker + i18n key-parity checks pass; main.cjs node --check OK.
2026-06-23 13:22:14 +02:00
yoniebans
fce5aaae4b feat(desktop): SSH connection settings UI + onboarding entry (issue #36970)
Add a third "Connect via SSH" connection mode to Settings -> Gateway next to
Local and Remote, so an SSH-capable user reaches a remote Hermes backend by
entering user@host — no token to copy, no dashboard pre-config (issue #36970).

- gateway-settings.tsx: third ModeCard (Network icon); SSH form with host
  (datalist-backed ~/.ssh/config suggestions + ssh -G resolve-on-blur that
  fills blank user/port/identity from the alias), user, port, identity file,
  and an optional remote Hermes path override.
- Connection test (Test SSH) runs ssh open + uname gate + locate-hermes WITHOUT
  spawning a dashboard, surfacing distinct unreachable / auth-failed /
  host-key-changed / hermes-not-found / unsupported-platform / timeout errors
  inline and via toast. Save/Connect persist mode:ssh; Connect applies + rehomes.
- icons.ts: add Network (IconServer).
- i18n: full SSH copy block added to types.ts AND every shipped locale
  (en, ja, zh, zh-hant) — locale parity, not just en.

Renderer typecheck/vitest deferred: no node_modules in the worktree on this
host; to be run by the user in a populated tree. Delimiter/marker structural
check + i18n key-parity check pass.
2026-06-23 13:22:14 +02:00
yoniebans
7d1afaa769 feat(desktop): connection-config ssh mode plumbing
Wire mode:"ssh" through the desktop connection resolution chain so an SSH
remote resolves into the EXISTING token-remote machinery — SSH mode is
desktop-local mode with the loopback stretched over SSH.

connection-config.cjs (pure, node --test):
- normalizeSshConfig() validates a {mode:ssh, host, user?, port?, keyPath?,
  remoteHermesPath?} entry (drops the default port, requires a host).
- profileSshOverride() resolves a profile-scoped SSH entry.
- hostLabelFromBaseUrl() derives the pill host for token/oauth remotes.

ssh-config.cjs (pure, node --test): parse ~/.ssh/config host aliases (follows
Include, filters wildcard/negated patterns, read-only, cycle-safe) and parse
ssh -G output (hostname/user/port/identityfile) for the settings UI.

main.cjs:
- readDesktopConnectionConfig / sanitizeConnectionProfiles preserve mode:ssh
  and the SSH fields; coerceDesktopConnectionConfig + buildSshBlock build/save
  SSH blocks (no user token; the dashboard token rides separately, encrypted).
- resolveRemoteBackend gains per-profile and global SSH branches that
  bootstrap via SshConnection + remote-lifecycle.connect(), persist the served
  token (encrypted), and hand buildRemoteConnection a 127.0.0.1 tunnel baseUrl
  with the SSH host as the pill label.
- buildRemoteConnection gains a remoteHost param + remoteHost on every
  descriptor (token/oauth pill host derived from the real URL).
- SSH connection-state registry (master + tunnel ports + remote pid per scope);
  teardownSshConnection cancels the forward + closes the master but LEAVES the
  remote dashboard running (reconnect-instant VS Code semantics); wired into
  before-quit and connection-config:apply (flip = re-bootstrap).
- testDesktopConnectionConfig SSH branch: ssh open + uname gate + locate-hermes
  WITHOUT spawning, returning distinct unreachable/auth-failed/hermes-not-found/
  unsupported-platform errors. New IPC: ssh-hosts, ssh-resolve. preload +
  global.d.ts updated (HermesConnection.remoteHost, SSH config/test/resolve
  types).

node --test: ssh-connection 23, remote-lifecycle 24, ssh-config 9,
connection-config 55 — 111 pass. (tsc/vitest deferred: no node_modules in the
worktree; renderer typecheck to be run by the user in a populated tree.)
2026-06-23 13:22:14 +02:00
yoniebans
cc24de2caa feat(desktop): remote dashboard lifecycle over SSH
Electron-free module that brings up (or reuses) a desktop-dedicated Hermes
dashboard on the remote host and a tunnel to it. Composes an injected
SshConnection with injected HTTP probes + served-token adoption so it stays
node --test-able.

- locateHermes(): profile path -> login-shell `command -v hermes` -> conventional
  venv path. The login-shell probe is load-bearing (non-login ssh PATH misses
  user installs). Clear hermes-not-found error with an install one-liner.
- probeRemotePlatform(): uname -s/-m gate to Linux/macOS; anything else fails
  with an unsupported-platform error before spawning.
- Lockfile on the remote (~/.hermes/desktop-ssh/<client>.lock.json, schemaVersion
  guarded). Reuse requires ALL of: schema parses, pid alive, the stored token's
  fingerprint matches the lockfile, AND an authenticated /api/status probe
  through the tunnel succeeds. PID liveness alone is insufficient (recycled pid,
  wedged dashboard, rotated token) — the probe is the deciding test.
- Spawn fresh: detached setsid `hermes dashboard --isolated --no-open --host
  127.0.0.1 --port 0`, sentinel-marked log so we scrape only THIS spawn's
  HERMES_DASHBOARD_READY port=<n>. --isolated keeps it off the host's unified
  machine dashboard.
- Served-token adoption against the tunneled baseUrl; the SERVED token's
  fingerprint lands in the lockfile so reuse checks the credential that actually
  authenticates /api/ws.
- Stale cleanup kills a pid ONLY when provably ours (cmdline carries hermes +
  dashboard + --isolated); always drops the lockfile.

24 node --test cases cover locate ordering, platform gate, lockfile parse/
write, pid-aliveness, provably-ours cleanup, spawn-command shape, readiness
scrape (incl. timeout + dead-process), and connect() fresh-spawn / reuse /
killed-respawn / wedged-respawn / unsupported-platform paths. Wired into
test:desktop:platforms.
2026-06-23 13:21:40 +02:00
yoniebans
f65468624b feat(desktop): ssh-connection.cjs — OpenSSH ControlMaster manager + token redaction
Electron-free SSH connection manager for Desktop SSH remote mode, using the
system OpenSSH client so it inherits ~/.ssh/config, the agent, ProxyJump, and
hardware keys for free (same rationale as tools/environments/ssh.py).

- SshConnection: exec(), forward()/cancelForward(), isAlive(), open(), close()
  over a persistent ControlMaster (ControlMaster=auto + ControlPersist), with a
  SHA256-hashed control-socket path kept short for macOS's 104-byte sun_path
  limit.
- BatchMode=yes everywhere: a programmatic ssh never hangs on a passphrase/2FA
  prompt; auth-needing-interactivity fails fast with guidance to load the key
  into the agent.
- Host-key policy StrictHostKeyChecking=accept-new (TOFU, fingerprint logged);
  a host-key CHANGE fails closed with the verbatim OpenSSH error surfaced.
- Every op raced against a hard timeout; timeout => connection-dead (half-open
  TCP after sleep) so the caller reconnects rather than retrying in place.
- redactSecrets() scrubs HERMES_DASHBOARD_SESSION_TOKEN, X-Hermes-Session-Token,
  Authorization: Bearer, and ?token=/?ticket= before any line hits desktop.log.
  All lifecycle logging routes through it.
- classifySshError() => distinct unreachable / auth-failed / host-key-changed /
  timeout kinds for actionable UI errors.

23 node --test cases cover command construction, redaction, error
classification, and the lifecycle with an injected fake spawn. Wired into
test:desktop:platforms.
2026-06-23 13:21:11 +02:00
brooklyn!
2a10b8384a
Merge pull request #51103 from NousResearch/bb/desktop-tool-preview-cleanup
fix(desktop): manual tool previews via status stack
2026-06-22 19:29:29 -05:00
Brooklyn Nicholson
7daa6d83fc style(desktop): soften inline code and expanded tool chrome
Drop the inline-code border; halve the expanded tool block radius.
2026-06-22 19:23:07 -05:00
Brooklyn Nicholson
48a8f84169 fix(desktop): toggle preview rail and open in browser
Status row opens/closes the preview pane; external link uses a dedicated
file:// browser bridge (openExternal, not openPath).
2026-06-22 19:22:11 -05:00
Brooklyn Nicholson
d0af7fc954 feat(desktop): detect tool previews into composer status stack
Register previewable artifacts from the tool row, feed a session-scoped store,
and render compact rows above the composer. Remove the inline preview card.
2026-06-22 19:22:11 -05:00
Brooklyn Nicholson
cb17a9efb2 fix(desktop): stop auto-opening tool previews
Drop gateway-event preview registration so HTML artifacts from tool results
no longer pop the rail. De-dupe the inline preview card label.
2026-06-22 19:21:20 -05:00
Eri Barrett
ba9e3a491b
feat(memory): Honcho OAuth connect — desktop and CLI flows + token refresh (#44335)
* feat(memory): OAuth token storage and refresh for the Honcho provider

* feat(memory): refresh the Honcho OAuth token in the client and session

* feat(memory): zero-CLI loopback OAuth authorization flow

* feat(memory): generic memory-provider OAuth connect endpoints

* feat(desktop): memory-provider OAuth connect link

* feat(memory): CLI OAuth sign-in with source-tagged authorize links

* fix(memory): IP-literal loopback redirect and consent config_path on the authorize link

* fix(memory): profile-scope the memory-provider OAuth endpoints

* refactor(desktop): generic memory-provider OAuth client functions

* docs(memory): trim OAuth module docstrings to the invariants

* docs(memory): document OAuth connect as an optional auth method

* fix(memory): send home-relative display path to consent, not the absolute path

* perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read

* fix(memory): log OAuth refresh failures at warning, not debug

* feat(memory): fall back to an OS-assigned loopback port when 8765 is taken

* test(memory): cover the desktop Connect launcher, status, and provider dispatch

* fix(desktop): keep the memory-provider dropdown one size regardless of connect state

* fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched

* refactor(memory): move OAuth connect routes out of web_server into a memory-layer router

* refactor(desktop): import MemoryConnect directly, drop the single-export barrel

* fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard

* fix(desktop): auto-clear the OAuth error state instead of leaving it sticky

* test(honcho): isolate auth-method prompt from deployment-shape wizard tests

main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only.

* docs(honcho): document query-adaptive reasoning level (reasoningHeuristic)

README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview.

* fix(honcho): default the CLI peer prompt to the OAuth consent name

The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it.

* feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop)

resolve_endpoints now picks the client_id from the initiating surface and
threads it through authorize -> token exchange -> persisted grant -> refresh,
so the CLI and desktop register as distinct OAuth clients. Surface-specific
env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic
HONCHO_OAUTH_CLIENT_ID, which still overrides every surface.

* feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup

status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of
masking the OAuth access token as a generic API key; setup notes an existing
OAuth grant when re-run.

* docs(honcho): drop 'shared pool' wording from unified observation mode help

* fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation

The in-process threading lock can't stop a sibling process (another profile or
the desktop app sharing honcho.json) from replaying the single-use refresh
token and tripping reuse-detection, which revokes the whole grant. Guard the
read-refresh-persist section with an OS file lock on <config>.lock so only one
process rotates at a time; the others re-read the freshly-persisted token.
Best-effort: platforms without flock degrade to in-process serialization.

* refactor(honcho): one OAuth client (hermes-agent) for all surfaces

Collapse the per-surface client_id split. CLI and desktop now use a single
client_id (hermes-agent); consent branding/UI still adapt via the source query
param. One grant identity means no clientId-vs-refresh-token desync that could
get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting.

* fix(honcho): per-session resolves to session_id, never remapped by title

Reorder resolve_session_name so stable identifiers win over labels: gateway
per-chat key first, then the per-session session_id, then the cwd map / title.
A (possibly auto-generated) title can no longer remap a live per-session
conversation onto a second Honcho session mid-stream — fixes the desktop, which
is per-conversation via session_id. Consequence: a gateway's per-chat key now
also wins over a title (titles never remap a stable id).
2026-06-22 19:16:47 -05:00
brooklyn!
116331dd3f
Merge pull request #51094 from NousResearch/bb/desktop-thread-timeline
feat(desktop): conversation timeline rail for long threads
2026-06-22 18:41:13 -05:00
brooklyn!
6780cee679
Merge pull request #51072 from NousResearch/bb/desktop-computer-use
feat(computer-use): add a cross-platform readiness preflight to the desktop
2026-06-22 18:37:07 -05:00
Brooklyn Nicholson
3fffecbdaf feat(desktop): add timeline rail for long chat threads
Adds a compact right-edge prompt timeline for long desktop chat sessions, with hover previews, click-to-jump, active/hover row states, and pane hover-reveal suppression so the rail can live at the hard edge without opening side panels.
2026-06-22 18:34:07 -05:00
Brooklyn Nicholson
a6b670d4a2 fix(desktop): avoid stack overflow on embedded image replay
Replace the giant embedded-image regex with a bounded scanner so opening sessions with multi-megabyte data URLs does not crash the renderer.
2026-06-22 18:19:36 -05:00
Brooklyn Nicholson
2dfcead683 feat(computer-use): make the preflight cross-platform (win/linux)
The card was macOS-only. cua-driver also runs on Windows and Linux, so
fold `cua-driver doctor` (cross-platform binary/health probes) into a
single OS-aware `ready` signal:

- macOS: ready == both TCC grants; keeps the permission rows + grant flow.
- Windows/Linux: no TCC toggles, so ready == driver health, with a
  per-OS note (SmartScreen/UIAccess on Windows; X11/XWayland on Linux).

`computer_use_status()` replaces the macOS-only `permissions_status()` and
surfaces `platform`, `ready`, `can_grant`, and the doctor `checks` (non-ok
ones render as warnings). CLI `permissions status`, the REST endpoint, and
the desktop card all key off the one payload. Grant stays macOS-only (400
elsewhere — nothing to grant).
2026-06-22 17:48:43 -05:00
Brooklyn Nicholson
0223ea5f59 feat(computer-use): surface macOS permission preflight in the desktop
Computer Use already worked through the desktop backend (the cua-driver
toolset enables + installs via Settings -> Skills & Tools), but there was
no in-app way to see or grant the two macOS permissions it needs, so "give
a model my Mac" was tribal knowledge.

The grants attach to cua-driver's OWN TCC identity (com.trycua.driver /
the installed CuaDriver.app), not Hermes -- so no app entitlement is
involved. cua-driver 0.5+ exposes `permissions status/grant`, which we wrap:

- tools/computer_use/permissions.py: thin client over the two subcommands
- hermes computer-use permissions {status,grant}: CLI parity
- GET /api/tools/computer-use/status, POST .../permissions/grant: desktop REST
- ComputerUsePanel: live Accessibility + Screen Recording state with a
  Grant button (dialog attributed to CuaDriver), shown in the expanded
  Computer Use toolset row. Binary install stays in the existing provider
  post-setup runner.

Follow-ups: i18n the card copy; a "Stop driver" control (cua-driver stop)
for the runaway-`serve` case.
2026-06-22 17:33:52 -05:00
Brooklyn Nicholson
de7ad8b78e fix(desktop): guarantee out-of-bounds composer is reclamped on load
Re-clamp once more on the next frame after pop-out so layout (sidebar widths,
fonts) has settled, and treat a degenerate pre-layout bounds rect as "unknown"
(fall back to the window) so we never clamp the box into a collapsed area. Net:
anyone who loads in with a stranded position is pulled back on-screen and the
fix is persisted, even if the first measure was premature.
2026-06-22 13:59:26 -05:00
Brooklyn Nicholson
ea5fa505d9 fix(desktop): clamp floating composer to the thread area, not the whole window
Now that the popped-out composer is fixed to the viewport, clamping against the
window let it slide under a pinned sidebar. Confine it to the thread region
(data-slot="composer-bounds") instead — its rect already excludes a pinned
sidebar and the header — falling back to the full window before it's measured.
This subsumes the old titlebar top-margin (the thread rect starts below the
header).
2026-06-22 13:57:53 -05:00
Brooklyn Nicholson
aff5ae692f fix(desktop): move composer out of contain wrapper instead of portaling
Replaces the body-portal approach: render ChatBar as a sibling of the
contain:[layout paint] chat wrapper (inside the same runtime boundary) rather
than portaling the floating instance to <body>. The wrapper is a containing
block for — and clips — position:fixed descendants, which is what stranded the
popped-out composer off-screen. As a sibling it anchors to the outer relative
container: docked stays absolute (identical placement), floating resolves
against the viewport. Both states stay mounted, so dock<->float no longer
remounts the editor (the portal toggle did).
2026-06-22 13:41:53 -05:00
Brooklyn Nicholson
79f270f549 fix(desktop): portal floating composer to body so it can't be clipped off-screen
The popped-out composer is position:fixed, but the chat content wrapper sets
`contain: layout paint`, which makes it a containing block for — and clips —
fixed descendants. Inline, the floating composer was positioned/clipped relative
to the chat column (which shifts with the sidebars), not the viewport, so the
viewport-based bounds clamp from #50466 couldn't keep it reachable: users still
lost it off-screen. Portal it to <body> when popped out so fixed positioning and
the clamp finally share the viewport as their reference. Docked stays inline
(it's absolute within the chat column by design).
2026-06-22 13:37:31 -05:00
Teknium
17dfc6bec4
fix(desktop): set AppUserModelID on Windows so notifications fire (#50808)
Windows toast notifications silently no-op unless the app sets an
AppUserModelID — new Notification().show() returns without error and
nothing appears. The desktop's native-notification system (approval,
turn-done, input, etc.) was therefore dead on Windows while working on
macOS/Linux.

Set the AUMID to the build appId (com.nousresearch.hermes) on Windows
right after app.setName, so toasts route to the installed Start Menu
shortcut. No-op on macOS/Linux, which don't require it.
2026-06-22 06:31:39 -07:00
teknium1
d4fa2db1c5 fix(desktop): show all of a provider's models when searching the composer picker
The composer model picker capped each provider's search matches at 12
(PER_PROVIDER_SEARCH). A provider serving more than 12 models (e.g.
opencode-go with 19) showed only a truncated subset when the user typed
its name to find it — exactly the models they were searching for got
cut. Edit Models showed the full list because it never applied this cap.

A search is already a narrowing action, so capping a single provider's
own matches is wrong. Remove the slice; search now lists every matching
model for the provider. The no-search default still shows the curated
top-N per provider via the visibility set.

Follow-up to #47077 (the backend dedup fix); this closes the remaining
frontend truncation users saw in the composer.
2026-06-22 06:23:49 -07:00
brooklyn!
04a1d9efd7
feat(desktop): PR-style file diffs in chat (#50731)
* feat(desktop): add Update now button to About panel

The About > Updates panel only surfaced "See what's new" when an update
was available, which just opens the changelog overlay — there was no way
to start the install directly from About. Add an "Update now" primary
button that opens the updates overlay (for apply progress) and kicks off
the install for the active target (backend in remote mode, else client).

* feat(desktop): PR-style file diffs in chat

Render write_file/edit_file/patch as a reviewable diff instead of raw
result JSON, closer to a Cursor/T3 per-edit review.

- Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise,
  drop the +/- gutter, color by line with a 2px gutter accent, full-bleed
  to the card, transparent context lines, compact scroll height.
- Header shows filename + language icon + +N/-N stats; full path moves to
  a hover tooltip (no Edited verb, no ms).
- Treat the three file-edit tools uniformly (isFileEditTool); read diff
  from inline_diff or patch's diff field; suppress raw-arg detail.
- Reusable FileTypeIcon primitive sharing the code-block icon mapping
  (codiconForFilename), codicon fallback.
- Per-row scaffolding fade (not the group wrapper, which trapped child
  opacity); expanded edits stay full, collapsed fade; keyboard-only focus
  lift. Hide diff-less rehydrated creates that read as dupes.

* style(desktop): lead --dt-font-mono with bundled JetBrains Mono

Code/diff blocks preferred a system Cascadia Code before the bundled
JetBrains Mono, so they drifted from the terminal (which leads with
JetBrains Mono) on machines where Cascadia is installed. Reorder so every
mono surface uses the face we actually ship.

* feat(desktop): syntax-highlight inline diffs via Shiki

Unify the diff renderer onto the same Shiki path as code blocks: highlight
the marker-stripped change content in the file's language, then a per-line
transformer layers the add/remove tint + gutter accent on top. Falls back
to the plain color-only renderer when the language is unknown, over budget,
or while Shiki loads.

- shikiLanguageForFilename(): extension → bundled-language id (shared
  filename-token helper with codiconForFilename).
- code display:grid so full-width line tints don't double with newline
  nodes; theme surface stripped so context lines stay transparent.

* style(desktop): use github-dark-dimmed for inline diffs

The vivid github-dark-default tokens read harsh behind the add/remove
tint in dark mode; switch the diff's dark theme to GitHub's lower-contrast
dimmed palette. Light mode and code blocks are unchanged.

* style(desktop): dim code-block syntax theme + share with diffs

Apply github-dark-dimmed to code blocks too (not just inline diffs) and
export one shared SHIKI_THEME so the two highlighters can't drift. Lower
contrast reads easier at our small code size in dark mode.

* style(desktop): soften shiki token contrast in dark mode

github-dark-dimmed only dims the background, which the diff/code surfaces
strip — so the bright token foregrounds were unchanged. Pull saturation +
brightness back a touch (hues preserved) on .shiki in dark mode for both
code blocks and inline diffs.
2026-06-22 05:22:23 -05:00
Brooklyn Nicholson
61c266b0dc style(desktop): soften dark-mode syntax highlighting
Share one SHIKI_THEME (github-dark-dimmed) across code blocks and inline
diffs so they can't drift, and pull token saturation/brightness back via a
`.shiki` dark-mode filter. The dimmed theme alone only changes the
background — which both surfaces strip — so the bright foregrounds needed
the filter to actually calm down.
2026-06-22 05:16:18 -05:00
Brooklyn Nicholson
ac128af1ce feat(desktop): syntax-highlight inline diffs via Shiki
Unify the diff renderer onto the same Shiki path as code blocks: highlight
the marker-stripped change content in the file's language, then a per-line
transformer layers the add/remove tint + gutter accent on top. Falls back
to the plain color-only renderer when the language is unknown, over budget,
or while Shiki loads.

- shikiLanguageForFilename(): extension → bundled-language id (shared
  filename-token helper with codiconForFilename).
- code display:grid so full-width line tints don't double with newline
  nodes; theme surface stripped so context lines stay transparent.
2026-06-22 05:10:23 -05:00
Brooklyn Nicholson
c6fbd5a104 style(desktop): lead --dt-font-mono with bundled JetBrains Mono
Code/diff blocks preferred a system Cascadia Code before the bundled
JetBrains Mono, so they drifted from the terminal (which leads with
JetBrains Mono) on machines where Cascadia is installed. Reorder so every
mono surface uses the face we actually ship.
2026-06-22 05:05:34 -05:00
Brooklyn Nicholson
a61baa9615 feat(desktop): PR-style file diffs in chat
Render write_file/edit_file/patch as a reviewable diff instead of raw
result JSON, closer to a Cursor/T3 per-edit review.

- Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise,
  drop the +/- gutter, color by line with a 2px gutter accent, full-bleed
  to the card, transparent context lines, compact scroll height.
- Header shows filename + language icon + +N/-N stats; full path moves to
  a hover tooltip (no Edited verb, no ms).
- Treat the three file-edit tools uniformly (isFileEditTool); read diff
  from inline_diff or patch's diff field; suppress raw-arg detail.
- Reusable FileTypeIcon primitive sharing the code-block icon mapping
  (codiconForFilename), codicon fallback.
- Per-row scaffolding fade (not the group wrapper, which trapped child
  opacity); expanded edits stay full, collapsed fade; keyboard-only focus
  lift. Hide diff-less rehydrated creates that read as dupes.
2026-06-22 05:04:13 -05:00
kshitij
ab22317d09
Merge pull request #50214 from kshitijk4poor/salvage/desktop-rename-branched-50143
fix(desktop): rename a branched session via session.title RPC (fixes "Session not found")
2026-06-22 15:15:30 +05:30
Teknium
7130d60861
feat(providers): remove google-gemini-cli + google-antigravity OAuth providers (#50492)
* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers

Google now actively bans accounts for third-party tools that piggyback on
Gemini CLI / Antigravity / Code Assist OAuth, and because abuse prevention
sits at a backend layer the ban can extend to the entire Google account
(Gmail/Drive), with a second violation being permanent.
Ref: https://github.com/google-gemini/gemini-cli/discussions/20632

Removes both OAuth inference providers entirely (modules, provider profiles,
auth/runtime/config/models wiring, the /gquota Code Assist quota command,
the antigravity-cli optional skill, desktop + docs surface in en + zh-Hans).
The API-key 'gemini' provider (GOOGLE_API_KEY/GEMINI_API_KEY against
generativelanguage.googleapis.com) is unaffected and stays fully supported.

* fix(skills): keep the antigravity-cli skill — only the OAuth provider is removed

The antigravity-cli optional skill orchestrates the external `agy` binary as
a coding-agent tool via the terminal tool — it does NOT wrap Hermes inference
through the banned google-antigravity OAuth provider, so it carries none of
the account-ban risk that motivated removing that provider. Restore the skill,
its docs page, the sidebar entry, and the optional-skills catalog row. The
google-antigravity / google-gemini-cli inference providers stay fully removed.
2026-06-21 19:53:27 -07:00
Carl
e5e2583635 fix(desktop): relaunch on Linux after in-app update instead of hanging (#45205)
On a Linux source install the in-app updater ran the full backend update +
desktop rebuild successfully but never restarted the app — it hung forever on
the applying overlay with no close button. Two causes:

- applyUpdatesPosixInApp() only handled the macOS .app bundle swap;
  runningAppBundle() is null off macOS, so Linux fell through to
  { ok: true, backendUpdated: true } without ever relaunching.
- The renderer store had no terminal state for that result shape, so
  $updateApply stayed { applying: true } and the overlay's close button
  (hidden while applying) never appeared.

Fix (new electron/update-relaunch.cjs, pure + unit-tested):
- Decide the Linux outcome from whether the *running* binary is the one we
  just rebuilt (execPath under release/<plat>-unpacked, path-segment-aware so
  linux-unpacked-evil can't masquerade) and whether its chrome-sandbox helper
  is launchable (root:root + setuid, or an --no-sandbox / ELECTRON_DISABLE_SANDBOX
  opt-out):
    relaunch — detached watcher waits for this PID to exit (graceful, then
      SIGKILL), self-deletes, and re-execs the rebuilt binary with the original
      launch context (filtered args + HERMES_*/sandbox env + cwd) restored.
    guiSkew  — AppImage/.deb/.rpm/dev: backend updated but this GUI package was
      NOT changed; surface an honest closeable 'reinstall the desktop app'
      terminal state instead of lying that it loads next launch (#37541 skew).
    manual   — rebuilt binary but sandbox helper not launchable: keep the
      working window, don't quit into a dead app.
- store/updates.ts lands a terminal, closeable state for EVERY resolved apply
  outcome (handedOff / guiSkew / manualRestart / updated-not-relaunched / error)
  so the hang is impossible regardless of platform or result.
- New DesktopUpdateStage values (update/rebuild/done/guiSkew) + GuiSkewView so
  progress reads correctly and the skew state is closeable. i18n in all four
  locales (en/ja/zh/zh-hant) in parity.
- electron/update-relaunch.test.cjs (16 tests) + store outcome tests.

Salvaged from #45205 onto current main. Linux quit dwell uses the shared
UPDATE_HANDOFF_DWELL_MS (2.5s) from #50448 for consistency. Four-locale i18n
parity, AUTHOR_MAP entry, and the test wiring added on top.

Closes #45205.
2026-06-21 17:04:52 -07:00
brooklyn!
1ec4fcf614
Merge pull request #50466 from NousResearch/bb/composer-popout-bounds
fix(desktop): keep the floating composer in-bounds (can't be lost off-screen)
2026-06-21 18:58:14 -05:00
Flownium
13ce811906
fix: show desktop approval fallback (#46548) 2026-06-21 18:57:18 -05:00
liuhao1024
bef1d3e4ff
fix(desktop): filter undefined entries in AttachmentList to prevent refText crash on session switch (#49624)
* fix(desktop): filter undefined entries in AttachmentList to prevent refText crash on session switch

When switching sessions, the attachments array can contain stale/undefined
entries from the previous session's state. Accessing attachment.refText on
an undefined entry throws TypeError, breaking session switching entirely.

Fix: add .filter(Boolean) before .map() to skip undefined/null entries.

Fixes #49614

* fix(desktop): update I18nConfigClient usage in attachment test

The i18n config API changed from getLocale/saveLocale to
getConfig/saveConfig. Update the test fixture to match.
2026-06-21 18:54:09 -05:00
Brooklyn Nicholson
16aeba1707 fix(desktop): clamp composer peel-off under cursor
Keep the floating composer bounded from the first peel-off frame and leave titlebar clearance when recovering bad persisted positions.
2026-06-21 18:52:01 -05:00
Brooklyn Nicholson
7785655b4e fix(desktop): keep the floating composer in-bounds so it can't be lost off-screen
The pop-out position is a bottom-right corner inset; the old clamp only floored
it and capped each inset by a flat constant, so dragging left/up (or restoring a
position saved on a larger/other monitor) could push the box's width/height past
the left/top edges and strand it off-screen — unrecoverable since the bad spot
persisted to localStorage.

Now the clamp bounds the WHOLE box (accounting for its measured width/height plus
an edge margin) on all four sides. Applied on drag (measured size), on load
(clamped in readPosition), and via a mount + window-resize reclamp so a shrunk
window or stale persisted value always pulls the box back into view.
2026-06-21 18:35:33 -05:00
Teknium
745c4db235
feat(desktop/windows): show update-in-progress feedback before the desktop exits (#50419) (#50448)
Follow-up to #50238/#50381. The restart-loop is now SAFE (marker + launch
gate), but the trigger that lured users into relaunching mid-update remained:
on the in-app update hand-off the desktop window vanished almost immediately
(app.quit() 600ms after spawning the detached updater), before the updater's
own window appeared — a blank-screen gap that looks like a crash.

- Linger on the update overlay for UPDATE_HANDOFF_DWELL_MS (2.5s, was 600ms)
  before quitting, on BOTH hand-off paths (in-app update + Windows bootstrap
  recovery), so the message lands and bridges to the updater window.
- Strengthen the restart-stage copy and the overlay's applyingBody/applyingClose
  to explicitly tell the user the window will reopen automatically and NOT to
  reopen Hermes themselves while it updates. All four locales (en/ja/zh/zh-hant)
  updated in parity.

Pure UX; does not touch the #50381 marker/gate mutual-exclusion safety net.
2026-06-21 15:34:52 -07:00