READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.
Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).
The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.
User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
Two changes that npm's hoisting hid and pnpm's layout exposes:
- reactCompilerPreset()'s rolldown.filter is optional once @vitejs/plugin-react's
optional type peers resolve (TS18048 on the in-place assignment). Spread the
preset instead of mutating it.
- Babel resolves preset plugin names from process.cwd(). Builders that run Vite
from the checkout root (installer, Docker, Nix) only found
babel-plugin-react-compiler because npm hoisted it there. Pass the config
dir as babel's cwd.
The @source pointed at web/node_modules/@nous-research/ui/dist, but npm hoists the package to the
root node_modules, so Tailwind silently skipped it and the dashboard lost the utilities only the
bundle uses (--font-sans, .!visible). Tailwind cannot resolve packages, so both locations are
listed; the missing one is skipped.
POST /api/dashboard/agent-plugins/update-all runs the same unattended
update-all as Desktop and /plugins update (nothing accepted on the
user's behalf; widened pins come back needs_review), and
GET/PUT /api/dashboard/plugins/auto-update read and set the profile's
plugins.auto_apply. Both run in the dashboard profile scope off the
event loop. The Plugins page gets the button and the switch; copy in
every dashboard locale. Plugin catalog types move to api-plugins.ts to
keep api.ts under its size cap.
- Drop the assertions that read web/desktop TS sources (tests/AGENTS.md bans
source-reading tests); the frontends get their own auxTaskRows tests.
- The leftover-block test now proves the block is inert: it survives
load_config(), the "reset to main" stale-pin nudge ignores it, a direct
assignment is rejected with 400, and assign-all leaves it untouched.
- config_defaults: rewrap the merged note so the removal and the Compression
notes sit on separate lines (net -1 line under the file cap).
- integrations/providers: OpenCode session-header list no longer names
"skills-hub lookups" as an auxiliary call.
Refs #40703
Skills Hub search/install is deterministic; nothing calls
call_llm(task="skills_hub"). Removes the slot from DEFAULT_CONFIG, the CLI
picker, the dashboard and Desktop task lists (+ their locale strings) and
the docs, as requested in the review of #40710.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The account list no longer disappears when only the access-list question
fails (managed_by 'unknown'); minting still re-asks and refuses.
- Linking snapshots the linked accounts right before minting, so a failed
earlier load cannot make every existing account read as newly linked.
- The Portal button follows the instance's Portal (operator override, stored
login, default) through the scoped resolver instead of raw os.environ.
- Managed copy no longer asks the user to link here.
- Tests: the stand-in connector checks the bearer on the access route and
answers 404 like a connector without it; the managed test also rejects the
'Link another' button.
- On a Portal-managed agent (connector /manage/access/list answers
managedBy: portal) a code minted with the gateway bearer carries no owner, so
its redeem binds no account (gateway-gateway scopeService: a managed agent
takes only the Portal setting). The gateway now refuses to mint there
(managed_by_portal) and the Channels card on both surfaces lists the linked
accounts read-only with a 'Link accounts in Nous Portal' button.
- Success is a new platformUserId in the refreshed list, never an 'inactive'
code before expiry: 'inactive' also means used by an already-linked account
or unknown. That case now shows a neutral note; expiry still says expired.
- Desktop keys the link card by connection + profile + platform, so a code,
poll or account list never carries over to another backend.
Closing the code (setLink(null)) re-ran the poll effect and set its cancelled
flag before the account reload returned, so a successful link never showed the
confirmation.
- Desktop: key the link panel by platform so a pending code never follows the
user to another platform's page.
- A code that goes inactive before it expires was redeemed: a re-link from an
already-linked account no longer reads as "expired".
- Polling stops after 5 consecutive errors or at expiry and shows the error.
- Connector 401/403/409 map to named, plain-language errors; 5xx/408/429 are
retryable (503); unlink answers {unlinked: true}; a silent gateway is worded
for stopped, too-old and timed-out alike.
On a Hermes Cloud instance with Team Gateway, every direct messaging adapter is
switched off at gateway start (config_env._relay) and the connector's shared bot
is the only way Telegram, Discord and Slack reach the agent. The Channels page
and the Desktop messaging page still offered "Create with QR" and bot-token
setup, which make a bot the relay never runs: users finished setup, restarted,
and the bot never answered (#141546, #138101).
The instance now mints the connector's one-time `/link <code>` itself:
- gateway/relay/link.py calls /manage/link, /manage/link/state,
/manage/link-status and /manage/unlink with the per-gateway bearer, and
builds the shared bot's DM link from GATEWAY_RELAY_BOT_IDS.
- The per-gateway secret lives only in the gateway process, so the gateway
answers a new `relay-link` control-socket verb and the dashboard routes
(/api/messaging/relay*) go through it.
- /api/messaging/platforms reports relay_fronted / relay_exclusive per platform.
- Dashboard Channels and Desktop Messaging show a Team Gateway card (link
button, QR of the bot, the exact message to send, linked accounts with
Unlink) and hide the direct setup that cannot work under an exclusive relay.
The /api proxy rewrote a WebSocket upgrade's Origin to HERMES_DASHBOARD_URL.
Pointed at a port forward (http://127.0.0.1:8000 forwarded to the backend on
9119), that Origin names neither the backend's bound port nor the Host the
proxy keeps (localhost:5173), so the backend refused the dev page's sockets.
The proxy sets no changeOrigin, so Host stays the page's own authority and
the browser's Origin already names it, which the backend admits as
same-origin. Drop the rewrite and its helper, pin the proxy options, and
pin both header pairs in the backend test.
_TRUSTED_LOCAL_ORIGIN_PORTS (9119, 5173, 5174, 4174) was trusted on every
backend, including packaged Desktop backends on an ephemeral port. A Vite app
on 5173, or on 5174 when 5173 is taken, could still read the session token
from / and open /api/ws. _is_trusted_local_origin() now accepts only the bound
port and the desktop dev renderer this backend was spawned for
(HERMES_DESKTOP_DEV_SERVER). `hermes dashboard` keeps working on 9119 because
that is its bound port.
The dashboard's Vite dev server was the only client that relied on a fixed
port: the browser's Origin on proxied /api upgrades is Vite's. The /api proxy
now presents upgrades from Vite's own page (Origin host equals Host) with the
backend's origin, and passes every other Origin through for the backend to
refuse, so it does not launder foreign pages the way Vite's rewriteWsOrigin
would. Vite's default CORS grants every localhost port and would hand another
local page the token hermesDevToken() injects into index.html, so it is off.
(cherry picked from commit 66de028a3ce39ec49a32205855dc269d30bf3942)
Review on #145357 (@unsupportedpastels):
M1: a failed chunk request reloaded even when nothing was rebuilt (a
network abort, offline). Before reloading, fetch the page's index.html
with cache: no-store and reload only when it names a different built
entry than the running one; skip while offline and in dev. Otherwise
the import rejects as before.
M2: the single tab-wide 5-minute timestamp suppressed recovery for a
later build (A -> B -> C inside the window). The session guard now
records the entry the tab reloaded away from, so a repeat on the same
build (or a reload that lands on it again) is suppressed while a newer
build can still recover.
The tests called reloadForChunkLoadError directly, so a broken listener in
startChunkLoadRecovery would stay green. startChunkLoadRecovery now takes the
storage and reload seams, and the tests dispatch the event itself: one reload
then suppression, chunk CSS / Safari / Firefox messages, and throwing storage.
Renaming the listened event turns 4 of the new cases red.
A web UI tab (Webapp / gateway web UI / dashboard) opened before
`hermes update` keeps referencing the old hashed page chunks. The update
rebuilds web_dist with emptyOutDir, so the first page the stale tab opens
404s on its chunk; there is no error boundary, so React unmounts the whole
app and the tab goes blank until the user reloads by hand.
Listen for vite:preloadError (fired for every failed lazy import and chunk
CSS preload) and reload once; index.html is served no-store, so the reload
picks up the new build. A sessionStorage timestamp limits it to one reload
per 5 minutes per tab, and only build chunks under /assets/ qualify, so a
broken plugin bundle never triggers it.
Review on #145574:
- The Settings summary labelled the unsaved memory.provider draft "Active
memory provider" even when GET /api/memory named another one. The draft
now shows as "Selected memory provider" (not active yet, naming what
sessions keep using), and the active row and recall copy come only from
status.active. A config autosave that touches `memory` refetches.
- Readiness refreshes after an inline credential save, a full-config modal
save and OAuth completion ($memoryStatusRevision); a failed status read
shows "Memory status unavailable" with Retry instead of dropping the badge.
- The built-in row reflects memory.memory_enabled / user_profile_enabled in
Settings ("On by default" where the config isn't loaded), and recall is
claimed only for a ready provider. Web dashboard copy drops "always on".
- isExternalMemoryProvider matches the backend's full sentinel set,
including `default`.
Refs #144648 (WA-22)
Refs #145175
The dashboard Memory card moves into its own MemorySection and the
Settings slot decides its own visibility, so SystemPage and
ConfigSettingsInner end no larger than on main (code-health ratchet).
Refs #144648 (WA-22)
An external memory provider is additive: MEMORY.md / USER.md stay loaded
in every session. The Memory surfaces did not say so, and Desktop/Webapp
Settings > Memory & Context showed only the provider select plus the
built-in toggles, so a working provider read as "not in effect".
- Desktop + Webapp: new MemoryLayersSummary (Settings > Memory & Context >
Persistent memory, and Command Center > Maintenance) names the active
provider with its readiness from GET /api/memory (or "None - built-in
memory only") and labels MEMORY.md / USER.md as the built-in layer,
"Always on". On Settings it follows the live memory.provider draft.
- Dashboard System page: "Active memory provider" and a "Built-in layer -
always on" label with the additive note, through web i18n.
- MemoryStatusResponse now types the per-provider status the server
already returns; getMemoryStatus takes the Settings scope profile.
No endpoint change: GET /api/memory already returns active + status.
Refs #144648 (WA-22)
Refs #145546Fixes#145175
A passive-effect cleanup on the outgoing page runs after the incoming page's
layout effects, so leaving Sessions (the landing route) or Files still erased
the next page's header action, e.g. Channels' Restart gateway. Move the
remaining passive header writers (Sessions, Files, Plugins and Chat's title)
to useLayoutEffect, document the rule on usePageHeader, and cover leaving
Files and Sessions.
Reset per-page header slots during the route render, before child layout
effects populate them. The parent layout effect previously erased the
Channels restart action on direct load and replacement slots on navigation.
Adapt the provider-reset portion of #45652. The separate hidden ChatPage
writer was already addressed by #72234. Add real Channels and route lifecycle
coverage, including delayed mounting and clearing every previous slot.
Co-authored-by: WompaJango <WompaJango@protonmail.com>
The installed map listed each ClawHub lock row under its bare slug as well
as clawhub/<slug>. A bare id carries no source, so with @alice/foo
installed, a `foo` from any other source would also read installed.
HubLockFile.by_identifier now keys a ClawHub row by its lock id and the
source-qualified clawhub/<slug> card id only. Desktop cards install that
id, so they need no change. The dashboard's hub search and featured list
carry a ClawHub skill's bare slug, so web/src/lib/skill-hub-installed.ts
looks a hit up under its own id, then under the id a catalog card would
install for it (skillCatalogInstallIdentifier), which differs only for
ClawHub.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Carried from BearHuddleston's #93508 onto main. The Webapp relay and the
profile-incarnation fence stay out: carried code follows main's
named-profile rule and refuses a missing or deleted profile home.
- Webapp routes: one-use launch tickets, window handoff, and gateway
WebSocket tickets minted for the signed-in browser
- Browser surface policy and the SPA document policy
- A host terminal for the browser that survives reconnects
- Staged browser uploads and file tickets; file.attach takes a staged upload
- hermes sessions adopt
- Webapp bundle build helpers and the renderer freshness check
- Regenerated gateway contracts
Co-authored-by: emozilla <emozilla@nousresearch.com>
@nous-research/ui declares peer three ^0.180.0 (>=0.180.0 <0.181.0), so the 0.186.1 pin in web
left a second three: 0.180.0 hoisted for @nous-research/ui and 0.186.1 under web/node_modules,
both bundled into the dashboard. Back to the version the UI kit accepts until it widens its peer.
Resolves hermes_cli/auth_model_picker.py against 7a8569baa9 (the picker
takes a title, public as prompt_model_selection): keeps this branch's
description_lines()/menu_title() split, with menu_title(title=...) and the
title= parameter on _prompt_model_selection carried over from main.
Latest 4.1.x patch: mocker hardening for manual and redirect mocks
(vitest-dev/vitest#11580 backport to v4). Also bumps @vitest/mocker.
vitest 4.1.12 is 7 days old, below the min-release-age=14d guard, so
.npmrc gains a dated exclude entry (remove when > 2wks old).
vite 8.2.4 bundles launch-editor fixes for GHSA-587h-7wmj-h5pm,
GHSA-r825-w8mm-5pxw and GHSA-698v-4r3x-3vcv. Bumped in all three
workspaces that pin it (web, apps/desktop, apps/bootstrap-installer),
leaving one hoisted copy.
Forced by vite 8.2.4's ranges:
- rolldown ~1.2.4: 1.2.1 → 1.2.13 (@oxc-project/types =0.153.0)
- postcss ^8.5.26: root override 8.5.23 → 8.5.29; website lock moved
to the same 8.5.29
The .npmrc age-gate excludes for vite/rolldown/@rolldown/*/@oxc-project
were marked "remove once vite 8.2.0 is > 2wks old" (it is 82 days) and
let a same-day rolldown 1.2.15 into the lock, so they are removed; every
version above is past the 14-day gate. The website postcss exclude
("remove when 8.5.23 is > 2wks old") is removed for the same reason.
The first-run offer on Desktop, the dashboard and the terminal had a
third answer ("Keep on this computer" / "Collect locally only" / "Local
only") that collected stats nobody reads: no surface shows them to the
user, and data from before an opt-in is never sent. Every surface now
offers "Share with Nous" or "No thanks", and each answer sets `enabled`
and `send` together. Desktop Settings > Privacy and `hermes setup
telemetry` become one on/off choice to match.
Profiles that already chose collect-only keep that answer and are not
asked again: they saw the question and declined to send. Nothing is sent
for them; the Desktop switch reads off and the `hermes tools` row says
"collecting locally".
Copy that described collection as a separate local choice is rewritten
in every locale that carries it (de, es, fr, ja, ru, zh, zh-hant, ar).