10 KiB
Desktop Engineering Guide
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
it teaches the invariants and the reasoning behind them so a change fits the app
even as files move. Read it with the repository AGENTS.md (root rules still
apply) and DESIGN.md for the visual and interaction contract.
When a rule here and the code disagree, trust the code and fix whichever is wrong — but never break an invariant to make a change easier.
What this app is
Desktop is its own native chat surface. It is not the browser dashboard and it does not embed the TUI. Three parties, each authoritative for one thing:
- Electron owns the machine: process lifecycle, native filesystem/git/ windows, install/update, and a narrow, typed capability bridge.
- The renderer owns the experience: navigation, presentation, and ephemeral interaction state.
- The agent backend owns the work: sessions, tools, model calls, streaming.
Keep the seams clean. The renderer never reaches for Node or Electron directly; native power arrives through a deliberate capability, not a general escape hatch. Agent behavior lives behind the gateway, never reimplemented in React. When a change blurs a seam, that is the smell — fix the seam, don't widen it.
Decide state by authority
The first question for any piece of state is who is allowed to be right about it, not where it is convenient to store it. Put state with its authority:
- The backend is authoritative for anything another Hermes surface can also change. Treat the renderer's copy as a cache of that truth.
- Electron is authoritative for machine and runtime facts.
- The renderer owns only what is purely about this window's presentation.
From that, everything else follows: shared renderer state lives in small stores owned by the feature that owns the concern; request-shaped server data that wants invalidation lives in the query layer; short-lived interaction detail stays in the component; hot coordination that must not paint stays in a ref. Reach for the narrowest home that still lets the state be correct. A new global store is a claim that many distant surfaces need it — earn that claim.
Persisted state must declare its scope in its own key: is this global, or does it belong to a connection, a profile, a stored session, a project, or a window? Getting the scope wrong is how one profile's setting bleeds into another.
Identity is not incidental
Sessions have more than one identity, and conflating them is a recurring source of "session not found" and vanishing history. Reason about which identity a surface needs: durable navigation and anything the user pins or persists key off the stable/durable identity; live streaming keys off the runtime identity; state that must outlive compression keys off the lineage root. Keep the mapping between them explicit and translate at the boundary rather than passing the wrong id inward.
Server truth is cached, not owned
The renderer paints from a cache of backend truth, so it must reconcile, not assume:
- Merge, don't clobber. A refresh is new information layered over what you already know, not a replacement that can drop live or pinned rows.
- Be optimistic, then honest. Direct manipulation should paint immediately from a snapshot; a failed write rolls back visibly and an authoritative refresh gets the last word.
- Guard against the past. Async results can arrive out of order; a stale response must never overwrite newer intent. Generation counters and request tokens exist for this.
- Isolate the foreground. Only the surface the user is looking at may publish into the shared view; background work updates its own cache quietly.
- Coalesce noise, flush signal. Batch high-frequency cosmetic updates, but let terminal transitions (a turn finishing, needing input, failing) reach the user immediately.
- Preserve reference identity on no-ops. Handing React a fresh array that contains the same data re-renders expensive trees for nothing.
Switching context is a re-home, not a reboot
Changing profile, connection, or mode is a workspace switch, not a cold start. The shell and whatever the user was doing stay put; only the gateway-bound view is cleared and repopulated, and the previous context must not leak into the next one. Reserve the full-screen boot/connecting experience for a genuinely unusable backend.
There are three distinct switch shapes, and conflating them is the classic bug:
- A connection/mode apply (local ↔ remote ↔ cloud) is the soft re-home: shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query invalidation alone cannot evict live session stores — wipe them.
- A runtime home change (switching the underlying
HERMES_HOMEprofile) is a hard re-home: the window legitimately reloads and state resets by remount. - A live profile swap in the same window activates another profile's socket while background profiles keep streaming; lists merge rather than wipe, and only an explicit user selection starts a fresh foreground draft.
Treating a soft switch as hard flickers the app; treating a hard one as soft strands stale rows. After any swap, the active socket, active profile, and connection atoms must agree, or REST and filesystem calls route to the wrong backend.
Cross everything as an observable ladder
Desktop lives at the seams: versions, profiles, local vs remote vs cloud, partially installed runtimes, stale caches, older backends. The durable technique for all of it is the same — an ordered ladder of candidates:
- Precedence is written down, in one place, as data or a pure function.
- A candidate is trusted only after it is validated at the right boundary. Existence is not proof; probe what you're about to rely on.
- A failed read falls to the next rung; a failed authoritative write surfaces or rolls back rather than silently retargeting.
- A missing capability and a transient failure are different: the first may enable a compatibility path or a disabled state; the second should retry.
- Retries are bounded and end in a real recovery affordance — never an infinite spinner or a hot loop.
- One resolver owns each policy so every caller gets the same answer. Scatter is how two call sites drift apart.
This is the shape of backend discovery, command/version fallbacks, connection and auth resolution, workspace-cwd selection, capability detection, and preview normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- One-time credentials are never reused. An OAuth gateway connection mints a fresh WebSocket ticket on every dial; a mint failure means reauthentication, not "fall back to the cached URL." Only long-lived token/local auth may reuse a cached URL as a lower rung.
- A connection test must exercise the leg you'll actually use. An HTTP status probe passing while the WebSocket/auth leg fails is a false positive that ships as "it said connected but nothing works."
Compatibility without carrying the past forever
Desktop and its runtime update on separate clocks, so a change can meet an older backend. Keep those users working: preserve the current feature, keep the fallback narrow and tied to an identified older runtime, and cover it with a test. A fallback that quietly degrades the feature it's meant to protect is worse than the crash it replaced.
Keep the waist narrow, grow at the edges
The root contribution rubric governs here too. New capability should arrive at the smallest surface that solves it: extend what exists, add a feature locally, lean on an existing seam — before you invent a framework. The shell's internal registries are composition seams, not a public plugin ABI; do not build a universal extension system, a manifest, or a plugin adapter for a single consumer. Design a shared contract only once more than one real consumer proves its shape. "Plugin" means several unrelated things across Hermes — do not assume one surface's extension model runs in another.
Respect the person using it
Design and engineering meet at intent. The user's attention and context are sacred:
- Never navigate, move focus, or open a surface because something happened in the background. Offer; don't hijack.
- The states around loading are distinct experiences — empty, loading, reconnecting, degraded/stale, and exhausted-recovery each deserve their own honest copy and their own way out.
- Keyboard ownership follows focus. The focused surface wins its keys; one cancel gesture does exactly one thing.
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden. Visibility is not lifecycle.
Make it feel instant
Performance is a feature the user feels, especially in drag, resize, scroll, typing, streaming, and terminals. The principles are timeless even as the code changes: keep hot-path state local or narrowly derived; don't subscribe heavy trees to per-frame updates; coalesce pointer work; avoid reading layout right after writing style; and don't mount expensive content mid-gesture. Prove speed against realistic content — a fast empty demo proves nothing about a long transcript. If motion is masking latency, remove the motion, don't tune it.
Testing as a habit of proof
Test the behavior that would actually break a user, not a snapshot of today's data. Favor invariants over frozen values. Exercise the real path for anything at a seam — resolver precedence and its failure rungs, identity and scope boundaries, optimistic rollback and stale-response ordering, and both sides of a local/remote adapter with its profile routing intact. Match how the suite is actually run rather than inventing a command; when in doubt, read the scripts.
The taste test before you hand off
- Does every piece of state live with its authority, at the narrowest scope?
- Would a background event ever steal the foreground or the user's focus?
- Does each resolver have one home, a validated ladder, and a bounded, recoverable end?
- Do local, remote, and profile routing still agree?
- Does async failure leave a usable UI and a way forward?
- Do hot interactions stay cheap under realistic load?
- Does the change pass the
DESIGN.mdchecklist and update all locales?
If any answer is "not sure," that's the part to go verify.