nix/npm-12-0-2.nix pinned npm 12.0.2 with an SRI hash while
runtime-pins.json pinned the same npm with a hex digest, and nothing
connected them: two files to bump, and a devShell free to ship a
different npm than every user's install. Nix is now a consumer of the
pin table, not a second table.
Shape: one derivation per pinned tool, `extends` in the table becoming a
real Nix dependency (npm's derivation takes node's, so Nix orders the
builds and neither side restates "npm needs node"), and a bundle that
symlinks them into a runtime dir.
That bundle is not a set of specially-wrapped programs. It is the layout
runtime_registry.py already describes, and Nix does not reimplement any
of the knowledge about it: the build RUNS the real loaders and writes
what they return. runtimes.json comes from save_facts, path-dirs from
runtime_env.managed_path_dirs, tool-env from managed_tool_env. Nix reads
those three files. That is load-bearing, not tidiness — the layout is
per-tool, and a hand-rolled lib.makeBinPath silently dropped uv and
ripgrep, which keep their binary at the tree root rather than in bin/.
The devShell and the package therefore both ship the pinned toolchain:
node 26.7.0 (nixpkgs carried 26.5.0), npm 12.0.2, uv, git, gh, ripgrep,
each with the env its layout needs. `hermes doctor` on the built package
reports all six as managed at their pinned versions.
Sealed installs fail loudly on drift. A git checkout provisions on
demand, so a mismatch there is transient and raising would break the run
that fixes it; a nix/docker/desktop tree cannot provision at all, so a
mismatch means the artifact was assembled against a different pin table
than the code it ships. require_current_runtimes refuses at that point
and `hermes doctor` reports drift as an error rather than a warning,
both keyed off the existing runtime_tree Sealed/GitCheckout split.
Packaged installs locate the table through HERMES_RUNTIME_PINS and the
prebuilt tools through HERMES_RUNTIME_DIR, set by the package wrapper —
the same bare-data-dir treatment as HERMES_OPTIONAL_SKILLS and
HERMES_BUILD_INFO. The table is deliberately not wheel package-data: we
build wheels only for the Nix package, and package-data would put it in
every wheel anyone ever builds.
Evaluation stays free of import-from-derivation. The generated files are
read in build phases, never with builtins.readFile on a derivation, so
`nix flake check --no-build` and cross-system eval still work.
run_tests.sh forwards the git env vars alongside PATH. The devShell puts
a relocated git on PATH, and `env -i` kept PATH while dropping the env
that git needs — the two have to travel together.
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.
install.sh and install.ps1 keep only the irreducible bootstrap (prereqs,
repo download, uv, venv, uv sync, PATH, config) and then call
'python -m hermes_cli.post_update --install-phase'. Node, gh and ripgrep
are provisioned from runtime-pins.json by the same engine 'hermes update'
uses, so installer and self-heal can no longer drift.
Deleted: scripts/lib/node-bootstrap.sh, install.sh install_node +
configure_managed_node_npm_prefix (managed Node is private now — the
~/.local/bin symlinks WERE the cross-install collision), install.ps1's
node-zip download, and every ripgrep system-package path in both.
Windows PortableGit stays: it is the BOOTSTRAP git that must exist before
the repo is cloned. It lands in the legacy location on purpose — the
provisioner salvages that tree by move, so it is downloaded once.
Stage names (node-deps, system-packages) are unchanged: the GUI install
driver renders them. Verified: bash -n, pwsh Parser::ParseFile, ASCII
guard, 22 installer test files green.
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.
Add msix beside nsis for every Windows lane, in both variants. The exe keeps electron-updater and normal distribution; the MSIX exists for Store/sideload installs and for the Windows Copilot hardware key, whose provider registration is only readable from an MSIX manifest.
electron-builder.config.cjs composes the msix section per variant: own identityName/applicationId/displayName for Hermes and Hermes Light, publisher matching the Azure Trusted Signing certificate subject (MsixTarget signs through the same packager.signIf chain as the exe), and customExtensionsPath pointing at the variant's uap3:AppExtension fragment. The fragment declares xmlns:uap3 on its root element because it is spliced into the generated <Extensions> block after macro substitution — the stock template has no uap3 prefix. The hermes:// windows.protocol extension is auto-generated from the existing protocols config.
The key's press activates hermes://copilot-key/start. deep-link-route.ts owns the routing contract: start summons the ephemeral quick-entry popup entirely in the main process (behind app.whenReady — macOS open-url can deliver pre-ready); stop is ignored because a tap fires start+stop nearly together and acting on stop would undo the summon; everything else keeps the renderer path.
The release workflow uploads *.msix; win.target replaces the never-shipped msi with msix.
release.py --publish always pushed to 'origin'. On a checkout with a fork remote wired for CI dry runs, that meant a dry-run publish could push a tag to the upstream repo — the tag push is what fires the release workflow, so the destination deserves an explicit choice.
resolve_push_remote: one configured remote is used as-is; more than one requires --remote <name>; an unknown name or no remotes at all fail before any commit or tag exists. The gh release create call is pinned to the pushed remote's GitHub repo (--repo owner/name parsed from the remote URL) so gh cannot resolve a different remote than the one the tag landed on.
Add the light variant to the release pipeline. HERMES_DESKTOP_VARIANT=light now builds "Hermes Light": the remote-only client with no agent payload and no local backend.
electron-builder.config.cjs gains an identity overlay for light builds: own appId (com.nousresearch.hermes-light, installs beside full Hermes), own product/executable/artifact names, publish channel 'light' (light*.yml feed files, so both variants share one GitHub release), and a distinct packaged package.json name so electron-updater's derived cache dir (hermes-light-updater) cannot collide with the full app's on a machine that runs both.
build-bundled-desktop.mjs takes --variant=bundled|light. The light arm skips the payload node download and exports the variant to the desktop build; payload staging already writes the external stub manifest for non-bundled variants.
The release workflow matrix becomes variant x target (12 jobs). Light lanes skip the OpenSSL/vcpkg and payload cache steps; upload globs include light*.yml.
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.
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.
The rules come from one source, package.json engines, instead of
copies in the build script. The payload then embeds the EXACT host
versions the gates approved: the node dist is downloaded at the host
node version (and must be an official nodejs.org release), the staged
uv is the host binary, and npm ships inside the node dist. The
installer moves to Node 26 so source installs and embedded installs
run the same node major.
--no-install and --no-package are retired and rejected loudly. A
skipped step is a different artifact, and a different artifact is
not a reproduction. CI drops --no-install; its own npm ci remains
only as a cache warmer with retry protection. The payload stages
(uv python install, pip --target site-packages, node dist) already
ran unconditionally inside the desktop build.
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.
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.
release.py now tags each release vX.Y.Z from the package version. The
old CalVer date tags stay readable as history. get_last_tag() prefers
the newest SemVer tag and falls back to the legacy CalVer tags for the
first SemVer release.
__release_rev_count__ records the commit count of the release-bump
commit. Immutable Nix builds carry no git history, so they derive the
"+N commits since release" display from this number and the flake's
revCount.
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.
The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.
- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
imports. -m filters after collection, and collection imports every
module. without this helper, one unrelated ImportError on the
foreign host fails a job whose own tests passed. the helper exits
non-zero when a marker matches no file, and writes bytes with
explicit lf so windows crlf translation cannot corrupt the bash
file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
macos_only/windows_only tests were skipped on this host, and this
ci lane runs them. a green local run on linux no longer reads as
coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.
GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.
Each slow job now runs in its own workflow:
- docker.yml owns its `pull_request` trigger and does its own change
detection. The new `detect` job runs the same composite action with the
same condition that ci.yml applied, so a tests-only PR still skips the
build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
the workflow and the scripts from the default branch, which is the trust
boundary that the old job got from its `ref: default_branch` checkout.
The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.
The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.
Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.
ci.yml no longer needs `packages: write`, because the image build has left.
Two focus polish items from the first fully-working hand-off run
(ryanc, 2026-08-09):
1. The progress window came up backgrounded: the script is spawned via
`cmd start /min`, and Form.Show() + TopMost keeps it above other
windows without ACTIVATING it. Claim activation explicitly
(Form.Activate + SetForegroundWindow) right after Show.
2. The relaunched Desktop came up behind whatever the user had focused:
a WMI-spawned process starts unfocused and cannot take foreground by
itself. Since the hand-off owns foreground while its progress window
is up, delegate it: AllowSetForegroundWindow(new pid), poll up to 20s
for Electron's MainWindowHandle, then ShowWindow(SW_RESTORE) +
SetForegroundWindow. Best-effort at every step -- a focus failure
never affects the update result.
Sequence on success: progress window foreground during the update ->
window closes -> freshly relaunched Hermes.exe takes foreground.
Verified live on the incident machine: Add-Type shim compiles under
PS 5.1; WMI spawn + AllowSetForegroundWindow + MainWindowHandle poll +
ShowWindow all execute against a real spawned window. (In the bg test
shell SetForegroundWindow returns False by OS design -- only the
current foreground owner may delegate; the real flow's TopMost progress
window IS that owner.) PS parse clean, check-windows-footguns clean.
First real-world run of the #82328/#82366 hand-off (2026-08-09, ryanc)
surfaced two defects:
1. The console window never closes after the update finishes -- and
closing it manually KILLS the freshly relaunched GUI. Root cause:
Start-DesktopRelaunch spawned Hermes.exe as a child of the console
PowerShell. Electron/Chromium calls AttachConsole(ATTACH_PARENT_
PROCESS) at boot, so the new Desktop latched onto the hand-off's
console: the console can't close while an attached process lives,
and closing it takes the attached GUI down with it. Fix: create the
process via WMI (Win32_Process.Create) -- parent becomes WmiPrvSE,
no console to inherit or attach, same detachment explorer.exe gives
a normal launch. Start-Process fallback retained (tethered Desktop
beats no Desktop).
2. Both the console and the progress box render hermes update's UTF-8
glyphs (checkmarks, arrows) as mojibake. PS 5.1 defaults redirected
child streams to the OEM codepage. Fix: StandardOutput/ErrorEncoding
= UTF8 on the child, PYTHONIOENCODING/PYTHONUTF8 so Python emits
UTF-8, and [Console]::OutputEncoding = UTF8 for our own echo.
Verified live on the incident machine: WMI-created process parents to
WmiPrvSE.exe (not the shell); UTF-8 glyph round-trip through the exact
ProcessStartInfo shape reads back byte-correct (15/15 chars). PS 5.1
parse clean, check-windows-footguns clean.
Review feedback on the #82328/#82366 hand-off, all four points plus the
missing progress GUI:
1. FAIL CLOSED. Both preflight gates aborted-open: a Desktop still alive
after 30s proceeded anyway, and a shim locked after 20s proceeded
with --force - both mutate a potentially locked install (the exact
Access-denied brick class). Now: desktop-alive -> exit 4, nothing
changed; shim-locked -> exit 5, nothing changed. Both relaunch the
Desktop so the user is never stranded.
2. TRUTHFUL COMPLETION. `hermes update` treats a Desktop GUI build
failure as non-fatal (warns, exits 0) - correct for CLI use, a lie
for a Desktop-driven update that then relaunches the OLD exe as
"success". The script now detects the warning in the update output,
retries the build once (`hermes desktop --force-build --build-only`),
and exits 6 with an honest message when it still fails.
3. MARKER OWNERSHIP. Cleanup now removes the marker only while OUR pid
still owns it - a handoff partner that rewrote the marker keeps its
claim (same rule as UpdateLock.release).
4. RESULT SURFACING. The script writes .hermes-update-result.json on
every exit path (ok, exit_code, message, branch, finished_at). New
electron/handoff-result.ts consumes it exactly once at the boot
update-gate: success logs, failure shows a real dialog pointing at
desktop-update-handoff.log. Stale (>30min) and malformed results are
consumed silently. Previously a failed detached update was
indistinguishable from "nothing happened" - the exact live report
that triggered this work.
5. PROGRESS UI. The old Tauri updater showed a window; the script ran
in a hidden console with zero feedback. It now shows a WinForms
progress window (marquee bar + streaming log) pumped via DoEvents
during the update; -NoUi keeps tests/headless sessions clean, and a
WinForms-unavailable session degrades to log-only.
Also: subprocess execution moved from Start-Process (ExitCode
unreliably $null under PS 5.1 even with the Handle workaround -
observed live: happy path reported "failed (exit )") to
System.Diagnostics.Process with synchronous stdout pumping, which
keeps the UI alive and the exit code real.
E2E on a real Windows box, sandbox HERMES_HOME + compiled fake
hermes.exe, all five paths:
- happy: exit 0, result {ok:true, "Update complete."}
- shim held open via O_RDWR: exit 5, nothing mutated, honest result
- desktop pid alive (60s ping child): exit 4 after the 30s gate
- update exits 0 printing "Desktop build failed" + rebuild fails:
exit 6, result names the stale build and the retry command
- foreign-owned marker: overwritten by step-0 claim, removed as owner;
ownership check verified in the cleanup path
vitest 18/18 (5 new handoff-result tests), typecheck 3 projects clean,
eslint clean, PS 5.1 parse + footguns + ASCII-only clean.
Remaining known gap (deliberate): the full click-to-relaunch lifecycle
through a REAL Desktop build still needs one live Windows verification
after this lands - tracked in the PR body.
Live failure on the first real use of #82328 (2026-08-09): clicking
Update closed the Desktop with "an updater will happen", then nothing.
desktop.log showed `launched repo hand-off script`, but
desktop-update-handoff.log was never created - PowerShell exited 0
without executing a single line.
Root cause, isolated by spawning the exact production shape against a
sandbox HERMES_HOME: `spawn('powershell', [..., '-File', script],
{ detached: true, stdio: 'ignore', windowsHide: true })` kills
powershell.exe during console-subsystem init, before -File processing.
Variant matrix: plain pipes -> runs; hide only -> runs; detached only ->
runs; detached+hide -> exits 0, script never starts. Unit tests and
foreground invocations can't see this class of bug.
Fix: wrapHandoffForDetachedConsole() routes the invocation through
`cmd /d /s /c start "" /min powershell ...` - `start` allocates the
script its own minimized console and fully detaches it; the cmd wrapper
exits immediately. Verified the wrapped form survives the full
detached+hidden production spawn.
Knock-on: child.pid is now the short-lived wrapper, not the script, so
the Electron-side marker pre-write can't represent the script. The
script now claims the update marker itself as step 0 (its own $PID,
byte-exact "<pid>\n<ts>\n" via WriteAllText - Set-Content emits CRLF
and would break the three readers' framing). The Electron pre-write is
kept as a bridge for the spawn window: the script overwrites it, and if
the script never starts the wrapper's dead pid reads as stale and
self-deletes (no wedge). `hermes update` adopts the script's claim via
update_lock.py's process-ancestry rule, unchanged.
E2E in exact production shape (cmd start wrapper, detached, hidden,
parent exits 1.5s after spawn) against a sandbox HERMES_HOME with a
compiled fake hermes.exe: script ran, claimed marker with its own pid
(fake observed "<script-pid>|<ts>|" LF-framed DURING the update),
desktop-pid wait worked, update invoked with correct argv, marker
removed on completion. vitest 13/13 (new wrapper-shape test), 3-project
typecheck clean, eslint clean, PS 5.1 parse + windows-footguns clean.
The Desktop's Update button hands off to the staged Tauri binary
(HERMES_HOME/hermes-setup.exe). That binary has no self-update path
(copy_self_to_hermes_home no-ops during --update), so every updater-side
fix only reaches users when a new installer is built, signed, and
published. In practice the published binary lags main by months and
users hit long-fixed bugs on every GUI update: the 2026-08-09 incident
chain was four distinct failures (stale install.ps1 cache resolver
pre-#67369, marker adoption pre-#74782, straggler teardown) all caused
by a June 4 binary running against an August repo.
This inverts ownership: scripts/desktop-update.ps1 lives in the repo
checkout, so every `hermes update` refreshes the code that drives the
NEXT update. Only PowerShell itself - an OS component - stays frozen.
Desktop side (apps/desktop/electron):
- resolveUpdateScriptHandoff() (updater-process.ts): returns the spawn
recipe when scripts/desktop-update.ps1 exists in the checkout;
Windows-only (POSIX updates in place via applyUpdatesPosixInApp);
null on old checkouts -> caller falls back to the staged binary path
completely unchanged.
- applyUpdates() prefers the script hand-off. The marker pre-write is
ALWAYS safe on this path - no stagedUpdaterSupportsPrewrittenMarker()
mtime heuristics - because hermes_cli/update_lock.py's UpdateLock
adopts a live marker held by a process ANCESTOR, and the script is
the `hermes update` child's parent. This closes the unguarded
marker-gap window that pre-#74782 binaries force today (the 23:56
failure in the incident: 'skipping marker pre-write: staged updater
predates self-adopt' -> renderer respawned a backend into the gap ->
update refused).
- CLI-installed users (no staged binary) now get the script hand-off
too instead of the manual `hermes update` card, when the script
exists.
Script (scripts/desktop-update.ps1): waits for the Desktop pid to exit
(bounded 30s), waits for the venv shim to unlock (mirrors the Rust
is_locked probe, bounded 20s), runs `hermes update --yes --gateway
--force --branch <ref>` from the CURRENT checkout with one retry for
the update-boundary class (skipped for exit 2), removes the marker on
every exit path, relaunches the Desktop. ASCII-only (the #67193
lesson), logs to logs/desktop-update-handoff.log.
Verification (real Windows box):
- apps/desktop: typecheck (3 projects) clean, eslint clean, vitest
updater-process.test.ts 12/12 (3 new resolver tests).
- Script E2E against a sandbox HERMES_HOME with a compiled fake
hermes.exe: correct argv (update --yes --gateway --force --branch
main), stale marker removed, exit code propagated (0 and 1 paths),
retry-once fires exactly once on failure, PS 5.1 parse + windows
footguns check clean.
- Contract E2E with the real UpdateLock: ancestor-owned marker adopted
(True), left in place on release, foreign live holder still refused.
On Windows npm prints only a terse summary on failure; the actual cause
(postinstall stderr like Electron's install.js, network traces, EBUSY
retries) lives in npm-cache\_logs\<ts>-debug-0.log, which never reached
the Tauri bootstrap log. Field report: a fresh-VM desktop install died
with 'npm error command node install.js' and zero actionable detail.
Adds Write-NpmDebugLogTail: locates the debug log from npm's 'A complete
log of this run' line (fallback: newest _logs/*-debug-*.log under 'npm
config get cache') and replays its last 200 lines through our output
stream, which the bootstrap installer's streaming sink captures.
Wired at all four npm failure sites: desktop workspace npm ci/install,
_Run-NpmInstall (browser tools), Install-AgentBrowser (--silent global
install), and the desktop 'npm run pack' build step.
Two Windows bugs in scripts/run_tests_parallel.py:
- --files/--paths/HERMES_TEST_PATHS were split on ':', which shreds
absolute Windows paths at the drive letter ('C:\repo\tests' ->
['C', '\repo\tests']): the drive letter became a phantom discovery
root and the rooted remainder only resolved by WindowsPath
re-anchoring it onto repo_root's drive. New _split_pathspec() keeps
drive-letter colons glued to their path and accepts ';' (os.pathsep)
on Windows, while ':'-joined lists (CI generate job) keep working.
- With piped stdout (CI, subprocess capture) Windows encodes the
runner's output as the ANSI code page, so printing the per-file
progress glyphs raised UnicodeEncodeError inside the executor
done-callback and every progress line was silently lost -- which is
also why test_bare_value_flag_keeps_its_value failed on win32 (no
'1[check]' line, and the summary says '1 tests passed', which does not
contain '1 passed'). The runner now reconfigures its own
stdout/stderr to UTF-8 on Windows, and the tests decode the captured
output as UTF-8.
Adds regression tests: os.pathsep-joined absolute roots (all
platforms) and no-phantom-drive-root (win32).
Fixes#57149
ruff PLW1514 (already enforced repo-wide via the blocking lint step)
covers open()/Path.open()/read_text()/write_text() but NOT os.fdopen —
the exact hole the AlexFucuson9 sweep PRs (#56033#56940#65565) kept
patching by hand. Add an fdopen rule to check-windows-footguns.py, which
also runs as a blocking CI step, so a bare text-mode fdopen fails CI.
Also fix a false-negative in the read_text/write_text rule: chained
forms like `read_text()[:4000]` or `read_text().splitlines()` never end
the line with `)` and slipped past the multi-line-call heuristic.
Replace the endswith check with a paren-balance walk (keeps multi-line
calls with encoding= on a continuation line unflagged — verified against
the full tree). This makes the rule the effective standing replacement
for the standalone checker proposed in PR #66669: R1-style coverage now
lives in PLW1514 + this script, both blocking in .github/workflows/lint.yml.
Sabotage-verified: reverting agent/shell_hooks.py's fdopen encoding or
tools/skills_tool.py's read_text encoding now fails the gate.
Co-authored-by: AlexFucuson9 <AlexFucuson9@users.noreply.github.com>
Co-authored-by: Paulo Nascimento <pnascimento9596@gmail.com>
scripts/run_tests.sh runs the suite under `env -i` with an explicit
allowlist. The runner's own documented environment knobs were never on
that list, so all of them were silent no-ops for anyone invoking the
canonical wrapper:
* HERMES_TEST_WORKERS / PATHS / FILE_TIMEOUT / FILE_RETRIES / SLICE
are read by run_tests_parallel.py at argparse-default time — inside
the stripped environment.
* HERMES_TEST_IMAGE is read by tests/docker/conftest.py to skip its
session-scoped docker build.
The HERMES_TEST_IMAGE strip is the expensive one, and it's been biting
CI since docker.yml switched from bare pytest to run_tests.sh
(f0cb04921): the workflow sets HERMES_TEST_IMAGE to the image the build
step just loaded, the wrapper drops it, and every per-file pytest
subprocess falls back to building hermes-agent-harness:latest itself.
The job log timing shows it plainly — the first 8 files dispatched (the
LPT-heaviest) all report 248-297s, which is them waiting out the
concurrent initial `docker build` (~4 min on a cold local builder);
every file dispatched after that rides the layer cache and finishes in
4-38s (e.g. test_dump_build_sha.py, a single `docker run --entrypoint
cat`, reported 256.6s). ~4 min of pure waste per docker job, on both
arches — and the tests exercised a locally-rebuilt image WITHOUT the
HERMES_GIT_SHA build-arg the workflow bakes in, not the artifact being
shipped.
Fix: forward the six knobs the same way the Windows location vars are
forwarded (66c4c9c0b) — an explicit compute-before-drop allowlist, each
var only when set, so POSIX runs without them are byte-for-byte
unchanged and the 'no credential can leak' property stays auditable.
Verified empirically via a probe test through the wrapper:
before: HERMES_TEST_IMAGE=None inside the subprocess
after: HERMES_TEST_IMAGE='sentinel-image', HERMES_TEST_FILE_TIMEOUT
forwarded, HERMES_TEST_WORKERS=3 yields '(3 workers)' in the
summary, and an unrelated SOME_SECRET stays stripped.
bash -n clean; shellcheck: no new findings (SC2046 on the pre-existing
compileall line predates this change).
Ships the hard A/B evaluation used for the August 2026 core-toolset
performance batch (#77056) as a reusable harness: 9 error-inducing trap
tasks derived from measured production waste classes, two-arm
PYTHONPATH-only comparison, ATOF-trace-based scoring, resume-safe
batteries.
Hardened from the original one-off: paths de-hardcoded (ABEVAL_ROOT /
ABEVAL_HOME), encoding= on all file IO, startup crashes retry on resume
instead of polluting cells, post-hoc grading fix for err_inline_script
baked in. Live-smoked end to end (baseline arm, qwen3-coder-30b,
err_multi_dir: exit 0, correct on-disk verification, resume record
written).
The artifact download URL returns a 302 redirect to a signed blob URL.
urllib sent the Authorization header to the blob, and the blob rejected it
with a 401 error. The download now has two hops. The first hop authenticates
to the API. The second hop follows the redirect without the auth header.
The query runs?event=workflow_call returns nothing for this repository.
GitHub flattens reusable-workflow jobs and their artifacts into the caller
run. The fetch now lists the artifacts on the orchestrator run only. The
dead sub-run enumeration is gone. Two API calls per cycle are gone with it.
The 'artifact statuses updated' reason never appeared. The code updated the
count before the comparison. Now the code compares first and updates after.
The code rejects zip members that contain '..' or start with '/'.
tests/ci/test_live_comment.py is deleted. This repository does not keep
tests for CI infrastructure.
The live comment poller got its review statuses from two sources. The first
was the REVIEW_STATUSES environment variable, fixed at the start of the
comment-live job. The second was one ci-timings artifact, downloaded at the
end of the run. Status details (error messages, action_required items)
appeared only after all jobs finished. The job pass/fail results were visible
as each job completed.
Now every status-producing workflow_call uploads a small review-status
artifact when it completes. The poller lists all review-status-* artifacts
from the orchestrator run and its workflow_call runs every cycle. It
downloads each artifact and merges the statuses into the comment. A status
appears as soon as its job finishes.
Changes:
- live_comment.py: _fetch_artifact_statuses became fetch_all_review_statuses.
The new function lists the artifacts via the API, downloads each one, and
parses it. Removed the review_statuses_json parameter, the
--review-statuses-file argument, and the subprocess import.
- ci.yml: removed the REVIEW_STATUSES environment variable, the inline Python
merger, and the --review-statuses-file argument. Renamed the
ci-timings-review-status artifact to review-status-ci-timings.
- Eight workflow_call files: added a step that writes review-status.json and
uploads it as an artifact after each review_status output.
- test_live_comment.py: added tests for _parse_status_file and
_merge_statuses.
The poller logs transitions between polls. It reports newly completed jobs
(with their results), newly appeared jobs, and jobs that left the pending
list. Each comment update shows the reason for the change. For example:
'1 new completion(s); artifact statuses updated'. When nothing changed, the
poller lists the jobs that are still pending. The status line shows the raw
job count from the API and the number of infra jobs that the filter removed.