mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Capture durable Desktop engineering principles from recent sessions — state by authority, workspace-switch shapes, resolver ladders, optimistic UI — and point root AGENTS.md at the scoped guide with current filenames.
200 lines
10 KiB
Markdown
200 lines
10 KiB
Markdown
# 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`](./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_HOME` profile) 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:
|
|
|
|
1. Precedence is written down, in one place, as data or a pure function.
|
|
2. 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.
|
|
3. A failed *read* falls to the next rung; a failed *authoritative write*
|
|
surfaces or rolls back rather than silently retargeting.
|
|
4. A missing capability and a transient failure are different: the first may
|
|
enable a compatibility path or a disabled state; the second should retry.
|
|
5. Retries are bounded and end in a real recovery affordance — never an infinite
|
|
spinner or a hot loop.
|
|
6. 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.md`](./DESIGN.md) checklist and update all
|
|
locales?
|
|
|
|
If any answer is "not sure," that's the part to go verify.
|