The merge landed main's `minimize-to-tray` and `hud-modifier-types` modules next to
ours, leaving two `perfectionist/sort-imports` errors — the only blocking failures in
the `JS & TS checks` job (`apps/desktop :: check:lint`):
electron/main.ts:321 ./minimize-to-tray before ./native-auth-decisions
src/global.d.ts:6 ../electron/hud-modifier-types before ../electron/machine-profile
Resolved with the workspace's own `npm run fix` (eslint --fix + prettier), which also
pads the `padding-line-between-statements` warnings it can. Lint is 0 errors after.
The runner's per-run temp roots live under a fixed `/var/tmp/hermes-pytest`, chosen for
real reasons (disk-backed, not hidden, short enough for AF_UNIX sun_path). But a fixed
literal in a world-writable sticky dir belongs to whoever creates it first: a root-owned
root — a container or system-service run — makes every later `makedirs`/`mkdtemp` there
fail with EPERM for every other user on the host, with no way back that does not need
root. That is exactly what happened on luna: /var/tmp/hermes-pytest is root:root 755, so
every local suite run by the login user died at `runner crashed: PermissionError(13)`
before collecting a single test.
Key the name by uid (with the same non-/var/tmp fallback), so no run can be blocked by
another user's leftovers. Invariant test: two uids never share a scratch root, and the
root is created under the expected parent.
Conflicts, all inside the local-runtime / local-models subsystem:
- hermes_cli/local_runtime/binaries.py, hermes_cli/web_routers/local_models.py:
main's own transfer machinery (urllib ranged download, shutil.move publish) is
retired here — pm/downloader owns every fetch. Took ours.
- tests/hermes_cli/test_local_runtime_downloads.py: deleted here (its subject,
binaries._download, is gone); kept deleted.
- tests/hermes_cli/test_local_models_routes.py: ours (the pause/resume suite) plus
main's held-finished-file contract, re-seamed onto the pm downloader.
main's fix 515412f415 ("wait out a held finished download instead of copying it and
failing") is re-homed so it fits the PM model: the Windows rename that fails while an
antivirus or indexing scan still holds a just-closed multi-gigabyte file is now handled
once, in pm/downloader.replace_when_released, which is the single publish path for PM
archives and model files alike. A cleanup that cannot remove its leftover can no longer
mask the error that left it.
Tests: helper retries a transient refusal and lands the file; gives up with the
plain-language message chained to the OS error; a download publishes through two refused
renames with nothing left behind; a stuck hold reports the rename error, not the failed
cleanup. Route-level: the job lands the file and reports done.
At 601px the select columns were squeezed to 89/103/113px while the selects
are floored at 120px, so each control overran its neighbour (row scrollWidth
603 vs 569 clientWidth). Hold the same floor on the column, letting the
search field absorb the shrink instead.
The plugins panel had the same viewport-drift the drawer had: leaving the
mobile band left `filtersOpen` true, so `aria-expanded` stayed "true" on the
hidden toggle. Reset it from the same media query.
The new compact controls hid `.sidebarToggle` at every width — the 1024px
block's `.layout > .sidebarToggle` rule outranks the later 900px
`display: flex` — so its button, `activeCatBadge` and three CSS blocks were
unreachable, and the drawer's `onKeyDown` was shadowed by the focus trap's
capture-phase listener. Delete them.
Also drop declarations that cannot paint under `display: contents`
(`padding`/`border-bottom`/`backdrop-filter`/`background !important` on
`.controlsBar` at <=600px), the redundant `.compactSort` hide (its parent is
already hidden), and the empty effect left over on the plugins page.
`.filterPanel.filterPanelOpen` keeps the open panel working regardless of
source order instead of relying on the 600px block coming last.
Tapping Filters scrolled 391px even when the panel was already on screen,
because the handler called `scrollIntoView({ block: "start" })` on every
open. Reveal it from an effect instead, with `block: "nearest"`, and keep the
click handler a plain state toggle (the cancelled rAF no longer rides along
on the handler).
Opening the filters drawer at <=600px and then resizing to a wider viewport
left `sidebarOpen` true: the hidden toggle kept `aria-expanded="true"`, and
coming back to mobile silently reopened the drawer and re-locked body scroll.
Reset it in the matchMedia sync, and name the query constant so it stays in
step with the `max-width: 600px` CSS blocks it mirrors.
`?embed=picker` hides the navbar and pins the controls bar to the frame top,
but that override still names `.controlsBar` — which is `display: contents`
at <=600px, so the sticky element (`.controlsTopRow`) kept its 60px navbar
offset and left a dead 60px gap with cards sliding under it. Extend the
override to the row, and add the same rule to the skills page, which had no
picker offset at all.
Between 601px and ~870px the compact selects were squeezed to as little as
13px of text area (SOURCE needed 64px), so the control displayed no
readable value while the pills it replaced were hidden — the band had no
way to see the active filter.
Floor the selects at 7.5rem, let the search field shrink to 8rem instead of
12rem, and drop the uppercase labels below 820px where they leave no room.
The new sticky control bar hardcoded the dark surface
(`rgba(7, 7, 13, 0.94)` plus gold borders and a gold-on-gold count badge),
but the site respects `prefers-color-scheme`, so light-mode users got dark
text on a near-black bar: the Filters label measured 1.00 contrast (1.5 by
pixel sample) and the skills search input dropped from 3.34 to 1.12.
Use the theme tokens instead — `--ifm-navbar-background-color` and
`--ifm-hr-border-color` resolve to the previous dark values exactly — and
scope the gold gem/border accents to the dark theme, with
`--ifm-color-emphasis-*` for light mode.
One test pins the contract (if/then/else + oneOf/not fragments survive
normalization and validate with the intended semantics, using the NBX
set_effects shape from #107141), one pins the guard (root and declared
nested objects still get the #4651 dangling-required repair).
_repair_schema didn't recurse into if/then/else, so a bare conditional
wrapper (e.g. an allOf branch expressing "when goal is set, mode must be
one of ...") fell through _fill_missing_type's default and was stamped
with type: "string". That corrupts the schema: the wrapper's actual
instance is an object, so Moonshot (and any strict validator) now
rejects every real argument against it.
if/then/else are added to the recursed node keys, and _fill_missing_type
leaves a bare conditional node untyped instead of defaulting to "string"
since it constrains the parent instance rather than describing its own
shape.
_repair_object_shape() treated every dict carrying `required` as an object
declaration. A constraint fragment — {"required": ["chain"]} inside an
allOf/oneOf/anyOf/if branch, with no properties and no type — is not one.
Repairing it synthesised `properties: {}` and then pruned every name out of
`required`, so sibling branches collapsed into identical always-true schemas
and the enclosing oneOf had two matches: the tool appeared in the catalog and
every call failed validation.
Only the parameters-schema root still receives the dangling-`required` repair,
which is the single node handed to providers as the argument object.
Merge origin/main at 8e806ae1b2. Keep native helper compilation in
prepareDesktopNativeDependencies and keep bundling/beforePack consume-only.
Bind helper sources and headers into preparation identities and cache keys;
copy admitted executable resources beside node_modules and preserve signing
semantics in product freshness checks.
Verified desktop typecheck, focused native/packaging/UI and gateway/cache
tests, and the real Linux preparation/copy/Xvfb execution path. Incoming
upstream anti-slop findings remain unchanged; no baseline was raised.
The container harness pinned the pre-multiplex-only contract (a named profile brings up its
own s6-supervised gateway). Under #118273 + this PR that start is refused: the slot stays
registered (DOWN) so `--force` has something to drive, and the refusal names `--force` and
`migrate --multiplex`. Split into the refusal invariant and the `--force` start/stop control.
Every line still promising that `gateway.multiplex_profiles: false` keeps
per-profile gateways, or documenting the deleted `migrate --standalone`
rollback, now describes the shipped behaviour: one gateway per host serves
every profile; `false` no-ops with a warning; `hermes update` folds a fleet
unless a real boundary (different UNIX user, HERMES_HOME outside profiles/)
holds; a blocked fleet keeps `--force` per profile; re-running
`migrate --multiplex` converges a half-migrated host; a manifest on disk is the
resume record, never a rollback. Adds the new "No new per-profile gateways"
section with the exact refusal `hermes -p <name> gateway install` prints.
Files: website/docs/user-guide/multi-profile-gateways.md,
website/docs/reference/cli-commands.md (migrate row),
website/docs/developer-guide/multiplexing-gateway.md (eager activation no
longer honours `false`), hermes_cli/AGENTS.md (migrate + refusal seams).
Completes #118273 (docs item). Part of #109417
`hermes -p <name> gateway install|start` (and `run`, `restart`, the setup flows
through `ensure_gateway_service`) refused only while a live multiplexer already
served the profile. On a host with no multiplexer running -- or one that had not
rescanned yet -- the same command wrote a brand-new per-profile unit: a fleet
member the multiplex-only topology (#118273) no longer supports.
`_named_profile_refused_under_multiplexer` now refuses every
`<root>/profiles/<name>` home that has no service of its own: the served form
keeps naming the owner PID and `gateway restart`; the new form points at
`hermes gateway install` (default profile) and `hermes gateway migrate
--multiplex`. `--force` stays the single escape (UNIX-user / out-of-tree
HERMES_HOME fleets), and a `--force`-installed service stays startable without
it so its supervisor (ExecStart carries no --force) keeps relaunching it. The
dashboard `/api/gateway/start` twin (`multiplexed_profile_refusal`) applies the
same rule and text; `stop` is unchanged.
Tests: red on base for the no-multiplexer refusal (exit 78, no unit written,
A->B->A over two profile homes, dashboard shape); controls for the default's own
install, the served-by-PID form, and the --force path; the #111958 setup test
now asserts the unserved named profile gets no unit either.
Completes #118273 (item B). Part of #109417
Patch rollup of the ~1,800 PRs merged since v0.21.3 so downstream consumers
(Docker images, Hermes Cloud) get a stable tag. Full curated notes ship with v0.22.0.
f50d4b3423 (a docs revert) restored a route-style /user-guide/profiles link in
nous-portal.md (en + zh-Hans); website/scripts/check_doc_links.py rejects those, so
the Docs Site check has been red on main since. Rewritten with the script's --fix.