hermes-agent/apps/desktop
Siddharth Balyan 60ec6a3b8e
feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761)
* feat(desktop): native in-app downgrade (chargeless preview → schedule → undo)

Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce
to the portal — picking a lower tier runs the gateway pending-change flow in-app;
the scheduled state renders on the plan card with an undo. Upgrades keep the portal
deep link.

- api.ts: add previewSubscriptionChange / scheduleSubscriptionChange /
  resumeSubscription wrappers over subscription.preview|change|resume
  ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse +
  BillingMutationResponse (now re-exported from types.ts).
- use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule,
  refetch + onScheduled on success; typed refusals surface via the shared
  BillingRefusalInline, so insufficient_scope drives the existing step-up exactly
  like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo).
  Both accept a `simulate` switch so DEV fixtures click through with canned success.
- plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an
  in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect
  <date>. No charge now; you keep your current plan until then."). The scheduled
  downgrade target renders an inert "Scheduled" marker; other lower tiers stay
  actionable (picking one reschedules).
- CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to
  <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps.
- use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the
  ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target
  by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView
  gains `pending`, derived from current.pending_downgrade_* .
- inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline /
  StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware
  refusal renderer without a circular import; openExternal now delegates to the
  canonical @/lib/external-link opener.
- dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free
  downgrade scheduled for Aug 15) for the plan-card pending state + grid marker.

Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume +
insufficient_scope refusal); view derivation (pending plan-card state, scheduled
grid marker); confirm flow (preview shown, change called with the right tier_id,
refetch on success, schedule refusal → step-up affordance); undo flow; the
use-subscription-change hooks (preview-refusal retry, cancel, simulate path).
Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck
(app/electron/e2e) + lint clean.

PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges.

* fix(desktop): format downgrade credits delta as signed dollars

The downgrade preview rendered the raw wire string ("Monthly credits change:
-88."), violating the "monthly credits are DOLLARS" ruling. NAS sends
monthly_credits_delta as a bare decimal; format it as signed dollars through the
same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero /
absent still hides the line.

Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/
absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm
flow. Billing suite 99/99 green; typecheck + lint clean.

* fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim

Addresses the adversarial review of the native-downgrade diff.

- Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule
  RPC is in flight). While a change commits, every other Downgrade tile and the
  Back button are disabled; the active panel's Confirm/Cancel already lock. The
  plan-card Undo blocks on its own resume via `busy`. (The server also 409s
  overlapping per-org mutations — this is UI honesty, not the only defense.)
- Accessibility: the confirm panel is role="status" aria-live="polite" and takes
  focus on open (tabIndex=-1 container); closing it returns focus to the tile card,
  so keyboard focus is never stranded and the async preview text is announced.
- DEV-gated simulation: the canned preview/change/resume seam is ignored unless
  import.meta.env.DEV, so a production build never takes the simulated branch even
  if a `simulate` prop leaks through.
- Comments: documented the deliberate manual-retry-after-step-up (no auto-replay,
  matching auto-reload) and that name-matching the scheduled target is safe because
  SubscriptionTypes.name is @unique in NAS.

Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule;
simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule;
Undo disabled mid-resume; confirm panel role + focus on open. typecheck
(app/electron/e2e) + lint clean.

* fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits

Addresses the native-downgrade review threads.

Scheduled cancellations were invisible (NEW review item). subscription.current
carries cancel_at_period_end + cancellation_effective_* and subscription.resume
clears cancellations exactly like downgrades, but the pending-transition helper only
read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain
renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName,
when } | { kind:'cancellation', when } — computed once in deriveBillingView and
threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with
the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a
cancellation has no target tier). Precedence: a downgrade wins if both fields are set
(it names a concrete target — the stronger signal), commented at the helper. Adds a
`pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker,
downgrade-wins precedence).

Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow)
so two same-tick clicks — before React commits busy='schedule' — cannot fire two
schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success).
useResumeFlow reorders its unlock: a refusal releases immediately, a success holds
runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending
card. Test: a synchronous double-activation fires one schedule RPC.

Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) instead of relying on outer narrowing / `?? ''`.

Billing suite green (109); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): move DEV billing simulation behind the api seam

The fixture simulation lived as `simulate` / `simulateResume` prop drills and
`if (simulated)` branches inside the flow hooks, and it could not actually produce
the state it advertised (a simulated schedule never showed the pending card).

Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi
built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known,
and supplied to the whole subtree via a new `BillingApiProvider` (context override on
`useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy
of the fixture and its subscription-change mutations WRITE that copy's pending state:
schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation.
Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted;
queries always enabled; an effect refetches on fixture switch), so the click-through
genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared.

Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every
`if (simulated)` branch — the hooks are now production-pure. Added a test driving the
full simulated loop (schedule → pending appears → resume → cleared), plus cancellation
undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests.

Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): extract billing row/card components out of index.tsx

Purely mechanical, no behavior change: split the settings billing route file
(1065 → 593 lines) into focused siblings now that the downgrade feature has settled
their final shape.

- billing-amounts.ts — the dollar parse/format/validate/clamp helpers.
- account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow).
- current-plan-card.tsx — CurrentPlanCard.
- auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor).

index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture
wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules

Polish that composes with the api-seam rework:

- ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union
  (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible
  combinations (a preview AND a refusal, "ready" with no quote) can no longer be
  represented; the hook and panel branch on one `kind`, and `mutating` is simply
  `phase.kind === 'scheduling'`.
- The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase,
  fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`.
- inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal`
  moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s
  `openExternalLink`), and `InlineMessage` moves back into its sole consumer
  (auto-reload-row.tsx).

No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.
2026-07-22 02:46:06 +00:00
..
assets fix(desktop): pad app icon to Apple grid so dock size matches peers (#42946) 2026-06-09 11:48:26 -05:00
e2e fix: tool_calls double-encoding on import (#68856) 2026-07-21 18:53:05 +00:00
electron Merge pull request #68130 from NousResearch/feat/desktop-remote-ssh-current 2026-07-21 17:30:43 +02:00
pr-assets chore(desktop): drop PR screenshot assets from tree 2026-07-07 13:04:32 -07:00
public fix(providers): align Fireworks integration with project policy 2026-07-11 05:43:35 -07:00
scripts feat(desktop/e2e): Playwright E2E suite with visual regression diffs 2026-07-20 11:44:40 -04:00
src feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761) 2026-07-22 02:46:06 +00:00
AGENTS.md fix(desktop): avoid false remote gateway reauthentication (#68250) 2026-07-20 20:54:36 -04:00
components.json feat(desktop): add shared project UI primitives 2026-06-25 16:40:27 -05:00
DESIGN.md feat(desktop): button tooltip keybind hints + keybinds settings tab + unified worktree dialog (#65204) 2026-07-16 18:26:21 -04:00
eslint.config.mjs refactor(lint): hoist shared eslint + prettier config to root 2026-07-16 01:42:02 +05:30
index.html feat(desktop): composer status stack, live subagent windows, editable prompts (#44630) 2026-06-12 08:30:06 -05:00
package.json fix(desktop): address review — overlay a11y, e2e typecheck, nits 2026-07-20 14:37:39 -04:00
playwright.config.ts fix(desktop): address review — overlay a11y, e2e typecheck, nits 2026-07-20 14:37:39 -04:00
preview-demo.html
README.md Merge pull request #63103 from NousResearch/bb/desktop-docs-alignment 2026-07-12 04:34:21 -05:00
tsconfig.e2e.json fix(desktop): address review — overlay a11y, e2e typecheck, nits 2026-07-20 14:37:39 -04:00
tsconfig.electron.json feat(desktop): ts-ify everything 2026-07-08 16:24:16 -07:00
tsconfig.json fix(desktop): minor type fixes and devShell cage dep 2026-07-20 11:44:40 -04:00
vite.config.ts feat(desktop): billing settings tab (#61054) 2026-07-18 19:38:02 +05:30
vitest.config.ts fix(desktop): bump skills test timeout to fix cold-start flake (#68235) 2026-07-20 21:27:34 +00:00
vitest.setup.ts test(desktop): widen Testing Library async deadline to de-flake UI panels (#67849) 2026-07-20 04:09:40 +00:00

Hermes Desktop ☤

Download Documentation Discord License: MIT

The native desktop app for Hermes Agent — the self-improving AI agent from Nous Research. Same agent, same skills, same memory as the CLI and gateway, in a polished native window — chat with streaming tool output, side-by-side previews, a file browser, voice, and settings, no terminal required. Available for macOS, Windows, and Linux.

Chat with the full agentStreaming responses, live tool activity, structured tool summaries, and the same conversation history as every other Hermes surface.
Side-by-side previewsRender web pages, files, and tool outputs in a right-hand pane while you keep chatting.
File browserExplore and preview the working directory without leaving the app.
VoiceTalk to Hermes and hear it back.
Settings & onboardingManage providers, models, tools, and credentials from a real UI. First-run setup gets you to your first message in seconds.
Stays currentBuilt-in updates pull the latest agent and rebuild the app in place.

Install

Already have the Hermes CLI? Just run:

hermes desktop

It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.

Prebuilt installers

Prebuilt installers are built and distributed via the Hermes Desktop website..


Updating

The app checks for updates in the background and offers a one-click update when one is ready. You can also update any time from the CLI:

hermes update

Requirements

The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).


Development

Want to hack on the app itself? Install workspace deps from the repo root once, then run the dev server from this directory:

npm install          # from repo root — links apps/desktop, web, apps/shared
cd apps/desktop
npm run dev          # Vite renderer + Electron, which boots the Python backend

Point the app at a specific source checkout, or sandbox it away from your real config:

# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
../scripts/dev-sandbox.sh npm run dev
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
HERMES_HOME=/tmp/throwaway npm run dev
npm run dev:fake-boot   # exercise the startup overlay with deterministic delays

Building installers

npm run dist:mac     # DMG + zip
npm run dist:win     # NSIS + MSI
npm run dist:linux   # AppImage + deb + rpm
npm run pack         # unpacked app under release/ (no installer)

Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (CSC_LINK / CSC_KEY_PASSWORD / APPLE_* for macOS, WIN_CSC_* for Windows).

How it works

The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into HERMES_HOME (~/.hermes, or %LOCALAPPDATA%\hermes on Windows), using the same layout as a CLI install.

The app has three boundaries:

  • Electron resolves and validates a runnable backend, owns native filesystem/git/window capabilities, and exposes a narrow preload bridge.
  • React owns the Desktop routes, panes, interaction state, and @assistant-ui/react transcript.
  • Hermes Agent runs as a headless hermes serve process and exposes the tui_gateway JSON-RPC/WebSocket API. The renderer connects through apps/shared, which is also used by the browser dashboard.

Backend resolution is an ordered ladder:

  1. HERMES_DESKTOP_HERMES_ROOT
  2. the current source checkout during development
  3. a completed managed install
  4. HERMES_DESKTOP_HERMES, or hermes on PATH
  5. a system Python that can import the Hermes runtime
  6. the first-launch bootstrap installer

Candidates are probed before use; an existing shim or interpreter is not enough. A runtime that predates serve falls back to headless dashboard --no-open. This is compatibility for the backend command only and does not launch or embed the dashboard UI.

The Electron orchestration entry point is electron/main.ts; pure resolution, probe, hardening, and platform policies live in focused modules beside it. The renderer is under src/, with shared atoms in src/store and transport/native adapters in src/lib.

Before changing the app, read:

  • AGENTS.md: architecture, state ownership, resolver/fallback, transport, performance, and testing rules.
  • DESIGN.md: visual system, information architecture, motion, direct manipulation, and keyboard behavior.

Connections, projects, and switching

Desktop supports a managed local backend, explicit remote gateways, and Hermes Cloud connections. Remote and cloud modes use the same remote-capability path; authentication and discovery differ, not the renderer feature model.

Projects are the workspace abstraction. A project may own multiple folders, repositories, worktrees, and sessions; a bare new chat remains detached unless the user enters a project or configures a default project directory. Use the Projects UI rather than adding a second per-session folder-picker workflow.

Changing profiles or connection modes is a soft workspace switch, not another cold boot. The shell and current management overlay remain mounted while gateway-bound nanostores are wiped, query-backed data is invalidated, and the new connection repopulates skeletons. This prevents rows or transcripts from the previous gateway bleeding into the next one.

Verification

Run before opening a PR (lint may surface pre-existing warnings but must exit cleanly):

npm run fix
npm run typecheck
npm run lint
npm run test:ui
npm run test:desktop:platforms

Run npm run test:desktop:all for install, boot, update, packaging, or other release-path changes.

Troubleshooting

Boot logs land in HERMES_HOME/logs/desktop.log (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.

macOS / Linux:

# Force a clean first-launch setup
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
# Rebuild a broken Python venv
rm -rf "$HOME/.hermes/hermes-agent/venv"
# Reset a stuck macOS microphone prompt (macOS only)
tccutil reset Microphone com.nousresearch.hermes

Windows (PowerShell):

# Force a clean first-launch setup
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
# Rebuild a broken Python venv
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"

The default Hermes home on Windows is %LOCALAPPDATA%\hermes. Set the HERMES_HOME env var if you've relocated it.


Community


License

MIT — see LICENSE.

Built by Nous Research.