Commit Graph

100 Commits

Author SHA1 Message Date
ethernet b087da8e3b fix arm32 win builds 2026-08-13 21:55:26 -04:00
ethernet b812c34fb9 feat: manage npm 12 as a pinned runtime, ordered by `extends`
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.
2026-08-13 14:00:32 -04:00
ethernet 5b56c116df desktop: fix broken signatures in uv builds
remove this after
https://github.com/astral-sh/python-build-standalone/pull/1217 is merge
2026-08-12 10:24:07 -04:00
ethernet 45aa181097 refactor: exact pins with per-target urls and digests; delete salvage
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.
2026-08-12 08:55:01 -04:00
ethernet 0270f9446b fix: tool pins 2026-08-12 08:27:32 -04:00
ethernet b3591d61ad feat: bundle dugite git and gh in the desktop payload; macOS never touches the shim (phases 6.17, 6.18, 6.20)
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.
2026-08-12 07:10:51 -04:00
ethernet 7dc342ce73 feat: desktop reads the runtime registry; bundle state leaves HERMES_HOME (phases 4.11, 4.12)
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.
2026-08-12 04:59:07 -04:00
ethernet 017441ffc4 nix: add desktop-light, move the linux .desktop file to electron-builder
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.
2026-08-12 01:21:52 -04:00
ethernet 64ad597a7d refactor(desktop): bake the product identity into the bundle
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.
2026-08-11 22:30:25 -04:00
ethernet 96ff338bb8 fix(desktop): declare uap3 on the manifest root; local msix manifest repro
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.
2026-08-11 20:56:46 -04:00
ethernet cae085be59 feat: single desktop variant selector and baked install stamp
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.
2026-08-10 19:37:17 -04:00
ethernet 3e95932805 fix(desktop): download PortableGit to tmpdir, not inside agent-payload
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.
2026-08-10 18:27:09 -04:00
ethernet 049196642e fix(desktop): exempt whole PortableGit tree from arch audit
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.
2026-08-10 17:29:05 -04:00
ethernet fcaafd705c fix(desktop): use fs.rmSync maxRetries + exempt PortableGit .NET DLLs from arch audit
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.
2026-08-10 17:03:33 -04:00
ethernet 84434a5250 fix(desktop): retry temp file cleanup after 7z extraction on Windows
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.
2026-08-10 16:18:41 -04:00
ethernet b711d9b1d6 feat(desktop): bundle PortableGit 2.55.0 in the desktop payload
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.
2026-08-10 15:16:26 -04:00
ethernet 4a783b1c21 unify install provenance on a single code-scoped install-stamp.json
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.
2026-08-10 11:21:49 -04:00
ethernet 02872b26e2 fix(desktop): scrub pbs's stray x64 vcruntime140_1.dll from the arm64 payload
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.
2026-08-10 11:21:49 -04:00
ethernet ef3020de1f style: drop useless slash escapes in the exempt-path regexes 2026-08-10 11:21:49 -04:00
ethernet 77d4164d77 fix(ci): exempt electron-builder's nsis elevate.exe from the arch audit
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.
2026-08-10 11:21:49 -04:00
ethernet 03f239b9fd fix(ci): notarytool wants a .p8 path; write the key file in the workflow
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.
2026-08-10 02:34:59 -04:00
ethernet 82b9c9c2ef delete notarize 2026-08-10 02:34:59 -04:00
ethernet a534415e36 change: builtin macos notarization; delete the notarize scripts
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.
2026-08-10 02:34:59 -04:00
ethernet 2b1cbcc925 feat(ci): fail the release when a bundled binary has the wrong arch
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.
2026-08-10 02:34:59 -04:00
ethernet 2f09a80d37 perf(ci): cache the payload python + site-packages across releases
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.
2026-08-10 02:34:59 -04:00
ethernet 8d9a156ade fix(ci): the v27 schema types ExcludeCredentials as a string, not an array
Also turn on DEBUG=electron-builder in the release workflow so the
signtool /dlib command lines and child output show in the log.
2026-08-10 02:34:58 -04:00
ethernet 4c885c4902 fix(ci): the signing dlib skips the imds probe that hangs hosted runners 2026-08-10 02:34:58 -04:00
ethernet 657cc38805 fix(desktop): macos signs mach-o only; payload symlinks sanitized 2026-08-10 02:34:58 -04:00
ethernet d32e24731f fix(desktop): the payload ships no absolute symlink — codesign rejects it 2026-08-10 02:34:58 -04:00
ethernet 2983fbf11b change: electron-builder unpacks its own electron dist 2026-08-10 02:34:56 -04:00
ethernet 0ea986db66 change: electron-builder 27 signs with signtool /dlib, not powershell 2026-08-10 02:34:56 -04:00
ethernet 5f231f83f6 fix(desktop): electron-builder never publishes; the workflow uploads 2026-08-10 02:34:56 -04:00
ethernet 2a0bb64d18 fix: signing uses the azureSignOptions schema 2026-08-10 02:34:55 -04:00
ethernet 19c0f4e5e2 fix: windows signing config moves to environment variables 2026-08-10 02:34:55 -04:00
ethernet d2777660b3 fix(desktop): write the stamp through uv, not a bare python3 2026-08-10 02:34:55 -04:00
ethernet 323df71ed3 fix(desktop): tag-to-commit resolution survives cmd.exe 2026-08-10 02:34:55 -04:00
ethernet eb676fe66e change: derive install state from .git and the build stamp
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.
2026-08-10 02:34:52 -04:00
ethernet 60372c1630 change: an embedded app always runs its embedded payload
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.
2026-08-10 02:34:50 -04:00
ethernet 28451113c0 feat: stamp the steward into build info
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.
2026-08-10 02:34:50 -04:00
ethernet 5ff391af5b feat(desktop): channel-aware update vocabulary and version details
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).
2026-08-10 02:34:50 -04:00
ethernet 08f13942f4 feat(desktop): stage offline agent payloads into the bundled artifact
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.
2026-08-10 02:34:50 -04:00
ethernet 01aba5cf16 feat(version): one install stamp carries build provenance
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.
2026-08-10 02:34:47 -04:00
notkisk 481ccdafb7 fix(desktop): keep react-router in one runtime chunk 2026-08-10 02:04:47 -04:00
Brooklyn Nicholson 2cd9e1777b fix(desktop): rebuild get-windows when its win32 binding is missing
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>
2026-08-08 21:23:20 -05:00
Brooklyn Nicholson 0c2cdccccc fix(build): win32 get-windows staging must skip the tarball's bundled darwin binding
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.
2026-08-08 12:17:50 -05:00
Brooklyn Nicholson f463a7e8ee build(desktop): stage get-windows like node-pty
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.
2026-08-08 12:17:50 -05:00
Johnny 1051434325 perf(desktop): isolate right pane layout work 2026-08-03 13:39:33 +05:30
Brooklyn Nicholson 0481766561 perf(desktop): make ⌘K cost nothing until it opens
⌘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.
2026-07-30 03:01:05 -05:00
Brooklyn Nicholson a7cc924204 perf(desktop): add a session-load scenario to the harness
`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.
2026-07-29 12:13:36 -05:00
Brooklyn Nicholson 4acc162712 chore: overlay churn measurement probes (sweep + median A/B) 2026-07-28 20:26:58 -05:00