npm 12.0.2 is newer than the npm bundled inside pinned node 26.7.0, and
supersedes it. That relationship needs two things to be true at once, so
the pin table states it once and both are derived:
"npm": { "extends": ["node"], ... }
Install AFTER node, because staging npm runs the node it extends. Sort
BEFORE node on PATH, because node's own bin/npm shim would otherwise win
and serve 11.19.0.
Deriving the order also removes a duplicated literal. The PATH order was
`_PATH_ORDER` in runtime_env.py AND `MANAGED_TOOL_ORDER` in
backend-env.ts, kept equal by a test that read the TypeScript source as
text -- an antipattern AGENTS.md bans outright, and the only tool the
duplication left available. The provisioner now records the derived order
in runtimes.json, both languages read it as data, and the source-reading
test is replaced by a real round-trip.
npm is not a relocatable archive: its bin/npm resolves npm-cli.js from
dirname(process.execPath), so unpacking it on PATH finds node's bundled
copy and dies with MODULE_NOT_FOUND. It is staged by running node's
bundled npm against the pinned tarball with --offline, which keeps the
bytes digest-verified while letting npm write the per-platform launchers
itself (POSIX shims in bin/, .cmd/.ps1 in the prefix root on Windows).
The tarball's bytes do not vary by platform, so `files` accepts a single
"any" key rather than six identical rows that would drift.
Also fixes a wrong comment in stage-agent-payloads.mjs claiming payloads
are cross-built on a linux runner. desktop-bundled-release.yml is a
runner-per-target matrix, as resolveTargets' own header says.
runtime-pins.json v2 pins every tool to an EXACT version and, per target,
the exact download URL and its sha256 — 5 tools x 6 targets, all 30
verified (URLs resolve, digests match a real download). No ranges and no
'resolve latest then check it satisfies': that shape needed a GitHub API
call per tool (60/hour unauthenticated), made two builds of one commit
disagree, and let a tool change under users without review.
runtime_registry drops the whole version-spec grammar. It now validates
the table eagerly and totally — a truncated digest fails at load, not
halfway through a user's first launch.
The provisioner is one download-verify-extract path plus per-tool
staging. Salvage is gone everywhere, including managed_uv's: adopting an
unverified tree from an older install defeats pinning digests at all.
Both git suppliers now ship git 2.53.0 (dugite-native v2.53.0-4 /
PortableGit 2.53.0.3), so behaviour cannot fork by platform. Windows
stays on PortableGit deliberately: MinGit and dugite's own windows build
both omit bash.exe, which find-git-bash.ts needs.
stage-agent-payloads shells out to the provisioner instead of carrying a
second downloader; stageNode/stageGit/stageGh/payloadRuntimeFacts are
deleted. The build-host uv (installs the payload interpreter) and the
payload uv (ships to users) are now distinct — on a cross-build they are
not even the same architecture.
Found by running it: _flatten_single_dir hoisted a lone bin/ directory,
which would have broken gh on every platform. It now only unwraps a
versioned wrapper dir.
Verified: all 5 tools provisioned and executing from real pinned
downloads, cross-target staging produces genuine arm64 Mach-O, and a
moved runtime dir still clones (relocatable). 183 python tests, 24 JS.
The payload now stages a real git on every platform (dugite-native for
darwin/linux, PortableGit for win32) plus gh everywhere, reading the pins
from the same runtime-pins.json the Python provisioner uses so a bundled
app and a source install cannot land on different Gits. The dugite
archive is sha256-verified before extraction.
New Mach-O and ELF arch probes sit beside the existing PE one, so a
wrong-arch binary fails staging on every platform rather than only
Windows. The bundle audit's git exemption is narrowed from the whole
git/ tree to PortableGit's windows layout: the .NET/MSYS2 reasoning is
PortableGit's alone, and exempting dugite would hide a wrong-arch git in
the one payload with no system git to fall back to.
resolveGitBinary and resolveGhBinary now read the runtime registry
instead of hand-rolled candidate lists. On macOS git has NO /usr/bin/git
fallback and no bare 'git': that path is the xcode-select shim, and
invoking it without the Command Line Tools pops a modal install dialog
from a process the user thinks is idle. Failing loudly beats hijacking
the screen. Same rule on the Python side in plugins_cmd.
Verified: 4723 desktop tests, typecheck clean, 84 python tests.
backend-env.ts now READS runtimes.json instead of mirroring layout rules
by comment: managedRuntimePathEntries() consumes the same facts file
hermes_cli/runtime_env.py serves Python, including the pathDirs spread
for multi-dir tools and the vanished-binary guard. The hand-synced
hermesManagedNodePathEntries() is gone, and so is createEmbeddedBackend's
hand-rolled six-entry PATH list — the payload IS a runtime dir, so
stage-agent-payloads.mjs writes runtimes.json into it.
Fixes a live collision (4.12): PYTHONPYCACHEPREFIX and
HERMES_LAZY_INSTALL_TARGET pointed into HERMES_HOME, so two installs
shared a lazy-install overlay whose wheels are ABI-coupled to the
PAYLOAD's CPython. Both move to the Electron userData dir, which is
per-install by construction.
Cross-language contract test runs the TypeScript reader over a
Python-written facts file (node --experimental-strip-types) and asserts
identical output, plus schema/filename/order constants matching.
Verified: 1021 desktop tests, typecheck clean, 8 cross-language tests.
The desktop app now has a light variant. This commit adds it to nix and
makes JavaScript the one owner of the Linux launcher entry.
The .desktop generation:
- Add apps/desktop/scripts/gen-linux-desktop-entry.mjs. It runs
electron-builder's own LinuxTargetHelper on a stub packager. The
entry gets the variant name (com.nousresearch.hermes[-light].desktop)
and @@EXEC@@ / @@ICON@@ placeholders.
- bundle-electron-main.mjs bakes the entry into the electron bundle as
the __HERMES_LINUX_DESKTOP_ENTRY__ define. This is the same mechanism
as the install stamp and the product identity.
- Add electron/linux-desktop-entry.ts. On Linux, packaged runs install
the entry and the icon into the XDG data directories at startup. Nix
builds do not run this path: the stamp says distribution "nix" and
the store derivation ships the entry system-wide.
- Set desktopName (the appId) in extraMetadata. Electron derives the
Linux WM_CLASS from this package.json field, so the running window
and the launcher entry now associate correctly.
- Delete hermes_cli/linux_desktop_entry.py. The uninstaller keeps its
own cache-refresh helper and removes the entries of both variants.
The nix side:
- nix/desktop.nix now returns two derivations from one mkDesktop
function: desktop and light. The renderer exports
HERMES_DESKTOP_VARIANT before the build steps, so the identity, the
stamp, and the launcher entry all agree on the variant.
- The renderer writes the stamp with scripts/write_install_stamp.py
before the electron bundle step. This is the same order that
scripts/build-bundled-desktop.mjs uses. The old loose
$out/install-stamp.json had no reader and is gone. The nix desktop
build was broken before this change: bundle-electron-main.mjs
requires the stamp file.
- The light wrapper does not set HERMES_DESKTOP_HERMES. Its closure
contains no hermes-agent store paths.
- Add the .#desktop-light package.
Verification, on luna:
- nix build .#desktop .#desktop-light: both build. The light closure
has zero hermes-agent paths. Both .desktop files carry the correct
names, WM_CLASS values, and store-path Exec lines.
- apps/desktop: tsc, eslint, and vitest (1063 passed) are green.
- scripts/run_tests.sh tests/hermes_cli/test_gui_uninstall.py
tests/hermes_cli/test_gui_command.py
tests/scripts/test_write_install_stamp.py: 24 passed.
The name derivation (display/kebab/train/pascal + appId, channel, deep-link scheme) moves out of electron-builder.config.cjs into product-identity.cjs, and bundle-electron-main.mjs bakes that object into the main bundle as the __HERMES_PRODUCT_IDENTITY__ define — the install-stamp mechanism. The builder config and the runtime now consume the SAME module, so the packaged artifact and the code cannot disagree about the app name or the protocol scheme.
Replaces two manual sync points: APP_NAME no longer falls back to a hardcoded 'Hermes' (the light appimage was registering as Hermes and sharing its userData dir), and HERMES_PROTOCOL comes from the identity instead of deepLinkScheme(stamp.payload) — that function's 'keep the two derivations in agreement' comment was the drift risk, so it is deleted. HERMES_DESKTOP_APP_NAME stays as the dev-only sandbox override (dev-mock.mjs).
Dev bundles have no define; product-identity.ts derives the same values live from HERMES_DESKTOP_VARIANT, and product-identity.test.ts holds the two derivations in lockstep for both variants. Bake verified in dist/electron-main.mjs for both: light → HermesLight/hermes-light, full → Hermes/hermes.
The second 0x80080204: the copilot key fragment declared xmlns:uap3 on its own element. That passes XSD validation, but makeappx requires manifest namespaces to be declared on the root Package element (the rule IgnorableNamespaces is built on). The config now ships msix.customManifestPath — the stock app-builder-lib template with uap3 injected at the root, derived from the installed template at require time so upstream template changes keep flowing — and the fragment carries no namespace declaration of its own.
scripts/gen-msix-manifest.mjs renders the exact manifest MsixTarget.writeManifest produces (real winAppUtil helpers, real config, both variants) so the XML makeappx sees is inspectable locally instead of only inside a dead runner's stage dir. The release workflow also dumps the generated AppxManifest.xml on win32 failures.
Validated both variants' manifests against the Windows SDK manifest schemas (xmllint + msix-packaging XSDs): both pass, and the uap3 subtree resolves from the root.
Replace HERMES_DESKTOP_BUNDLED with HERMES_DESKTOP_VARIANT. The variant is one value: bootstrap, bundled, or light. The stamp payload field records it.
Bake install-stamp.json into the electron bundle as an object literal. The app does not load a stamp file from resources anymore. Remove the extraResources entry and the loose-file loader. A new install-stamp.ts module owns the InstallStamp and ArtifactKind types.
Python reads a light stamp as an error: a light artifact has no Python runtime, so this state means the build is bad.
The .download-* temp file inside agent-payload/ got copied into the
bundle by electron-builder's extraResources and failed the arch audit.
Download to os.tmpdir() so it never touches the payload dir.
The .NET AnyCPU DLLs are not just in mingw64/bin and mingw64/lib —
they are also in mingw64/libexec/git-core, and usr/libexec has a
32-bit MSYS2 helper. The staging script's own PE probe on cmd/git.exe
is the authoritative arch check; the bundle audit does not need to
re-audit PortableGit's internal MSYS2/.NET layout.
Two fixes for the win32 build failures:
1. Atomics.wait needs a SharedArrayBuffer, not a plain Int32Array.
Use Node's built-in fs.rmSync maxRetries/retryDelay instead.
2. PortableGit's mingw64/bin ships Git Credential Manager .NET
dependencies (Avalonia.*, Atlassian.*, etc.) as AnyCPU/MSIL
assemblies. Their PE machine field is 0x14c (ia32) because .NET
assemblies are format-neutral — the CLR JITs them at load time.
Exempt agent-payload/git/mingw64/(bin|lib)/ from the arch audit.
The 7z self-extractor exits before Windows releases the file handle,
so rmSync hits EPERM. Retry with a brief pause; if it still fails
after 5 attempts, log and continue — the build dir is wiped on the
next run anyway.
stage-agent-payloads: new stageGit() downloads PortableGit 2.55.0.3
for win32 (x64 + arm64) into agent-payload/git/. macOS/Linux write a
.platform-native marker — system git is always present there. PE header
arch probe at staging time mirrors audit-bundle-arch.mjs.
bundled-runtime: EMBEDDED_RUNTIME_ITEMS is now embeddedRuntimeItems(), a
function that includes 'git' only on win32. resolvePayload uses it, so
mac/linux payloads pass the completeness check without a git/ dir.
main.ts: createEmbeddedBackend prepends git/cmd, git/bin, git/usr/bin
to the backend PATH. findGitBash checks the bundled git first via
HERMES_RESOURCES_PATH. resolveGitBinary checks agent-payload/git/cmd.
install.ps1: git pin 2.54.0.windows.1 -> 2.55.0.windows.3.
one artifact replaces three: install-stamp.json (code-scoped) subsumes
.hermes_build_info.json (same schema, different name) and .install_method
(derivable). git checkouts carry no stamp at all — .git plus location is
the fact.
detect_install_method() now delegates to runtime_tree.install_method():
stamp distribution (docker/nix/desktop-app)
-> .git at a managed install root => git
-> .git anywhere else => source (new)
-> unknown
the new 'source' method makes hermes update refuse random src checkouts
outright and point at git pull (replaces the --yes-overridable ask-first
guard). nixos dies as a method value; /nix/store sniffing and the
HERMES_MANAGED ladder step die with it. HERMES_MANAGED keeps exactly one
job: the NixOS module's declarative config-write guard.
lazy_deps drops install-method inference entirely: the read-only guard
now probes site-packages writability directly.
no backwards compat: nothing reads the legacy stamps anymore. stage2-hook
keeps deleting stale home-scoped .install_method markers left by old
images.
python-build-standalone's cpython-3.11.15-windows-aarch64-none dist
ships an x64 vcruntime140_1.dll beside an otherwise all-arm64 install
(verified by PE header on the extracted dist; vcruntime140.dll,
python311.dll, python.exe are all arm64). The DLL exists only for x64
__CxxFrameHandler4 unwinding — arm64 binaries never link it and an x64
DLL cannot load into an arm64 process — so staging deletes it instead
of the arch audit learning to tolerate it.
The elevate.exe half of the same audit failure is already fixed on
ethie/desktop-bundles (60ef9897e); the failing run predates it.
The NSIS finalize task copies its elevation helper into resources/
after every target builds (electron-builder #9852); electron-updater
runs it for elevated installs. It is ia32 by design — one binary that
covers every Windows arch through the x86 emulation layer. The
exemption matches only resources/elevate.exe at the tree root.
The env-var-to-argv chain is verbatim: MacTargetHelper passes
APPLE_API_KEY straight to @electron/notarize, which passes it straight
to notarytool --key, which takes a file path. Raw PEM content splices
newlines into the argv ('Invalid option'); the base64 form the
electron-builder docs describe is a nonexistent path — nothing in the
shipped code decodes it. Keep the raw .p8 in the APPLE_API_KEY_P8
secret, write it to a runner-temp file, and export the path.
electron-builder's builtin runs notarytool + stapling when the
APPLE_API_* env vars are present, which only the release workflow sets.
APPLE_API_KEY must hold the BASE64-ENCODED .p8: the custom script died
because the raw PEM's newlines spliced into the notarytool argv
('Invalid option'). notarize-artifact.mjs was referenced by nothing.
Sniffs PE/ELF/Mach-O (incl. fat) magic in every file of the unpacked
app and names each binary whose architecture does not match the matrix
target. Wrong-arch binaries run fine on the runners through emulation
and only misbehave on user machines — an x64 pip launcher shim inside
the arm64 payload shipped exactly this way.
The two trees are a pure function of uv.lock, the payload python
version, and the target — not the release tag. The staging script owns
correctness: it compares a .stage-cache-key (schema version, target,
source-build list, requirements hash) and restages from scratch on any
mismatch; on a hit it skips only the python install and pip install,
while the arch probes, dist-info rewrite, .pth, and import backstop
run on both paths. The workflow restores the trees with actions/cache
keyed on the same inputs. win32-arm64 saves 15+ minutes of MSVC/Rust
sdist builds per run; the other platforms save the python install and
wheel downloads.
hermes_cli/runtime_tree.py replaces install_manifest.py. A tree with
.git is a git checkout and `hermes update` owns it. A tree without
.git is sealed, and the distribution field of the build stamp names
the steward that replaces it (desktop-app, docker, nix). The refusal
message comes from a per-steward table.
.hermes-install.json dies: staging stops writing it into the payload,
the CLI never reads it, and the update channel lives in config.yaml
(update.channel; main is the default and keeps the current behavior).
Eject gates on Sealed(desktop-app) and is a full handoff: it tells
the user that Setup replaces the desktop app. --channel on a git
checkout writes config instead of a manifest.
Two decisions land together because the code cannot compile between
them:
- Staging has no per-item skip. A stage failure throws and the build
fails. The payload manifest shrinks to a complete-payload sentinel
(schemaVersion 3, tag, commit). External builds write an
external:true stub.
- Backend selection is a constant of the artifact. resolvePayload
requires every runtime item directory; when it resolves, the app
spawns the embedded backend without a look at any checkout. A
payload with no runnable interpreter is a damaged artifact and
raises an error instead of a silent checkout fallback.
decideResidentRuntime, the adoption-era checkout examination, and the
installMode parameter of shouldUseAppUpdater are deleted. The app
self-update gate is now: embedded stamp AND packaged. The update
channel moves to config.yaml (update.channel); Electron mirrors it
with a narrow parser for the version pill. The resident vocabulary is
renamed to embedded; thin builds are now called external.
The distribution field names who replaces a gitless tree. The desktop
payload writes desktop-app. The CLI reads this value to give the
correct update instruction.
The renderer speaks in releases on the stable channel and in commits
on the main channel. The statusbar pill shows "(update)" and names the
release tag in its tooltip; a commits-behind count reads as an
alarming +N on a channel where a release is one step. The updates
overlay names the release ("Hermes v0.21.0 is ready to install")
instead of the no-changelog copy, because a release feed carries no
commit rows by design.
The new VersionDetails panel shows version, branch, commit, source,
and distribution from the build stamp on the About page and in the
updates overlay, so support screenshots carry full provenance. The
statusbar tooltip stacks the same details in one panel; TooltipContent
changes from per-line marker chips to a single column panel, and a new
TooltipDetails helper renders muted secondary rows.
gen-share-codes.ts only picks up lint fixes (import order, blank
lines).
stage-agent-payloads.mjs assembles the resources-resident runtime that
ships inside the bundled installer: the repo tree at the release tag
(no .git, with the prebuilt TUI and dashboard JS), a static uv, a
uv-managed CPython, the full site-packages tree from uv.lock, and a
node dist. A hermes-bundle.pth with relative paths makes the payload
interpreter resolve repo/ and site-packages/ wherever the app bundle
sits — no venv, no PYTHONPATH, no absolute paths.
Each CI runner stages natively for its own (os, arch), so there are no
cross-platform wheel-tag tables. Banner probes verify that every staged
binary was built FOR the target: uv prints its build triple, python
reports platform.machine(), node reports process.arch. A wrong-arch
payload fails the build instead of shipping. Packages with no
win_arm64 wheel build from sdist on the arm64 Windows runner; user
machines never compile.
The script stays dormant unless HERMES_DESKTOP_BUNDLED=1, and writes a
thin stub manifest otherwise, so dev builds are unchanged.
scripts/build-bundled-desktop.mjs runs the same sequence locally on
any platform. The desktop-bundled-release workflow builds each
(os, arch) target on a tag push, signs through Azure OIDC (Windows)
and the Apple secrets (macOS) when they exist, and attaches the
artifacts plus the latest*.yml feed files to the GitHub release.
All packagers (Docker, Nix, desktop) write the same install-stamp.json
with scripts/write_install_stamp.py or with equivalent inline data. The
new hermes_cli/version_info.py reads the stamp first, falls back to
live git for source installs, and reports "unknown" when neither
exists. It caches the result per process.
The stamp replaces three separate provenance paths:
- the HERMES_REVISION env var from the Nix wrapper,
- the .hermes_build_sha file from the Docker build arg,
- live git probes in banner.py and dump.py.
hermes_cli/build_info.py and the desktop's write-build-stamp.mjs are
deleted with them. The desktop build calls the shared Python script.
The banner, `hermes --version`, `hermes dump`, the TUI session panel,
and the desktop About panel now show the same derived version: the
release version, plus "+N" when the build is N commits past the
release tag, or "+?" for a dirty tree with no countable tag. The
release_date field is gone from every surface.
The dirty probe uses `git status --porcelain -uno`: it runs on the
startup-banner path, and an untracked-file scan costs real time on
large checkouts.
Fixing the allowlist only helps a fresh install. npm will not re-run an
install script for a package already on disk, so every checkout that
installed while get-windows was blocked stays bricked: `hermes update`
pulls the fix, `npm install` skips the script, and the build fails on the
same missing binding.
Run `npm rebuild get-windows` from the staging step when the binding is
absent, and if that still yields nothing, print the two commands that
recover the checkout by hand instead of the previous advice to reinstall
dependencies, which is exactly what the user already tried. Gated to a
win32 host building for win32, since no other host can produce the binding.
Co-authored-by: JoaoMarcos44 <JoaoMarcos44@users.noreply.github.com>
The published tarball ships lib/binding/napi-9-darwin-unknown-arm64 on every
platform, so a real Windows host has both it and the downloaded win32 binding
— the classify-everything gate threw on the darwin dir and killed every
Windows pack. Stage only bindings naming the target platform (classify still
rejects impostors), stop copyGlobByExt from recursing into lib/binding, and
add a version tripwire so a get-windows bump fails the build until the
lib/windows.js rewrite is re-verified.
Also from review: the renderer answers window.read.respond with empty text
when the IPC invoke rejects (older shell / main-side throw) instead of
stalling the tool's 30s timeout; the tool schema discloses that sibling
Hermes windows are skipped; docs gain read_window_below in both references.
get-windows@9.3.0 (MIT, zero runtime deps on macOS/Linux) is external to the
esbuild bundle and staged into dist/node_modules per target platform: the
universal Swift helper on macOS, the prebuilt N-API binding on Windows
(fail-closed magic-byte validation), nothing on Linux (xprop at runtime).
The staged lib/windows.js is rewritten to load the binding directly so
@mapbox/node-pre-gyp's tree stays out of the package.
⌘K is an overlay that is stateful to itself — pressing it owes the user a
frame immediately, whatever else the shell is doing. It was not built that
way.
`CommandPalette` is mounted for the life of the app, and its body ran
unconditionally: a dozen store subscriptions (connection, desktop version,
client + backend update status/apply, keybinds, worktrees, theme, i18n),
three `useQuery`s, and the group builders that assemble a few hundred rows.
`<Portal>` renders nothing while closed, so none of it was ever visible —
but all of it still ran. An in-flight update rewrites `$updateApply` on
every progress line, and each of those rebuilt the entire row set for a
surface nobody could see.
Split the body into `CommandPaletteBody`, mounted only while the palette is
on screen. A closed palette is now one store subscription. The body is keyed
by open count, so per-open state (search, sub-page) resets by remount and
the explicit close-reset effect goes away, and `mounted` lags `open` by the
150ms exit animation so Radix can still play `data-[state=closed]` instead
of the overlay vanishing.
Rows additionally move behind `useDeferredValue` in their own memo
component. Because that component mounts with the portal, the deferred
initial value applies per open: the first commit is the frame + input, and
the several-hundred-row list arrives in an interruptible follow-up render
rather than blocking the frame the keypress asked for. The empty state is
suppressed while rows are still pending so opening doesn't flash "no
results".
The `enabled: open` gates on the three queries are dropped — the component
only exists when open, so they are inherently lazy, and react-query still
serves a reopen from cache while revalidating.
`submit` measures the scroll jump when a turn is appended; nothing measured
the jump when a session is opened, which is the prepend/settle path. Clicks
sidebar rows and tracks how far the bottom turn moves after first paint.