Commit graph

74 commits

Author SHA1 Message Date
brooklyn!
d1c455acf7
bench(desktop): systematized perf harness; sunset 12 one-off scripts (#67466)
Replaces the dozen ad-hoc measure-*/profile-* scripts (each reinventing the
CDP client — 4 different copies — plus its own arg parsing, stats, output
path, and none with a baseline) with one framework under scripts/perf/:

- lib/cdp.mjs      one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors
- lib/stats.mjs    percentiles, histograms, CPU-profile self-time ranking
- lib/baseline.mjs load/compare/update baseline + regression gate (new capability)
- lib/launch.mjs   attach, OR spawn a fully ISOLATED instance
- scenarios/*      one module per measurement, registered in scenarios/index.mjs
- run.mjs / serve.mjs, baseline.json, README.md

Isolation solves the long-standing measurement blocker: a running `hgui` held
the Electron single-instance lock, so a second instance quit. `--spawn` /
`perf:serve` launch with their own --user-data-dir (separate lock scope), their
own HERMES_HOME (separate backend/sessions, config seeded from ~/.hermes so it
reaches a chat view without onboarding), and their own --remote-debugging-port.
Synthetic scenarios drive $messages via window.__PERF_DRIVE__, so no LLM credits.

Scenario -> sunset script mapping:
  stream            <- measure-synthetic-stream, profile-synth-stream, profile-long-stream
  stream --real     <- measure-real-stream, profile-real-stream
  keystroke         <- measure-latency, profile-typing, leak-typing
  transcript        <- (new: long-transcript mount cost)
  submit            <- measure-submit, measure-jump
  session-switch    <- profile-session-switch
  profile-switch    <- measure-profile-switch
CPU profiling is now a cross-cutting --cpuprofile flag, not 5 separate scripts.

CI-tier scenarios (stream, keystroke, transcript) need no backend/credits and
are gated against baseline.json (seed values; re-capture with --update-baseline
on a reference device). Backend-tier scenarios are report-only.

perf-probe.tsx gains loadTranscript() for the transcript scenario. No core
files touched; isolation is via CLI args, not env-gated app changes.

Verified: node --check all modules, tsc, eslint, and a unit smoke of the
stats + regression-gate logic. The end-to-end GUI run (which opens a window)
is left to run interactively via `npm run perf -- --spawn`.
2026-07-19 07:41:00 -04:00
Harry Yep
ddd6ad43f0 chore: upgrade @assistant-ui/react 0.12 to 0.14 and react-streamdown 0.1 to 0.3
bumps @assistant-ui/react from ^0.12.28 to ^0.14.23 and
@assistant-ui/react-streamdown from ^0.1.11 to ^0.3.4. this crosses
two minor bumps on each package and unlocks the built-in defer, smooth,
and tail-bounded remend primitives for PR 2.

breaking change from core 0.2.x: MessageRepository.appendOptimisticMessage
was removed (assistant-ui#4162). inline the three steps it did (generateId
+ fromThreadMessageLike + addOrUpdateMessage) in
incremental-external-store-runtime.ts, and set metadata.isOptimistic so
the new off-branch eviction logic cleans up the placeholder correctly.

fromThreadMessageLike and generateId graduated to the public API in
0.14.22 (assistant-ui#4414), so they now import from @assistant-ui/react
instead of @assistant-ui/core/internal. ExportedMessageRepository in the
test file moves to the public import for the same reason. the remaining
internal imports (AssistantRuntimeImpl, BaseAssistantRuntimeCore,
ExternalStoreThreadListRuntimeCore, ExternalStoreThreadRuntimeCore,
hasUpcomingMessage) are runtime construction internals with no public
equivalent and stay on @assistant-ui/core/internal.

the @assistant-ui/store npm override is removed: all transitive ranges
now resolve to 0.2.18 without it.

verified: tsc --noEmit passes, vitest shows zero new failures (15
pre-existing, 792 passing, identical to baseline before the upgrade).
2026-07-15 01:41:03 -04:00
ethernet
955c5b73c5 test(desktop): move node tests to vitest as well 2026-07-13 17:22:17 -04:00
ethernet
6800ec9d66 change(ci/desktop): move desktop app build into check job 2026-07-13 17:22:17 -04:00
ethernet
f382ff84f7 change(desktop): add vitest config for the desktop app 2026-07-13 17:22:17 -04:00
ethernet
bffc098d09 test(desktop): fix relative import in oauth-net-request.test.ts, drop dead source-regex test
oauth-session-request.test.ts regexed main.ts source text (extracting the
fetchJsonViaOauthSession function body and matching regexes against it) to
check Electron net.request doesn't set the forbidden Content-Length header
and does call request.write(body).

That behavior is already covered for real by oauth-net-request.test.ts,
which imports the actual serializeJsonBody/setJsonRequestHeaders helpers
from oauth-net-request.ts and asserts on a mock request object's setHeader
calls -- it only needed its relative import corrected to include the .ts
extension (Node's ESM loader doesn't resolve extensionless relative
specifiers). Removed the now-fully-superseded oauth-session-request.test.ts
and its dangling package.json wiring.
2026-07-13 17:22:17 -04:00
ethernet
1b8f1504b3 test(desktop): extract profile-delete routing decision for real unit tests
profile-delete-respawn.test.ts regexed main.ts source text to check that
prepareProfileDeleteRequest returns the torn-down profile name, and that
the hermes:api ipcMain handler captures that return value and routes to
the primary backend instead of respawning a pool backend for the just-
deleted profile.

Extract the pure decision logic into profile-delete-routing.ts (no
Electron import):
- profileNameFromDeleteRequest(request): parses a DELETE
  /api/profiles/<name> path, moved verbatim (already pure).
- decideProfileDeleteAction(profile, deps): the branch decision (noop /
  teardown-primary / teardown-pool) and the profile name to return,
  parameterized over isDefaultProfile/isValidProfileName/primaryProfileKey.
- resolveRouteProfile(tornDownProfile, profile): the
   routing ternary from the hermes:api
  handler.

prepareProfileDeleteRequest in main.ts now calls decideProfileDeleteAction
for the decision and only performs the async side effects (teardown +
writeActiveDesktopProfile) the decision calls for.

profile-delete-routing.test.ts replaces profile-delete-respawn.test.ts
(which was never wired into test:desktop:platforms), importing the pure
functions directly and asserting real return values across every branch
-- no readFileSync, no regex-on-source. Wired the new test file into
test:desktop:platforms in package.json.
2026-07-13 17:22:17 -04:00
ethernet
ec2cb3ab47 test(desktop): extract Windows hermes-resolution helpers for real unit tests
windows-hermes-resolution.test.ts regexed main.ts source text to check
three Windows resolution bugs that caused desktop reinstall loops:
1. findOnPath()'s PATHEXT extension order (must try real extensions before
   the empty one, or an extensionless Git-Bash hermes shim shadows
   hermes.cmd/.exe).
2. handOffWindowsBootstrapRecovery()'s --update vs --repair choice (must
   gate on any real-install signal, not just the hermes.exe shim).
3. unwrapWindowsVenvHermesCommand()'s probe-before-trust behavior (must
   canImportHermesCli() before returning a venv python, or a broken venv
   gets re-selected forever).

Extract all three into pure, dependency-injected functions in
windows-hermes-path.ts (no Electron import): buildPathExtCandidates(),
chooseUpdaterArgs(), resolveVenvHermesCommand(). main.ts's
findOnPath/handOffWindowsBootstrapRecovery/unwrapWindowsVenvHermesCommand
now call these with their existing helpers (fileExists, canImportHermesCli,
getVenvPython, etc.) passed through as deps.

windows-hermes-path.test.ts replaces windows-hermes-resolution.test.ts,
importing the pure functions directly and asserting real return values
with fake/injected dependencies (fake venvs, fake probes) -- no readFileSync,
no regex-on-source.
2026-07-13 17:22:17 -04:00
ethernet
4527943e91 test(desktop): extract hiddenWindowsChildOptions + stopBackendChild for real unit tests
windows-child-process.test.ts regexed main.ts and bootstrap-runner.ts
source text to check that spawn/execFileSync call sites wrapped their
options with hiddenWindowsChildOptions(), and that backend teardown
chose the right kill strategy.

Extract both into dependency-free sibling modules:
- windows-child-options.ts: hiddenWindowsChildOptions(options,
isWindows)
  now takes isWindows as an injectable param (defaults to the real
  platform check). main.ts and bootstrap-runner.ts both import the same
  implementation instead of each defining their own copy.
- backend-child.ts: stopBackendChild(child, deps) with
forceKillProcessTree
  and isWindows injected, so the SIGTERM-vs-tree-kill branching is
directly
  testable with a fake child + a spy.

windows-child-options.test.ts replaces windows-child-process.test.ts,
calling the real functions with fake spawn/execFileSync-shaped objects
and asserting on the actual returned options / kill call.
2026-07-13 17:22:17 -04:00
ethernet
7b3f3047ab feat(ci): run JS tests in CI, add npm run check in ws root 2026-07-13 17:22:17 -04:00
Brooklyn Nicholson
88fbc8825c feat(desktop): bridge WSL paths for a Windows host + WSL backend
When the desktop UI runs on Windows and the gateway runs in WSL, a WSL/POSIX
cwd isn't openable/readable from the Windows host. Add wsl-path-bridge.ts to
translate the Windows-side direction only:
- native folder dialog defaultPath: `/home/...` → `\\wsl.localhost\<distro>\...`
- fs read path: WSL cwd → its UNC / `C:\` drive form

Distro detection reads `wsl.exe -l -q` with `WSL_UTF8=1` and strips stray NUL
bytes, since older wsl.exe emits UTF-16LE (microsoft/WSL#4607) — the original
utf8 read returned a garbled distro name. UNC uses `\\wsl.localhost\` with a
`\\wsl$\` fallback for older Windows. The reverse (any path → POSIX) is handled
once gateway-side, so the picker result needs no desktop translation.

Co-authored-by: Rage Lopez <VrtxOmega@pm.me>
2026-07-12 05:32:54 -04:00
ethernet
fc977f62bc fix(desktop): remove old .js files
we built .ts into .js for a minute there and dumped em in src, and those
old .js files are getting resolved over top of the ts updated ones so
desktop clients don't update.

just --clean the old files so there's never a conflict :3
2026-07-10 17:05:08 -04:00
ethernet
b8880f1245 fix(desktop): type-check electron/ in CI typecheck
removing tsc -b from the build script (previous commit) also removed
the only step that type-checked the electron/ directory — the CI
typecheck job runs tsc -p . --noEmit, which uses tsconfig.json whose
include is only ["src", "../shared/src"], so electron/ was silently
uncovered. extend the typecheck script to also run against
tsconfig.electron.json so electron/ stays type-checked in CI.
2026-07-10 14:06:51 -04:00
ethernet
db8772a062 fix(desktop): don't emit js files when we build desktop 2026-07-10 14:06:51 -04:00
Brooklyn Nicholson
bf913abc2e fix(desktop): stop using tsx to boot Electron main in dev
Electron 40 ships Node 24.15, where tsx's ESM load hook returns null and
crashes with ERR_INVALID_RETURN_PROPERTY_VALUE. Bundle main+preload via
esbuild for `npm run dev` and always load the JS preload from dist/.
2026-07-08 22:40:51 -05:00
ethernet
7a65530fa5 fix(js): set @types/node to node 22, what's required in "engines" 2026-07-08 16:24:16 -07:00
ethernet
39d09453f9 feat(desktop): ts-ify everything 2026-07-08 16:24:16 -07:00
Adolanium
8ce3c2f991 feat(desktop): add UI scale setting to appearance settings 2026-07-07 13:04:32 -07:00
teknium1
077419b220 test(desktop): regression-guard fetchJsonViaOauthSession headers (#40069)
Closes #40069.

Salvaged from #40242; re-verified on main, tightened, tested.

Co-authored-by: maxpetrusenkoagent <maxpetrusenkoagent@users.noreply.github.com>
2026-07-06 11:12:15 -07:00
Brooklyn Nicholson
dec44994a5 feat(desktop): Memory Graph — playable radial timeline of memories + skills
A top-down Memory Graph panel: memories and skills on a radial time axis
(core = oldest, outer rings = newer) with a playable / scrubbable timeline
that builds the map up over time.

- Reveal lives off the React tree (a ref drives the canvas, a nanostore atom
  drives the timeline + legend), so a play-through or scrub never re-renders
  the panel; paint is coalesced to one rAF and playback is abortable, so even
  frantic scrubbing stays responsive.
- Adaptive dated rings: one equal-width ring per POPULATED calendar bucket,
  a "nice-tick" count scaled to the span. Constant (orthographic) core/band
  scale — more data grows the disk outward (more rings), never thinner.
- A bucket's nodes fill the band inside their ring and ignite staggered by
  real timestamp across it (no end-dump), with an EVE-style warp-in; the
  camera steps out band-by-band as rings are reached.
- ASCII "computing" core, theme-aware palette with a distinct memory hue,
  shared trackpad-gesture primitives.
- Shareable WoW-style "loadout" codes on a generic, reusable codec
  (@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url).
- Opens from the statusbar and command palette; i18n across all locales.

Deps: d3-force, fflate (drops unused react-force-graph-2d).
2026-06-30 00:54:21 -05:00
Brooklyn Nicholson
1c0fa12edb feat(desktop): persist & restore terminal tabs + scrollback across relaunch
User terminal tabs and their recent scrollback now survive an app restart
(VS Code parity). Tabs, active selection, cwd, and a serialized scrollback
snapshot are written to localStorage on every change; on launch the tabs
reopen with their history replayed above a fresh shell. Processes are NOT
revived — a new shell starts one line below the restored block.

- Capture: SerializeAddon snapshots the buffer on a 750ms leading-edge
  throttle, so a `cmd; quit` lands on disk before teardown; the snapshot is
  trimmed of its trailing idle prompt (no "double prompt" on restore) and
  capped (200 scrollback lines / 48k chars) to stay under the storage budget.
- Teardown guard: app quit/reload kills the PTYs from the main process,
  firing onExit in the renderer, but React skips effect cleanups on teardown
  so the per-instance `disposed` flag never flips. A pagehide/beforeunload
  flag stops onExit from calling closeTerminal() and wiping the persisted
  tabs right before relaunch restores them. A real `exit`/Ctrl-D still closes.
- Agent mirror tabs stay runtime-only — only user tabs persist.
2026-06-28 22:12:29 -05:00
Cossackx
ba37c910e0 fix(desktop/windows): resolve real hermes over extensionless shim + prefer --update on recovery
Two Windows-only desktop boot bugs that caused spurious reinstall/repair loops:

1. findOnPath() searched the empty extension BEFORE PATHEXT, so an
   extensionless Git-Bash `hermes` shim shadowed the real hermes.cmd/.exe.
   The shim then failed the shell:false --version probe and the resolver
   fell through to bootstrap/repair even though a working CLI was on PATH.
   Fix: try PATHEXT extensions first, keep the empty entry LAST so callers
   that already include the extension (py.exe, pwsh.exe) still resolve.

2. handOffWindowsBootstrapRecovery() chose the destructive --repair over the
   gentle --update by checking only venv\Scripts\hermes.exe -- the setuptools
   console-script shim, written at the END of venv setup and absent in
   interrupted/quarantined states. Fix: take --update when ANY real-install
   signal is present (venv python, the shim, or .hermes-bootstrap-complete).

Adds windows-hermes-resolution.test.cjs (source-assertion pattern, wired into
test:desktop:platforms) guarding both regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:40:37 -07:00
emozilla
2d206a3a42 fix(desktop): stop hermes desktop from clobbering tracked main.cjs (#52735)
`npm run build` ended with `bundle-electron-main.mjs`, which esbuild-bundled
electron/main.cjs and renamed the bundle on top of the tracked source file.
Because every `hermes desktop` runs `npm run build`, each launch rewrote a
checked-in source file (~7.5k-line source -> ~14.8k-line bundle), dirtying the
working tree with a build artifact that `git restore` couldn't keep (the next
launch re-clobbered it) and forcing autostash/restore conflicts on update.

The bundle only existed to inline `simple-git` so the packaged app.asar (which
ships no node_modules) wouldn't crash at launch with "Cannot find module
'simple-git'". Replace it with the mechanism the repo already uses for the
other hoisted runtime dep (node-pty): stage the dependency closure and resolve
it from process.resourcesPath at runtime.

- stage-native-deps.cjs: resolve simple-git's runtime closure (walking
  dependencies + optionalDependencies, so a version bump that adds a transitive
  dep can't silently reintroduce the crash) and stage it under
  build/native-deps/vendor/node_modules/. The `vendor/` nesting is load-bearing:
  electron-builder drops a node_modules dir at the ROOT of an extraResources
  copy but keeps a nested one.
- git-review-ops.cjs: fall back to the staged
  native-deps/vendor/node_modules/simple-git when the hoisted require() fails;
  dev runs resolve the hoisted copy and never hit the fallback.
- package.json: drop the bundler from the `build` script so main.cjs is never a
  build target again.
- nix/desktop.nix: drop the direct bundler call (the closure rides the existing
  `cp -rn native-deps` into $out) and patch process.resourcesPath in
  git-review-ops.cjs alongside main.cjs.
- delete scripts/bundle-electron-main.mjs.

Verified: electron-builder's own file filter keeps the full staged closure
(0 dropped), and a packaged win-unpacked build launches with the git-review
pane resolving simple-git from the staged vendor path.
2026-06-28 01:30:09 -04:00
Brooklyn Nicholson
81ac562bf0 feat(desktop): inline embed detection + module primitives
Pure, synchronous URL→descriptor matchers for YouTube, Vimeo, Instagram,
Pinterest, TikTok, X, Spotify, Google Maps and OpenStreetMap, plus the shared
embed primitives (error boundary, fail card, escape-html, dark-mode hook,
sizing token). Declares the mermaid + dompurify deps used by the fenced
renderers.
2026-06-26 03:21:59 -05:00
Brooklyn Nicholson
3b1344c18c fix(desktop): WSL titlebar layout and WSL2 GPU acceleration
Live-measure WCO width in the renderer, drop the right rail below the titlebar
band, and re-enable GPU compositing under WSLg when /dev/dxg is present.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson
da5484b61f fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay
WSLg bridges clipboard text but not images — pull host screenshots via
PowerShell. Disable titleBarOverlay on plain Linux; gate overlay width per
platform in titlebar-overlay-width.cjs.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson
c456029b4e Merge remote-tracking branch 'origin/main' into bb/editor 2026-06-25 19:53:31 -05:00
Brooklyn Nicholson
ff81365988 feat(desktop): in-app spot editor for the file preview pane
Adds a CodeMirror 6 spot editor to the right-rail file preview so users can
make quick edits in-app without leaving for an IDE. Entering edit mode is a
pure in-place swap of the read view — same fixed-height header, same gutter
geometry/typography (mirrors SourceView 1:1) so nothing shifts — toggled via
the Edit button, a bare `e` when the pane is hovered/focused, or the tab.

- Save path is transport-agnostic (writeDesktopFileText): local Electron IPC
  or a new hardened POST /api/fs/write-text on the dashboard server (path
  validation, parent-must-exist, regular-files-only, size cap, atomic
  temp-file + os.replace), behind the existing auth middleware.
- Stale-on-disk guard re-reads before writing and offers overwrite vs
  discard-and-reload instead of clobbering external/agent edits.
- VS Code-style modified dot on the tab; ⌘/Ctrl+S and ⌘/Ctrl+Enter save,
  Esc cancels; GitHub highlight style matched to the read view's Shiki theme.
- Typing stays render-free (draft in a ref; dirty flips once at the boundary).
2026-06-25 19:50:25 -05:00
ethernet
df514654ba desktop: bundle main.cjs for electron
fixes simple-git not found
2026-06-25 20:05:20 -04:00
Brooklyn Nicholson
890e890281 chore(desktop): update package lock 2026-06-25 16:40:27 -05:00
briandevans
a6485bddb8 fix(desktop): don't report a bogus update count for a shallow checkout
The desktop installer clones with `--depth 1`, so a public install's local
history often shares no merge-base with the freshly fetched origin tip. In
that state `git rev-list HEAD..origin/<branch> --count` enumerates the
entire remote ancestry and returns a meaningless huge number, surfacing as
e.g. "v0.17.0 (+12104)" in the update indicator (#51922).

The official-SSH branch of checkUpdates() already sidesteps this by reporting
a binary up-to-date check (`behind: currentSha === targetSha ? 0 : 1`), and
hermes_cli/banner.py guards the identical class for the CLI banner. The
passive desktop count path was the one place the shallow guard was missing.

Detect shallow + no-merge-base up front and fall back to the same SHA-based
binary check; full clones (developers / Docker dev images) keep the exact
count path unchanged. The resolution logic lives in a pure update-count.cjs
helper so it is unit-testable without booting Electron.
2026-06-24 19:12:09 -05:00
Brooklyn Nicholson
2de7549fe0 feat(desktop): remember window size/position/maximized across launches (salvage #39154)
The desktop window opened at a hardcoded 1220×800 every launch, discarding
whatever size and position the user left it at (#39101) — on macOS the dock
reopen was the most visible case, but every restart reset it.

A small window-state.json under userData (same pattern as connection.json /
updates.json) records the window's normal bounds plus its maximized flag,
written debounced on resize/move/maximize and flushed on close, applied on the
next createWindow(). getNormalBounds() captures the pre-maximize size so an
un-maximize next session lands where the user actually sized it.

Restore is defensive: sanitize rejects garbage, drops off-screen positions
(window falls back to Electron centering), and caps a size saved on a
since-disconnected larger monitor to the largest current display. The geometry
math lives in a side-effect-free window-state.cjs so it unit-tests with
node --test, no Electron boot. No new dependency.

Salvages #39154 by @jeffrobodie-glitch — same userData approach and validation
intent, reimplemented tighter and folded into one module.

Co-authored-by: jeffrobodie-glitch <jeffrobodie@gmail.com>
2026-06-24 13:32:05 -05: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
Teknium
f72690825e
fix(desktop/windows): stop in-app update from cascading into a backend restart loop (#50381)
When a Windows user relaunches Hermes while an in-app update is still
running (the desktop vanished with no progress and looks crashed), the
fresh instance spawns its own dashboard backend. That backend re-locks
the venv shim, the updater's straggler cleanup (force_kill_other_hermes
-> taskkill /F /T /IM hermes.exe) kills it, the launch dies with the 45s
"backend didn't come up" timeout, and the user relaunches into the same
trap -- an infinite respawn/kill loop (#50238).

Root cause: no mutual exclusion between an applying update and a fresh
desktop spawning its own local backend.

Fix: the updater publishes a HERMES_HOME/.hermes-update-in-progress
marker (pid + start time) for the whole run via an RAII drop-guard that
removes it on every exit path (success, early return, panic). A
freshly-launched desktop checks the marker before spawning its local
backend and PARKS until the update finishes -- then brings the backend
up itself (it is the surviving instance; the updater's own relaunch hits
the single-instance lock and quits). A stale marker (dead pid or past a
20-minute ceiling) is pruned so a crashed updater can never strand
future launches. No rogue backend spawns mid-update, so
force_kill_other_hermes has nothing legitimate to kill.

Marker parse/staleness logic is extracted to update-marker.cjs and
unit-tested; the Rust guard has unit tests; the Rust-write <-> JS-read
contract is E2E-verified.
2026-06-21 13:10:32 -07:00
teknium1
6bbacc2238 fix(desktop): make cold-start port-announcement deadline tolerant
The port-announcement clock in waitForDashboardPort starts the instant the
backend process is spawned — before uvicorn binds its socket. On a cold
install the child first compiles and imports the whole hermes_cli.main ->
web_server -> FastAPI/uvicorn chain, and on Windows real-time AV scans every
freshly written .pyc. That pre-bind cost can exceed the old hardcoded 45s
deadline, so the desktop killed a healthy-but-still-starting backend and
respawned it, piling up orphaned processes (#50209).

Raise the default to 90s and make it overridable via
HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor so a bad
override can't reintroduce the loop. Warm starts still announce in well under
a second; both call sites inherit the new default with no change. Adds
backend-ready.test.cjs (wired into test:desktop:platforms).
2026-06-21 12:29:18 -07:00
xxxigm
7eb9678c54 test(desktop): cover link-title window audio muting
Verify createLinkTitleWindow mutes audio (regression guard for #49505) and
keeps the hardened offscreen defaults, and register the new test file in the
desktop platforms test script.
2026-06-20 14:53:05 +05:30
Teknium
866f1d65c4
chore(desktop): sync package.json version fallback to 0.17.0 (#49236) 2026-06-19 12:53:35 -07:00
ethernet
769f307042 fix(npm): lock react-simple-icons to 13.11.1
suppress annoying message about engines that's completely benign but
people seem to complain
2026-06-18 17:41:58 -04:00
brooklyn!
4b7a186003
fix(desktop): retry the self-update rebuild once so the app relaunches (#48122)
The desktop self-update runs `hermes update` then `hermes desktop
--build-only`, and only relaunches if the rebuild returns 0. The first
`--build-only` can exit nonzero on a still-settling post-update tree or a
network-blocked Electron fetch that the installer's self-heal repaired
mid-run — so both updaters (the Tauri setup binary and the in-app POSIX
path) bailed before the relaunch step. The update landed but the app
never restarted; a manual launch worked because the heal had completed.

Retry `--build-only` once in both paths before failing, mirroring the
retry-once `hermes update` already does (and the CLI `hermes update`'s
own desktop rebuild). A second run builds clean off the healed dist and
is a near-no-op when the first actually succeeded (content-hash stamp).

- update.rs: retry stage 2; add rebuild_needs_retry() + test
- main.cjs: retry via new update-rebuild.cjs helper (behavior-tested)
2026-06-17 19:33:27 -05:00
brooklyn!
c1f9eb0ec4
fix(desktop): resolve electronDist dynamically + self-heal blocked installs (supersedes #48081/#48082) (#48091)
* fix(desktop): resolve electronDist dynamically + self-heal blocked installs

Supersedes the static-path approach (#48081) and the install-step self-heal
(#48082) with a fix that removes the whole failure class instead of chasing each
symptom. Three distinct faults converged into the June desktop-build outage; this
closes all three.

Root cause (the part #48081 left open — "Gap B"):
  build.electronDist was a static relative path in apps/desktop/package.json, but
  npm workspace hoisting is NOT deterministic — depending on the npm version and
  what else is installed, npm nests the workspace-only electron devDep under
  apps/desktop/node_modules/electron OR hoists it to the repo root. A static path
  matches only one layout, so a clean install intermittently fails with "The
  specified electronDist does not exist". #48081 re-pointed the path at the
  nested layout (correct today) but electron-builder reads electronDist
  STATICALLY, so any future hoist change silently breaks it again — only caught
  by a CI invariant, never self-corrected.

Fix:
- scripts/run-electron-builder.cjs: resolve electron the way Node's runtime does
  — require.resolve("electron/package.json") walks node_modules from the desktop
  project upward and finds electron wherever npm actually put it. The path can
  never drift out of sync with the install layout again, on any OS/npm version.
    * dist present -> pass -c.electronDist=<abs>/dist so electron-builder reuses
      the unpacked runtime (keeps the #38673 fast path that dodges the 26.8.x
      missing-binary re-unpack bug).
    * dist absent  -> omit electronDist; electron-builder fetches Electron itself
      via @electron/get honoring electronVersion + ELECTRON_MIRROR.
  package.json: builder script now runs the wrapper; the static build.electronDist
  is removed (the resolver owns it).
- main.py / install.sh / install.ps1: on a dependency-install failure where the
  electron package staged but its dist is missing (electron's install.js
  process.exit(1) on a blocked/throttled binary download — #47266/#47917/#48021),
  repopulate the dist via electron's downloader (canonical, then npmmirror.com)
  and CONTINUE to the build instead of aborting. npm runs postinstall LAST, so
  the only casualty is electron/dist; bailing here is what made the pack-time
  mirror self-heal unreachable on a blocked network. Hard-fail only when electron
  never staged at all (a genuine dependency error).
- The pack-time mirror fallback now retries the build even when the pre-fetch
  can't populate the dist: the wrapper lets electron-builder download Electron
  itself via the mirror, so the retry is no longer a no-op (it was, when
  electronDist was a static path).

The exact 40.10.2 pin (already on main) keeps the third mode — the native
@electron-internal/extract-zip win32 binding that 40.10.3/40.10.4 ship without a
published prebuild — from recurring.

Tests:
- test_desktop_electron_pin.py: replace the static-path-matches-lockfile
  invariant with contracts that there is no hardcoded electronDist to drift, the
  builder script routes through the resolver, and the resolver uses Node module
  resolution + injects -c.electronDist.
- test_gui_command.py: install-failure self-heal continues to build; genuine
  (electron-never-staged) install failure still hard-fails; pack retries under
  the mirror even when the pre-fetch is blocked.

Salvages/supersedes the overlapping community work in #48003 (sitkarev),
#48012 (omegazheng), #48033 (james47kjv), and #48082.

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>

* fix(desktop): narrow Electron self-heal to real missing-dist failures

Follow-up on #48091 to remove the remaining misdiagnosis risk from the
installer/build fallback path (#46785 concern): only take the Electron
repair/retry path when Electron's package files are staged and dist is actually
missing/corrupt.

- main.py: add _electron_pkg_staged_missing_dist() and use it to gate install
  failure recovery; fail fast for unrelated npm install errors.
- main.py/install.sh/install.ps1: run cache purge + retry only when dist is
  missing; do not retry unrelated tsc/vite/build failures under an
  Electron-specific narrative.
- install.sh/install.ps1: tighten install-stage self-heal guard to require both
  package.json + install.js and missing dist.
- tests: add coverage that install failure hard-fails when Electron dist already
  exists, and update retry test to reflect the tightened recovery condition.

Validation:
- Python tests: 64 passed
- install.sh-related tests included in the run
- Real mac build on this machine:
  - npm ci at repo root: success
  - cd apps/desktop && npm run pack: success
  - electron-builder packaged darwin arm64 and used custom unpacked Electron dist

* refactor(desktop): trim electron self-heal helpers and comments

Deduplicate mirror-retry into _try_redownload_electron_dist / shell
counterparts; shorten wrapper and install-script commentary without
changing recovery semantics.

---------

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
2026-06-17 18:48:35 -05:00
Teknium
f8098c6b6f
fix(desktop): resolve electronDist to the actual electron install location (#48081)
After the June lockfile regeneration (#46652) floated electron and reshuffled
npm workspace hoisting, the desktop pack fails with "The specified electronDist
does not exist". apps/desktop/package.json pointed electronDist at the repo
root (../../node_modules/electron/dist) while npm now installs electron nested
under apps/desktop/node_modules/electron. The two contradict, so a clean
install can never package the app (Windows + macOS).

- electronDist -> node_modules/electron/dist (resolved relative to apps/desktop,
  i.e. the workspace-local install npm actually produces).
- hermes_cli/main.py, scripts/install.sh, scripts/install.ps1: add a runtime
  electron-dir resolver that prefers apps/desktop/node_modules/electron and
  falls back to the root hoist, so dist checks + the mirror re-download work
  under either npm layout.
- patch-electron-builder-mac-binary.cjs: try the workspace-local Electron.app
  before the root hoist in the macOS binary-restore fallback (sibling site no
  PR touched).
- test: assert build.electronDist resolves to where the lockfile installs
  electron, so a future hoist change (root <-> nested) can't silently break it.

Salvages the overlapping work in #48003 (sitkarev), #48012 (omegazheng), and
#48033 (james47kjv).

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
2026-06-17 18:08:01 -05:00
xxxigm
33b1d14459
fix(desktop): pin Electron below the broken native extract-zip install (#47792)
* fix(desktop): pin Electron below the broken native extract-zip install

The Windows desktop install fails at "Building desktop app": Electron's
postinstall aborts with `ERR_DLOPEN_FAILED loading
index.win32-x64-msvc.node` / "Cannot find native binding" from
`@electron-internal/extract-zip`.

Root cause is a dependency drift, not the user's machine. Electron changed
its install mechanism mid-patch-series:

  electron 40.9.3 .. 40.10.2  -> @electron/get@^2 + extract-zip@^2 (pure JS)
  electron 40.10.3 / 40.10.4  -> @electron/get@^5 + @electron-internal/extract-zip@^1 (native napi)

apps/desktop declares `electronVersion: 40.9.3` (the tested, JS-extract
build) but pinned the dependency as `electron: ^40.9.3`, so `npm ci`/`npm
install` silently resolved 40.10.3/40.10.4 — onto the brand-new native
extract-zip whose win32-x64 binding fails to dlopen on some Windows hosts.
The committed lockfile already carried 40.10.3, and the installer's mirror
fallback can't help (it re-runs Electron's own `install.js`, which uses the
same broken native module).

Fix:
- Pin `electron` to an exact `40.10.2` — the newest build before the native
  extract-zip switch — and align `build.electronVersion` to match (Electron
  Builder needs electronVersion/electronDist to match the installed binary).
- Add a root `yauzl: ^3.3.1` override so the (re-introduced) JS extract-zip
  path also works on Node >= 24.16 / >= 26.1, where the old yauzl hangs.
  This is the same workaround the wider Electron ecosystem adopted.
- Regenerate package-lock.json: drops @electron-internal/extract-zip and
  @electron/get@5, restores @electron/get@2 + extract-zip@2 + yauzl@3.4.0.

* test(desktop): lock the Electron pin/version/lockfile consistency contract

Guards against the dependency drift that broke the Windows desktop install:
the Electron dependency must be an exact version, must equal
build.electronVersion, and the lockfile must resolve to that same version so
`npm ci` installs exactly what electron-builder packages. Asserts the
relationships, not a specific version number.
2026-06-17 14:42:30 -04:00
Austin Pickett
368fcf1ff0
fix(desktop): read HERMES_HOME from the Windows registry when env is stale (#46772)
A GUI app launched from Explorer inherits the environment block captured at
login, so a HERMES_HOME set via 'setx' AFTER login is invisible in process.env
even though the CLI (a fresh shell) sees it. The desktop then silently fell
back to %LOCALAPPDATA%\hermes and reported 'No inference provider configured'
despite a valid configured home (#45471).

resolveHermesHome() now consults the live HKCU\Environment registry value on
Windows before the LOCALAPPDATA default. New windows-user-env.cjs helper parses
'reg query' output, expands %VAR% refs, and fails safe (returns null off-Windows,
on spawn error, or empty value). The registry value is normalized through the
same normalizeHermesHomeRoot() path as the env var for consistency.

Co-authored-by: jeffrobodie-glitch <jeffrobodie@gmail.com>
2026-06-15 15:16:55 -05:00
ChasLui
f3b32e9f52 fix(desktop): restore Electron binary before macOS pack rename (salvage #38673)
electron-builder 26.8.x can stage an Electron.app without its
Contents/MacOS/Electron binary, then fail renaming it to Hermes:

    ENOENT: no such file or directory, rename .../MacOS/Electron -> .../MacOS/Hermes

This breaks `npm run pack` and the installer desktop stage before a
launchable Hermes.app exists.

- Point build.electronDist at the already-installed Electron dist so
  electron-builder reuses it instead of re-unpacking from cache.
- Add a darwin-only prebuilder patch that restores the missing main
  binary from the runtime dist before the rename. Idempotent (marker
  guard), soft-fails on shape mismatch, survives node_modules reinstall.

Co-authored-by: ChasLui <chaslui@outlook.com>
2026-06-15 13:53:01 -05:00
Brooklyn Nicholson
76b93869d8 fix(desktop): rebuild thread autoscroll on use-stick-to-bottom 2026-06-13 01:57:30 -05:00
Brooklyn Nicholson
edc36f3a45 perf(desktop): incremental markdown rendering during streams
Re-parsing the full message markdown every reveal frame is O(N^2) over a
long answer and dominated stream CPU.

- Throttle useSmoothReveal commits to ~1 frame (REVEAL_MIN_COMMIT_MS).
- Memoize block parsing with an LRU keyed on source text so only changed
  blocks re-parse.
- Replace Streamdown's full-text parseIncompleteMarkdown with a
  tail-bounded remend: scan to the last top-level boundary outside
  fences/math and repair only the trailing open block. New remend-tail.ts
  is proven render-equivalent to full remend at every streaming prefix
  (remend-tail.test.ts), minus an intentional, documented divergence on
  cross-block dangling openers.
2026-06-12 21:07:36 -05:00
ethernet
1e25358a8f refactor(desktop): use port 0 for ephemeral port discovery instead of PortPool reservation
Replace the PortPool-based port reservation system (9120-9199 range) with OS-assigned ephemeral ports via --port 0.

Before: Desktop probed a hardcoded port range, reserved ports in-process to close TOCTOU races, and passed the chosen port to the dashboard via CLI arg.

After: Desktop spawns dashboard with --port 0, parses the actual port from a stdout announcement line (HERMES_DASHBOARD_READY port=<N>), and uses that for WebSocket connections.

Changes:
- web_server.py: add --port 0 support with SO_REUSEADDR pre-bind + announcement; add EADDRINUSE preflight for explicit ports
- main.cjs: remove PortPool, PORT_FLOOR/CEILING, pickPort(), isPortAvailable(); add waitForDashboardPort() stdout parser
- Delete port-pool.cjs and port-pool.test.cjs (106 lines removed)

Net effect: eliminates the entire TOCTOU-mitigation reservation infrastructure and arbitrary port range constraints. OS handles port allocation natively.
2026-06-12 14:02:19 -04:00
y0shualee
9c50521704 fix(desktop): complete backend PATH for Homebrew Codex
macOS Desktop backend processes can still miss Apple Silicon Homebrew paths even after adding Hermes-managed Node and venv bins. That leaves `/codex-runtime on` unable to find a Homebrew-installed `codex` binary at `/opt/homebrew/bin/codex`.

Add a small testable backend env helper that builds the dashboard subprocess environment in one place. It prepends Hermes-managed Node and venv bins, appends missing POSIX sane PATH entries individually, preserves caller precedence without duplicates, and keeps Windows PATH casing/delimiters intact.

Wire both source-checkout and active-install backend descriptors through the helper, and add Node regression coverage to the desktop platform test suite.
2026-06-12 03:03:44 -07:00
ethernet
906bee9cf7 fix(nix): natively compile and correctly stage node-pty for desktop app
- Add ELECTRON_SKIP_BINARY_DOWNLOAD=1 to nix/lib.nix to prevent offline download failures.
- Manually trigger native compilation of node-pty via npm rebuild --build-from-source in buildPhase.
- Run stage-native-deps.cjs to copy the natively compiled binary into build/native-deps.
- Flatten native-deps and install-stamp.json to the root of the output derivation in installPhase, matching electron-builder's extraResources behavior so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'.
- Add doCheck=true and a strict checkPhase to fail fast if the staged native binary is missing.
2026-06-12 03:55:09 -04:00
Mani Saint-Victor, MD
9ff0ba0827 fix(desktop): prevent backend port-squat boot loop and pickPort self-collision
Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module:

1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting.

2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window.

PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script.

(cherry picked from commit d4133945b9)
2026-06-11 18:22:54 -05:00