The main-model apply test raises a success toast, which arms a 5 s
auto-dismiss timer. Nothing cleared it, so when it fired after the file's
jsdom window was gone, vitest reported "window is not defined" from
dismissNotification as an unhandled error and failed JS & TS checks with
every test green. afterEach now calls clearNotifications().
The cache lived inline in vite.config.ts, and the renderer builder tests use a bare fixture
config, so nothing exercised it: a broken warm path would still pass every test.
Move it to scripts/compiler-cache.mjs (behaviour unchanged; toolchain files and cache root are
now parameters, and an existing closeBundle hook is chained instead of replaced) and test hit
vs fresh output, toolchain/NODE_ENV generations, corrupt entries, pruning, incremental (watch)
rebuilds and the dev-server bypass. A cold and warm production build of the real app stay
byte-identical. Reported by Greptile on #135092.
nativeTreeComplete decides whether a later build may skip restaging, but never looked at
node-pty. A partially extracted package with pty.node and no spawn-helper was recorded
complete; repairing the installed package without a lockfile change then left the staged
copy broken, and every later build shipped a terminal that fails on its first spawn.
Check the layout node-pty itself loads (build/Release, build/Debug, then prebuilds, as in
lib/utils.js) for a binding, and on macOS for the spawn-helper beside it (lib/unixTerminal.js).
Reported by Greptile on #135092.
The compiler emits HMR cache-reset code when NODE_ENV is development, and the cache key ignored it, so a dev-server session could seed entries a later production build reused. A dev server also wrote one entry per save with no build to prune them. The cache now wraps only 'vite build', NODE_ENV joins the toolchain key, and entries drop the map's sourcesContent (the module text the key already hashes; restored on a hit): 22 MB -> 15 MB. Verified: warm == cold byte-identical without sourcemaps; with --sourcemap two cold builds already differ in one map's __VITE_ASSET__ placeholder order, so that diff is vite's, not the cache's.
rendererCurrent and productCurrent share hostReceipt() for the schema/product/host checks. The stamped outputs come from bundleElectronMain's own electronOutputs list, so a renamed or added bundle can never be copied stale from the previous dist. The reuse copy skips node_modules/ and native/, which copyNativeTree recopies from nativeDeps right after, and a --typecheck build never reuses a renderer that skipped tsc.
Skipping stage-native-deps on an admitted receipt kept a tree staged without a piece forever:
get-windows (or its win32 x64 binding, which only the native installer inside staging can fetch),
the macOS get-windows helper, or the host HUD helper (no X11 toolchain on Linux). The receipt
identity cannot see the host fix that would complete it.
The receipt now records `degraded` from the staged tree itself (nativeTreeComplete: every piece
this host can produce for the target is present), and build.mjs reuses only a complete one
(nativeInputsComplete). Gaps no host fix closes — win32-arm64 has no get-windows prebuild, a
cross-target pack never builds the helpers — do not count. Judging the tree rather than each
builder's return value covers soft-fail paths that still return a path (the win32 binding).
Review findings on the local build scripts:
- Cleanup ran before desktop.py took the checkout lock, so a second run deleted
the outputs of a build that was still going. desktop.py gains --clean, which
removes this checkout's outputs after build_lock is held. Both scripts pass it
instead of deleting files themselves.
- build-bundle.ps1 left RELEASE_CLAIM_* and HERMES_PAYLOAD_* in the caller's
session. It now saves those variables and restores them in the finally block.
- The scripts said "unsigned" but inherited signing credentials. The ps1 removes
AZURE_* for the build. The sh removes CSC_* and APPLE_* and sets
CSC_IDENTITY_AUTO_DISCOVERY=false, the switch the desktop rebuild command uses.
The wording now says what is ignored.
- The Store bundle step needs the claim timestamp, and the script deletes the
claim. The ps1 prints it as HERMES_RELEASE_EPOCH, and BUILDING.md documents it.
scripts/build-bundle.sh (macOS, Linux) and scripts/build-bundle.ps1 (Windows)
build the checked-out commit through scripts/bundles/desktop.py --commit. That
path needs no tag and no push.
Both scripts refuse a dirty tree before any work starts. Both delete the
previous build output first. The staged Windows payload is read-only, so a
plain delete in a reused checkout fails with WinError 5.
build-bundle.ps1 -Store builds the Microsoft Store MSIX. The driver admits
Store packaging only for a stable tag. The script makes a local claim tag
rc.<N>-vX.Y.Z for the next patch after the newest published stable tag. It
counts local and remote attempt refs and starts at 900, so it cannot collide
with a real attempt. A finally block deletes the tag. The script never pushes it.
- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf /
catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove,
the dashboard and plugins.manage settings all go through it, so the catalog name you
installed with keeps working (17 catalog entries install under another manifest name).
_catalog_install_on_disk reuses the same catalog lookup.
- Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps),
not manifest name, so a self-cloned package is one row instead of two.
- Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent
switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays
per half. Capability consent is untouched.
- docs: plugin-catalog page.
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
The Desktop loads a unified package's desktop half from the copy in
desktop-plugins/<name>/, not from the package. Editing the source shipped
nothing and nothing said so:
- a package symlinked into plugins/ (the in-place dev layout the Python
loader already follows) was never copied at all — Dirent.isDirectory()
is false for a symlink;
- an installed package edited in place stayed stale until Rescan/restart.
Electron now follows symlinked package dirs, marks their copy linked, and
re-syncs it on any byte difference. The renderer watches a linked copy's
source plugin.js and, on save, runs the reconcile and hot-reloads. Catalog
and git installs keep the mtime rule. `hermes plugins doctor` warns when
the copy differs from the source and says how to refresh it.
Two stable-release identity checks broke on the first pipeline cuts of v0.21.6:
- nix.yml "Verify release package runtime identity" took the LAST token of the
whole `hermes --version` output. That output ends with the OpenAI SDK line, so
the check compared 0.21.6 against 2.24.0 and failed every release. Parse the
first line ("Hermes Agent v<version> ...") instead.
- msix-shared.mjs releaseEpoch() still required the retired `vX.Y.Z-rc` claim
name. Attempt refs are `rc.<N>-vX.Y.Z` (versioning.parse_attempt_ref), so the
Windows universal bundle threw "Stable claim tag differs from its payload tag".
Accept exactly `rc.<N>-<tag>`; a different version (rc.3-v0.27.10 for v0.27.1)
still refuses.
* fix(desktop): never idle-reap a local profile backend
Support f502bc6f: with a remote primary, a "This device" profile's local
`hermes serve` child was SIGTERM'd by the pool idle reaper after 60 min with
nothing streamed (the #105239 pinned-tier TTL), leaving "This device ·
Backend offline" while the user still had the chat open.
A local backend runs its profile's cron jobs and bot chats with nobody
watching, so idleness is not a reason to kill it. The idle reaper now only
reclaims connection descriptors (remote routes: no child, no slot). Local
children are still bounded by the maxBackends slot cap and foreground
reclaim (activeTurn vetoed), unchanged. Removes POOL_PINNED_IDLE_MS.
The Backend Idle Timeout setting description now says it applies to remote
connections only.
E2E no-idle-reap.spec.ts: a pooled local profile backend survives two reaper
ticks past both idle windows. Fails on main (backend reaped), passes here.
* chore(desktop): drop the dead pinned-tier idle policy (retireIdle, lastStreamedAt)
Greptile P2 on #134942: with local children no longer idle-reaped, retireIdle
has no production caller and lastStreamedAt no reader. Removes both, the stamp
write in touchPoolBackend, and the pinned-tier test. The shared retirement
safeguards stay covered through evictTo (identity, admission permit, activeTurn
veto).
PowerShell errors span several lines (message, location, details). The
script tagged only the first physical line with the attempt, so the
parent's attempt filter kept that line and dropped the rest. Write-WaiterLog
now tags each non-empty line.
Review follow-up to #134911, which merged before these landed.
- The script log is shared by every attempt, so a failure report could
quote another attempt's lines. Each line now carries the stage dir
name and waiterLogTail keeps only the current attempt's.
- Add-Content writes the Windows code page by default while the reader
decodes UTF-8, which mangles localized errors and non-ASCII paths. The
script now appends explicit UTF-8 without a BOM.
- waiterLogTail read the whole append-only file before slicing. It now
reads at most the last 16KB, and the script restarts the log once it
passes 64KB so it cannot grow without bound.
startRelaunchWaiter returned undefined for four different failures
(staging dir, script copy, PowerShell spawn, missed handshake), and the
detached script runs with stdio ignored. The Store updater then showed
only "Could not register automatic relaunch" and desktop.log had no
reason, so a user report could not be told apart.
Each failure path now writes its cause to desktop.log (error text,
paths, exit code or signal, elapsed ms). The script appends its own
steps and exit reason to %TEMP%\hermes-relaunch-waiter.log, which
survives the stage dir it deletes on exit, and a failed handshake
includes that file's tail in desktop.log.
The two waiter call sites shared the same options block; they now go
through one startUpdateRelaunchWaiter so the log hook cannot be
forgotten at one of them.
On Windows Desktop the hidden tier-2 link-title BrowserWindow has still
painted a blank top-level frame over the chat during browser-tool turns
(#64867). Encode the invisibility contract in the launch options —
off-screen bounds, skipTaskbar, focusable: false — and belt-and-suspenders
it with setOpacity(0) + hide() after creation, best-effort like the audio
mute.
Salvaged from #66523 (thanks @stantheman0128).
Three defects in one class, backend + renderer:
- _lap_bare_custom_row only built the bare `model.provider: custom` row
while the session was ON it; switching away made it vanish and the
unconfigured-canonical fallback substituted a 0-model placeholder
(#59702). Recover the endpoint from the config.yaml `model:` block when
the caller's current-provider slice points elsewhere; is_current is then
true only when that slice points back at the URL, and the live probe
stays gated on refresh / probe-current.
- The Desktop Settings model page kept the previous provider's model
selected after a provider switch (withActive() painted it as still
selectable, and Apply would pin the new provider to the old model),
and a named custom_providers entry the user just switched to showed 0
models because the normal non-refresh options load probes only the
CURRENT custom provider (#59063). A user-driven switch now clears the
model and fires one refresh-scoped options fetch for an empty
configured endpoint.
- groupModels derived model families solely from the provider catalog;
an OpenRouter current model the catalog omits left the picker with no
active-model row (#57534). Prepend the current model when the catalog
lacks it.
Tests: red on base for the bare-custom regression, the settings switch,
and the missing-current-model row; green with the fix.
Conflicts, all in apps/desktop/src/i18n:
- de/en/es/fr/zh.ts, types.ts: import blocks only. Main added the
*_projects section split, this branch the *_onboarding one; kept both.
- en_shared_metrics, zh_shared_metrics, ar_common, zh-hant (usage-stats
consent copy): kept this branch's rewritten copy. Main's only change on
its side since the merge base was a prettier reflow of
collectedReliability (2943ee6f19), which the branch's rewrite replaces;
types_shared_metrics on HEAD already matches the branch's keys.
Follow-through: main's Undo-hide toast (sidebar.projects.hiddenFromSidebar /
undoHide) arrived without ar or zh-hant strings, which this branch's
overlay-completeness test rejects. Added both to ar_chrome / zh-hant_chrome
and regenerated locales/_keys.desktop.json.
The undo-hide toast strings pushed 8 locale catalog files past their
main-pinned FILE_LINES caps (they may only go down). Offset the growth
the AGENTS.md way: extract the existing sidebar.projects block from each
over-cap file (en/de/es/fr/ja/ru/zh/types) into a topical sibling
<lang>_projects.ts following the <lang>_boot / <lang>_model_menu
convention. No key changes; locales/_keys.desktop.json verified unchanged.
No behaviour change — pure code move.
Adds a layout-store test beside the existing menu test: dismiss hides
the row, restore brings it back, restore is idempotent. Also drops an
unused helper the salvaged test file left behind.
Hiding an auto-derived project only appended its id to the persisted
$dismissedAutoProjectIds with no restore path, so an accidental Hide from
sidebar was permanent: the toast carried no Undo, ⌘Z was unwired, and there
was no other control to bring the row back.
Add restoreAutoProject(id) mirroring restoreWorktree, and fire a success
toast with an 8s Undo action from removeAuto. The id is captured before the
dismiss so Undo still restores after the row unmounts / scope exits.
Fresh installs opened at 90% window zoom, and View > Actual Size (Ctrl/Cmd+0) reset to 90% rather than actual size. Ship 100% as the default UI Scale; Chat Text Size keeps its 110% default.
String.replace with a string replacement expands $&, $' and $` from
another member's message against the delta header, so a crafted sibling
line could drop its own tail and re-insert the instruction boundary inside
the background block. Use a replacer function, name the delta header and
the 20-line cap, and say that room scoping is the caller's job.
Omit the gateway status description when cloud discovery cannot provide a meaningful state, while preserving valid status labels. Add renderer coverage for both unknown and known states.
Main added the Run History 'Queued run' label (44c1ad7f84) in English only;
this branch's overlay-completeness ratchet fails on a new untranslated key.
The name card crashed the setup chat on a real Windows bundle (React #185,
maximum update depth): the card wrote its stage callback into
$setupChooseStages, which it also reads, and the callback depended on
rowLabels, a new object every render. 40512011b6 stopped the rows from
changing identity; this removes the dependency itself, so the callback
changes only with kind, mode, multi-select or the request. The labels
effect already stages the row names the callback used to rewrite.
The new test renders the real card: red on 17646902e1 (the crashing
build), green here and with either guard alone.
Run History only rendered sessions the backend had already materialized,
so an accepted Trigger now was invisible for the tens of seconds the
scheduler took to create the run session (#70826). The queued row is
painted from the click, settles when Run History observes a run that
started after it (any load — a fast 1s poll while queued, the cron.changed
broadcast on event-capable backends, or the visibility poll), and drops
at a bounded 90s timeout so a never-materializing run cannot wedge the
button. A failed request drops the row immediately. Pause deliberately
does not settle it: the pause response only persists enabled=false and
never cancels an already-claimed execution.
Queued design (queued-run row + polling + settle semantics) originates in
#70840 by @nv-cho; this ports it onto current main's
createCronTriggerController wiring.
Fixes#70826