Reconcile the salvaged #81533 lifecycle helper with the renderer-log
console pipeline that landed in #83535 (the two PRs raced):
- window-renderer-lifecycle.ts no longer handles console-message —
renderer-log.ts is the single owner (per-window labels, boundary
reports). One owner means no double-logged errors on windows wearing
both, and OAuth/portal windows (lifecycle-wired for process events)
cannot spill third-party page console output into desktop.log.
- wake indicator window gets attachRendererConsoleCapture, keeping the
console coverage it previously got from the helper.
- HUD window (added after the PR branched) gets log-only lifecycle
coverage — it was the one renderer window the PR couldn't have known
about.
- Tests updated: lifecycle helper asserts it attaches NO console-message
listener; parser tests live in renderer-log.test.ts.
@spfcraze's triage review noted the PR description claimed "every
BrowserWindow" but the OAuth and portal sign-in windows were not wired:
a crashed sign-in renderer leaves the window's promise path never
settling, with no trace in desktop.log.
Wire both with the same log-only lifecycle diagnostics as the overlay
and quick windows — `kind: 'oauth'` and `kind: 'portal'` respectively.
Neither window gets crash-reload treatment (a sign-in window that
reloads itself mid-auth would be surprising); the lifecycle helper's
log-only callback is the exact contract needed here.
window-renderer-lifecycle.test.ts: 17/17 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three-part class closure for the React #310 / lost-renderer-crash family
(#79428, follow-up to #80560 / #82763):
1. Diagnosability (#79428 defect B): error-boundary catches now persist to
desktop.log with their component stack via a new fire-and-forget
hermes:logs:renderer-error IPC (synchronous flush — the window may be
dying). Every renderer-content window (main, secondary session, instance,
HUD, quick entry, pet overlay) gets the error-level console capture that
previously only the main window had, labeled per window. 'Open logs' on
the crash dialog now reveals a file that actually contains the crash.
2. Recurrence guard: eslint no-restricted-syntax rule banning inline
render() calls in JSX — the mechanism behind #80560. The rule
immediately caught two live sites #82763's audit missed (floating
panes, narrow-overlay reveal), both hosting plugin panes.
3. Fix those two missed sites with the same ContribRender mount.
extracted console-capture/report formatting to electron/renderer-log.ts
with unit tests; renderer console lines now carry the window label.
An unclean desktop exit (crash / SIGKILL / update handoff) stranded every
`hermes serve` profile backend as an orphan (ppid=1) still serving, each
holding its MCP child subtree — 31 orphans / ~1.3 GiB RSS on one install.
Root causes + fixes:
- serve had no parent-death watchdog: add _start_parent_death_watchdog() in
web_server.py (mirrors slash_worker.py), gated on HERMES_PARENT_PID; os._exit
cascades to MCP watchdogs. No-op for standalone `hermes serve`.
- desktop passes HERMES_PARENT_PID in both serve spawn env blocks (main.ts).
- POSIX teardown now group-kills (process.kill(-pid, ...)) so MCP grandchildren
die too (backend-child.ts + waitForBackendExit SIGKILL fallback).
Windows path unchanged (forceKillProcessTree). Tests updated + passing.
The floating HUD inherited the default Hermes title from index.html.
Set it explicitly in main and the renderer so the OS window label
matches the mode.
Tahoe already aligns traffic lights without the optical translate. In
fullscreen, drop windowButtonPosition in the main process and clear the
right-cluster inset so traffic-light dodge chrome goes away on both sides.
Register CommandOrControl+Shift+G in main while HUD mode is open so the
floating bar can jump under the pointer from any app. Tap-to-snap only —
Electron globalShortcut has no keyup for hold-to-follow.
* fix(desktop): stop HUD window growing on drag; add corner resize handle
The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.
Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
drag, so the OS can never accumulate drift (verified: 500 moveBy calls
with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
the ability to resize a window that is otherwise non-resizable
* fix(desktop): pin HUD drag size in renderer, not main-process globals
The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.
Supersedes #82455.
Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state
The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.
Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.
---------
Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>
A session row can say whether its work is open, merged or closed, and link
to it. The join is the session's own repo + branch, asked of GitHub in one
batched GraphQL request per repo (branch aliases, not a `gh pr list` page
that a busy repo crowds ours out of), through the remote-aware git facade so
a desktop on a remote gateway asks the backend's `gh`.
Two ways a session's branch can't answer, both covered:
- It ran on trunk. Fork PRs share our branch namespace, so asking about
`main` badges a stranger's PR onto it — trunk is never asked about, and
cross-repository PRs are dropped server-side either way.
- It worked in a worktree, so the branch it recorded at start isn't where
the PR came from. Creating a PR from the review pane binds the session to
the branch it actually used, and for sessions that predate that, the PR is
recovered from the transcript: `gh pr create` prints a bare PR url and
nothing else, so a tool result whose whole output is one is a claim rather
than a mention. Scanned read-only across profiles, once per session ever.
* fix(desktop): open HUD mode on the focused conversation's profile
The HUD is a full app renderer that adopted the PRIMARY backend's
profile at boot, so toggling HUD mode from a conversation on any other
profile resolved the session id against the wrong backend — the lookup
missed and the HUD fell back to the default profile's last session
(#82285).
- openHud() resolves the target's owning profile (session's stamped
owner, else the active gateway profile) and passes it through
hermes:hud:open.
- hudUrl() carries the profile in the query string next to win=hud;
the HUD renderer's gateway boot honors it as an override for both
getConnection() and profile adoption, so the window dials and adopts
the right backend from first paint.
- Retargeting a live HUD onto a session from a DIFFERENT profile
respawns the window against that profile's backend (a renderer adopts
its backend exactly once at boot; an in-place goto would repeat the
wrong-backend lookup).
No profile in the URL means no override — ordinary windows and
single-profile users boot exactly as before.
* refactor(desktop): extract the HUD renderer URL so its contract is tested
hudUrl() built the query string inline in main.ts, where the part that
actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or
HashRouter eats it as the route — had no coverage. Move it next to
buildSessionWindowUrl's split (pure piece out of the monolith, unit
tested) and pin the contract: flag order, profile encoding, trailing
slash on the dev server, empty profile omitted, packaged file URL.
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
* refactor(desktop): resolve the HUD's target profile through the existing ladder
openHud() had its own copy of "stamped owner, else active gateway, else
default" — the same ladder rememberedSessionProfile() already owns for
the remembered-navigation key, down to sessionMatchesStoredId and the
default fallback. One resolver per policy, so the two can't drift.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
Review feedback on the #82328/#82366 hand-off, all four points plus the
missing progress GUI:
1. FAIL CLOSED. Both preflight gates aborted-open: a Desktop still alive
after 30s proceeded anyway, and a shim locked after 20s proceeded
with --force - both mutate a potentially locked install (the exact
Access-denied brick class). Now: desktop-alive -> exit 4, nothing
changed; shim-locked -> exit 5, nothing changed. Both relaunch the
Desktop so the user is never stranded.
2. TRUTHFUL COMPLETION. `hermes update` treats a Desktop GUI build
failure as non-fatal (warns, exits 0) - correct for CLI use, a lie
for a Desktop-driven update that then relaunches the OLD exe as
"success". The script now detects the warning in the update output,
retries the build once (`hermes desktop --force-build --build-only`),
and exits 6 with an honest message when it still fails.
3. MARKER OWNERSHIP. Cleanup now removes the marker only while OUR pid
still owns it - a handoff partner that rewrote the marker keeps its
claim (same rule as UpdateLock.release).
4. RESULT SURFACING. The script writes .hermes-update-result.json on
every exit path (ok, exit_code, message, branch, finished_at). New
electron/handoff-result.ts consumes it exactly once at the boot
update-gate: success logs, failure shows a real dialog pointing at
desktop-update-handoff.log. Stale (>30min) and malformed results are
consumed silently. Previously a failed detached update was
indistinguishable from "nothing happened" - the exact live report
that triggered this work.
5. PROGRESS UI. The old Tauri updater showed a window; the script ran
in a hidden console with zero feedback. It now shows a WinForms
progress window (marquee bar + streaming log) pumped via DoEvents
during the update; -NoUi keeps tests/headless sessions clean, and a
WinForms-unavailable session degrades to log-only.
Also: subprocess execution moved from Start-Process (ExitCode
unreliably $null under PS 5.1 even with the Handle workaround -
observed live: happy path reported "failed (exit )") to
System.Diagnostics.Process with synchronous stdout pumping, which
keeps the UI alive and the exit code real.
E2E on a real Windows box, sandbox HERMES_HOME + compiled fake
hermes.exe, all five paths:
- happy: exit 0, result {ok:true, "Update complete."}
- shim held open via O_RDWR: exit 5, nothing mutated, honest result
- desktop pid alive (60s ping child): exit 4 after the 30s gate
- update exits 0 printing "Desktop build failed" + rebuild fails:
exit 6, result names the stale build and the retry command
- foreign-owned marker: overwritten by step-0 claim, removed as owner;
ownership check verified in the cleanup path
vitest 18/18 (5 new handoff-result tests), typecheck 3 projects clean,
eslint clean, PS 5.1 parse + footguns + ASCII-only clean.
Remaining known gap (deliberate): the full click-to-relaunch lifecycle
through a REAL Desktop build still needs one live Windows verification
after this lands - tracked in the PR body.
Live failure on the first real use of #82328 (2026-08-09): clicking
Update closed the Desktop with "an updater will happen", then nothing.
desktop.log showed `launched repo hand-off script`, but
desktop-update-handoff.log was never created - PowerShell exited 0
without executing a single line.
Root cause, isolated by spawning the exact production shape against a
sandbox HERMES_HOME: `spawn('powershell', [..., '-File', script],
{ detached: true, stdio: 'ignore', windowsHide: true })` kills
powershell.exe during console-subsystem init, before -File processing.
Variant matrix: plain pipes -> runs; hide only -> runs; detached only ->
runs; detached+hide -> exits 0, script never starts. Unit tests and
foreground invocations can't see this class of bug.
Fix: wrapHandoffForDetachedConsole() routes the invocation through
`cmd /d /s /c start "" /min powershell ...` - `start` allocates the
script its own minimized console and fully detaches it; the cmd wrapper
exits immediately. Verified the wrapped form survives the full
detached+hidden production spawn.
Knock-on: child.pid is now the short-lived wrapper, not the script, so
the Electron-side marker pre-write can't represent the script. The
script now claims the update marker itself as step 0 (its own $PID,
byte-exact "<pid>\n<ts>\n" via WriteAllText - Set-Content emits CRLF
and would break the three readers' framing). The Electron pre-write is
kept as a bridge for the spawn window: the script overwrites it, and if
the script never starts the wrapper's dead pid reads as stale and
self-deletes (no wedge). `hermes update` adopts the script's claim via
update_lock.py's process-ancestry rule, unchanged.
E2E in exact production shape (cmd start wrapper, detached, hidden,
parent exits 1.5s after spawn) against a sandbox HERMES_HOME with a
compiled fake hermes.exe: script ran, claimed marker with its own pid
(fake observed "<script-pid>|<ts>|" LF-framed DURING the update),
desktop-pid wait worked, update invoked with correct argv, marker
removed on completion. vitest 13/13 (new wrapper-shape test), 3-project
typecheck clean, eslint clean, PS 5.1 parse + windows-footguns clean.
The Desktop's Update button hands off to the staged Tauri binary
(HERMES_HOME/hermes-setup.exe). That binary has no self-update path
(copy_self_to_hermes_home no-ops during --update), so every updater-side
fix only reaches users when a new installer is built, signed, and
published. In practice the published binary lags main by months and
users hit long-fixed bugs on every GUI update: the 2026-08-09 incident
chain was four distinct failures (stale install.ps1 cache resolver
pre-#67369, marker adoption pre-#74782, straggler teardown) all caused
by a June 4 binary running against an August repo.
This inverts ownership: scripts/desktop-update.ps1 lives in the repo
checkout, so every `hermes update` refreshes the code that drives the
NEXT update. Only PowerShell itself - an OS component - stays frozen.
Desktop side (apps/desktop/electron):
- resolveUpdateScriptHandoff() (updater-process.ts): returns the spawn
recipe when scripts/desktop-update.ps1 exists in the checkout;
Windows-only (POSIX updates in place via applyUpdatesPosixInApp);
null on old checkouts -> caller falls back to the staged binary path
completely unchanged.
- applyUpdates() prefers the script hand-off. The marker pre-write is
ALWAYS safe on this path - no stagedUpdaterSupportsPrewrittenMarker()
mtime heuristics - because hermes_cli/update_lock.py's UpdateLock
adopts a live marker held by a process ANCESTOR, and the script is
the `hermes update` child's parent. This closes the unguarded
marker-gap window that pre-#74782 binaries force today (the 23:56
failure in the incident: 'skipping marker pre-write: staged updater
predates self-adopt' -> renderer respawned a backend into the gap ->
update refused).
- CLI-installed users (no staged binary) now get the script hand-off
too instead of the manual `hermes update` card, when the script
exists.
Script (scripts/desktop-update.ps1): waits for the Desktop pid to exit
(bounded 30s), waits for the venv shim to unlock (mirrors the Rust
is_locked probe, bounded 20s), runs `hermes update --yes --gateway
--force --branch <ref>` from the CURRENT checkout with one retry for
the update-boundary class (skipped for exit 2), removes the marker on
every exit path, relaunches the Desktop. ASCII-only (the #67193
lesson), logs to logs/desktop-update-handoff.log.
Verification (real Windows box):
- apps/desktop: typecheck (3 projects) clean, eslint clean, vitest
updater-process.test.ts 12/12 (3 new resolver tests).
- Script E2E against a sandbox HERMES_HOME with a compiled fake
hermes.exe: correct argv (update --yes --gateway --force --branch
main), stale marker removed, exit code propagated (0 and 1 paths),
retry-once fires exactly once on failure, PS 5.1 parse + windows
footguns check clean.
- Contract E2E with the real UpdateLock: ancestor-owned marker adopted
(True), left in place on release, foreign live holder still refused.
When enumeration was impossible the tool answered "could not determine the
window underneath (the desktop app did not answer, or window enumeration is
unavailable on this system)" — true, and a dead end. On Linux the two ways it
fails have opposite fixes and neither is guessable from that: a Wayland session
withholds window identity from applications outright, while an X11 session
needs xprop and xwininfo installed, because that is what the enumerator shells
out to.
Answer with the reason instead of nothing. A session with both WAYLAND_DISPLAY
and DISPLAY is XWayland, where xprop can still answer, so it gets the tooling
advice rather than being told to change session type.
Click-through decides whether to swallow the mouse by hit-testing the document
under the cursor, and it learns where the cursor is from mousemove. Those keep
arriving while the window ignores the mouse only because of
`setIgnoreMouseEvents(true, { forward: true })`, and `forward` is
`@platform darwin,win32`. On Linux the moves stop the instant the HUD turns
click-through, so it never sees the pointer return to the bar: the bar is
visible, and clicking it hits whatever is behind.
Main can still see the cursor, so on Linux it polls and pushes the position to
the renderer, which runs its usual hit test on it. The decision and its rules
stay in one place — only the courier for that one input changes — and off-window
is sent as null, which is already how the renderer hands the mouse back.
`moved` and `resized` are macOS/Windows only — Electron tags them
`@platform darwin,win32` — so on Linux neither the main window nor the HUD
ever heard that it had been dragged or resized, and both reopened at their
default placement every launch. The main window had a `close` flush to fall
back on; the HUD had nothing, so its position was lost outright.
Bind `move`/`resize` instead. Those carry no platform tag and fire everywhere,
and the trailing debounce already collapses the mid-drag stream a settled event
would have saved us from.
Follow-up to #82179 addressing helix4u's review comment
(#82179 issuecomment-5229441571). Three parts:
1. Desktop teardown (salvaged from #77436, @4adwentures): the update
hand-off's releaseBackendLock() sent SIGTERM to the primary backend
BEFORE taskkill /T. If the launcher exits first, Windows can no longer
enumerate its descendants and they survive holding the venv — the
Electron path that creates the orphan #82179 then has to repair.
New stopBackendTreesForUpdate() tree-kills the live root first, with
the behavioral vitest from #77436. The scanner half of #77436 is
deliberately NOT taken (superseded by #82158's full-cmdline scan).
2. Tree-aware orphan classification: _orphaned_desktop_backend_pids()
previously refused the whole holder set when any holder had a live
parent. But the scanner legitimately returns an orphaned serve root
AND its descendants (the venv trampoline's uv-managed interpreter
worker — which carries the same backend argv — plus .hermes-runtime
children). Those have a live parent: the orphan root itself. Now
holders inside an accepted orphan root's tree fold into that root
(only roots are returned; taskkill /T reaps descendants), and
live-parent backends defer to the ancestry check instead of refusing
outright. Anything outside an orphan tree still refuses.
3. Tests for the mixed shapes: root+managed-runtime child,
grandchild depth, non-descendant stray alongside an orphan root
(still refuses), descendant exited mid-classify.
E2E on a real Windows box: spawned a detached backend-shaped orphan
that itself spawned children (3 python descendants); the scanner-shaped
mixed holder set classified to [root], taskkill /T reaped root and all
descendants. The live Desktop backend on the box still classified None
(refusal preserved). The first E2E attempt caught exactly the
trampoline/worker case the mocks missed — the live worker re-execs with
the same backend argv and a live parent — which is what part 2 fixes.
Co-Authored-By: 4adwentures <296413879+4adwentures@users.noreply.github.com>
`-webkit-app-region: drag` and `useHudClickThrough` cannot share a window, and
every previous attempt at a HUD grab handle had them fighting. The window
manager takes a draggable region's mouse input whole, so the page never sees
the cursor arrive on the handle — and click-through, which decides whether the
window is solid from exactly those moves, has already handed the window to the
desktop by the time you press. The handle was unusable (the press fell through
to the app behind) and, having eaten the moves on the way out, it also left the
HUD solid over its own dead space so clicks meant for the app behind died in
empty window.
So no HUD surface declares a drag region any more, and dragging is a press and
hold on the bar: 140ms to arm, then the renderer moves the window through a new
`hud.moveBy`. Deltas are read in screen coordinates, because client coordinates
are relative to the window being moved and report zero once it keeps up with
the cursor. The pointer is captured and the window pinned solid for the
duration, so a fast drag cannot outrun the bar.
Removes the 2rem invisible drag strip along with it — dead window that only
ever swallowed clicks aimed at whatever was behind the HUD.
Session, instance, HUD, quick-entry and pet-overlay windows all open with
show: false and are revealed only by ready-to-show, so the Electron 40 bug
strands them exactly the way it stranded the primary window — and none of
them have the second-launch workaround that made the main-window case
recoverable.
Generalize the controller to any window and wire all six through one
wireWindowReveal helper. Callers pass their own reveal action (showInactive
for the pet overlay, show + focus for the HUD and quick entry) and their own
post-visible work, so whichever path wins runs them exactly once.
Quick entry now reveals the window the call created rather than whatever
`quickEntryWindow` points at when the event lands.
Desktop E2E is hard-disabled in ci.yml (#76627) because the mock-backend
window never reaches a usable state, so nothing can validate dropping the
TEST_WORKER_INDEX force-show right now — and the suite's lead symptom is
already a window-readiness failure. Restore it, routed through the reveal
controller so the bookkeeping in onRevealed still runs exactly once, and
leave the removal to whoever re-enables the suite.
The desktop_ui and post-hook ownership contract tests enumerate their tool
sets exactly — add read_window_below to both (plus the executor-path
parametrize case). Lint: sorted type import, explicit GetWindowsModule type
instead of an import() annotation, curly + blank-line style.
New electron/window-below.ts: pure z-order picker (walks past our own pid,
first other-process window whose bounds overlap ours) over get-windows'
front-to-back enumeration, with the Linux xprop stacking order reversed to
match (EWMH _NET_CLIENT_LIST_STACKING is bottom-to-top). Main answers the
hermes🪟readBelow IPC; on macOS other apps' titles pass through only
when Screen Recording is already granted — never prompted for.
The band is real macOS vibrancy now rather than backdrop-filter, which
reaches nothing in a transparent window — its backdrop root is the document,
and the desktop was never in it. Vibrancy composites below the web contents,
so it can see the desktop, and it can't be masked or clipped from the page.
That rules out the gradient the band used to carry and settles it as a flat
panel: uniform tint, uniform frost.
The fade does the work the gradient was doing. A landing turn brings the
transcript half way up to be glanced at, focus promotes it to properly
readable, and the hold takes it back down and then away — the panel sliding
behind the bar as the last of the text goes.
It only fades from an idle transcript. A running turn or a question waiting
on you holds it open, because a prompt that fades out is one you can neither
read nor answer, and the hold timer alone would expire through a long tool
call that prints nothing.
Asking for HUD mode from another tab used to just raise whatever the HUD
already had, so the conversation you were looking at never arrived. Main
now retargets the window and tells every renderer where it is pointed, so
the toggle keeps reading "switch" rather than "dismiss".
The remote interceptor rebuilt session/messages requests from pathname
only, silently dropping limit/offset/order. Against a paginating remote
backend, getAllSessionMessages would refetch the same default page until
the safe-load guard threw, breaking export/artifacts/branch for remote
sessions over one page.
A transparent, frameless, always-on-top window that renders the real chat
surface, so its composer is the app's composer rather than a lookalike that
drifts. Main owns the window, its remembered geometry, and click-through.
Leaving is a handoff, not a window close. The gateway binds a session's event
stream to exactly one socket, so a turn started in the HUD streams only there
and the app window hears nothing — no deltas, no turn-complete, no draft clear,
and nothing to poll for mid-turn. So the app re-resumes the session the HUD
ended on, which rebinds the transport, reconciles the transcript, and picks up
an in-flight turn. Main carries the session id across, since it is the only
party that outlives the HUD's renderer.
PDFs were classified as generic binary/text previews, rendering raw %PDF
bytes locally and failing entirely for remote-only files. Classify PDFs as
their own preview kind, load bytes through the existing local/remote
filesystem bridge, convert them to revocable Blob URLs for Chromium's
embedded viewer, migrate persisted pre-PDF tabs at restore, and retry
restored previews when the active filesystem connection changes.
Salvaged from #76008-era base onto current main: PDF classification now
composes with the remote-HTML enrichment branch, and the persisted-tab
migration runs before the One-Browser URL rekey in decodePreviewTabs.
Supersedes #76565.
Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
Export stages desktop.json (skin + mode, bundled user-theme definitions,
rail color, layout tree) into the CLI's own profile archive; import applies
it, so the receiver gets the whole look as a ready-to-use profile. Doors:
Export/Import profile... in Cmd-K, an import button beside the rail's +,
and Export in each profile square's context menu. New selectSavePath IPC
(native save dialog); credentials never leave the machine (CLI filter).
Desktop plugins can toast in-app (host.notify) but have no sanctioned way to
reach the OS notification pipeline the app's own approval/turn alerts use, so
a plugin surfacing a genuinely notable background event (e.g. a discovery
plugin finding a match) stays invisible once the user steps away from Hermes.
Add a curated per-plugin door instead of exporting the raw dispatcher:
- ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed
to the plugin id, routed through dispatchNativeNotification so every
existing gate applies (master + per-kind prefs, post-connect baseline,
away-from-app gating, throttle).
- New 'plugin' native-notification kind with its own Settings ▸ Notifications
toggle (default on), so users silence plugins without losing app alerts.
- New optional `tag` discriminator on the notify payload keys the renderer
throttle and main-process cross-window dedupe per plugin, so two plugins
can't collapse each other's session-less notifications.
Consumer: the Index Network desktop plugin wants background opportunity
alerts; anything in ~/.hermes/desktop-plugins gets the same door.
Copying a Discord thread (or any rich-text selection with images) attached
one or more blank thumbnails and dropped the message text entirely.
Two causes. The clipboard's `text/html` was scraped for inline
`<img src="data:…">` regardless of whether the copy carried its own text —
and what Discord ships beside each image embed is a 32x5 blurhash
placeholder, which is exactly the blank attachment. Then, because any image
blob short-circuited the paste handler, the prose that came with it never
reached the composer.
Inline HTML images now only count for an image-only copy, and are ignored
below a thumbnail-sized floor so spacers and trackers don't attach either. A
mixed paste attaches its real images and still inserts its text.
Also registers pasteAndMatchStyle in the Edit menu — Cmd+Shift+V had no menu
entry, so the chord was never translated into an editor command anywhere in
the app.
#76499 landed the npm floor as >=11.17.0 on a Node >=20 baseline. This
branch takes the other half of the same constraint: the vendored Node 26
tree now installs npm 12 into itself, so the toolchain satisfies the
stricter floor rather than the manifest relaxing to meet the tarball.
Resolved package.json + package-lock.json to node >=26.0.0 / npm >=12.0.0
and refreshed npm_engine.py's illustrative range to match. website/'s
mirror keeps #76499's >=11.17.0 — it is not a root workspace and builds
on its own Node.
Both hand-off sites pre-write the update marker: the in-app Update button
(applyUpdates) and the Windows bootstrap-recovery path
(handOffWindowsBootstrapRecovery). Either one can strand a user on a
pre-#74782 staged installer, and the recovery path is worse — it fires
when the install is already unhealthy, so a refused claim there wedges
the very repair meant to heal it.
Route both through stagedUpdaterSupportsPrewrittenMarker and log the skip
so the reason is visible in desktop.log instead of looking like a missing
write.
Also document on copy_self_to_hermes_home that its --update no-op is what
lets an installer-protocol change strand the entire installed base on a
binary that predates it — the root enabler of this class of bug.
Two paths let a pre-existing system Node win over the Hermes-managed one.
The desktop backend spawn built its managed-Node PATH entry as
`<home>/node/bin` only. That is the POSIX layout install.sh produces;
install.ps1 unpacks portable Node straight into `%LOCALAPPDATA%\hermes\node`
with node.exe at the root and no `bin\`. On Windows the entry therefore
pointed at a directory that does not exist, and the backend fell through to
whatever Node was already on PATH.
main.ts already had the correct platform-ordered list, behind a "keep this
in sync with iter_hermes_node_dirs()" comment on a second copy of the rule.
The two copies had drifted. Export the ordering from backend-env.ts and have
main.ts consume it so there is one source of truth on the Node side (the
Electron main process cannot import hermes_constants.py, so a mirror is
unavoidable — but one mirror, not two).
install.ps1 appended the node dir to the persisted User PATH instead of
prepending it. The session PATH was already prepended correctly, so this only
bit later processes: any shell opened after install, and a standalone
hermes-setup.exe run that inherits User PATH rather than a curated env, both
resolved a system Node ahead of the bundled one.
Not a bug, for the record: update.rs's prepend list omits the same Windows
root, but it inherits PATH from the desktop, which supplies the correct
entries — so it is redundant rather than broken, and no installer rebuild is
needed for this fix.
Tests: managed dirs lead with the platform-native layout while always
offering both shapes, empty without a home, and every managed dir outranks
the inherited PATH on darwin and win32. The three existing tests that pinned
`entries[1]` by index asserted the old single-dir shape and now assert the
relationship instead.
install.ps1 has no behavioral test here: CI has no PowerShell host, and
AGENTS.md bans source-reading tests (the neighbouring
test_install_ps1_node_path_for_npm.py predates that rule).
handOffWindowsBootstrapRecovery() writes the update marker
unconditionally, same as applyUpdates() before the previous commit.
It's reachable during boot whenever resolveHermesBackend reports
bootstrap-needed, which a relaunch mid-update can plausibly trigger
on Windows -- clobbering a live updater's marker through this second
path. Apply the same updateHandoffConflict() guard here: refuse to
spawn a second updater when one is already alive, and quit instead
so the live updater can finish and restart us.
writeUpdateMarker unconditionally overwrites HERMES_HOME/.hermes-update-in-progress
before every hand-off. If the user retries "Update" while a prior updater is
still alive and parked (e.g. waiting for the desktop to exit), the retry's
pre-write clobbers the still-running updater's claim, so the older updater is
no longer recorded as the owner even though it's actively mutating the
checkout. A second updater can then run concurrently over the same tree.
Add updateHandoffConflict() to check for a live foreign marker owner before
spawning a new updater, and refuse the hand-off (surfacing an "update already
running" message) instead of overwriting the marker.
Ref: #75778
The shipped artwork bakes in the macOS-style ~10% transparent margin
(content covered only ~80% of the canvas), so the taskbar/titlebar icon
rendered visibly smaller than neighboring Windows apps, which draw
full-bleed.
- Regenerate assets/icon.ico full-bleed (~95% coverage) from the same
art, with the standard 16-256px frames. This feeds both the exe stamp
(set-exe-identity via rcedit) and the installer.
- On Windows, resolve the BrowserWindow icon from the full-bleed ico
(resources/icon.ico, shipped via extraResources) before falling back
to the padded apple-touch PNG.
macOS is untouched: the dock icon and icon.icns keep the padded art,
which is correct there.