Commit Graph
3019 Commits
Author SHA1 Message Date
Teknium debdab9fec fix(desktop): drop python-docx's unzipped template from the Windows payload so MakeAppx can pack it
The signed Windows MSIX build fails every run since the [documents] extra
(b357ebbfc4) put python-docx into the bundled payload: its wheel carries
docx/templates/default-docx-template/[Content_Types].xml, and MakeAppx
refuses that package-reserved name anywhere in the layout with 0x8007007b
"The filename, directory name, or volume label syntax is incorrect".

The directory is the unzipped source of templates/default.docx; python-docx
only ever opens the .docx, so dropping the tree from the win32 venv and the
shipped uv-cache keeps Document() working. Any other reserved-name file now
fails payload staging with its path instead of failing the pack anonymously.
2026-11-02 02:02:44 -08:00
kshitijk4poor 782c0f847c ci(process): the detached-spawn ratchet fails closed, needs a reason on every marker, and says what it misses
Review (nit 9, nit 4): when git could not list the tracked files the ratchet scanned nothing and
reported the tree clean; it now exits 2. A bare `detached-spawn: ok` with no reason passed; the
marker now counts only with a reason after the dash (every existing marker has one). The docstring
now lists the shapes it does not catch (PTY spawns, aliased imports, os.posix_spawn(setsid=True),
kw.setdefault("start_new_session", True)), and says that a group-killing timeout runs in the owner,
so an owner killed mid-wait leaves such a child to finish; the process_identity comment no longer
claims the ratchet keeps every detached spawn out.

Tests: a reasonless marker does not opt out; a tree git cannot list fails with exit 2. Both fail on
the prior ratchet.
2026-10-29 16:58:09 +05:30
kshitijk4poor c9460ed495 ci(process): the detached-spawn ratchet also catches process_group= and kwargs["start_new_session"] = ...
The check flagged start_new_session (keyword or dict key) and os.setsid/setpgid/setpgrp, but not
Popen's process_group= keyword, which also puts the child in a group of its own, nor the
item-assignment shape kwargs["start_new_session"] = True. Seven production sites used those shapes
unmarked, so "a new detached spawn can't bypass it" was not true. All three shapes now count for
both keywords (process_group=None, the default, does not).

The six sites it now finds are all awaited with a group-killing timeout or run a gateway restart
that must not share the updater's group; each gets a `detached-spawn: ok` marker with that reason
(shell hooks, the Windows-safe probe runner, update steps, git repack, the two update recovery
restarts). The seventh is in update_custody's embedded watchdog script, a string the AST never sees.

Tests: the four new shapes are flagged and process_group=None / start_new_session=False items are
not; the new cases fail on the prior checker.
2026-10-29 16:58:09 +05:30
kshitijk4poor d1cb5d5cbd ci(process): no new detached spawn outside spawn_owned without a reason
A child started in its own session or group (start_new_session, os.setsid /
setpgid / setpgrp, windows_detach_popen_kwargs) does not die with a SIGKILLed
Hermes: it reparents to init and runs forever. That is how every family this PR
migrates leaked. With the families on spawn_owned, this keeps the class closed.

scripts/check_detached_spawns.py (AST) flags those shapes in the production trees
unless the line, or the line above it, says `detached-spawn: ok — <why>`. It runs
in scripts/check ("detached-spawns") and as a blocking lint.yml step. The baseline
is empty: every remaining site is on spawn_owned / own_process_groups, or carries
its reason. The reasons this commit adds are the census verdicts:
- must outlive the starter on purpose: restart watchers, /update and the updater
  hand-offs, dashboard/desktop/service launches, cron and kanban workers
  (restart-safe), the update-custody watchdog itself;
- awaited with a group kill on timeout: cron scripts, rg search, the Browser Use
  CLI, update completion (holds the checkout lock fd);
- covered by its own parent guard: the compute host (compute_host._parent_guard_loop
  flushes sessions; a supervisor KILL could cut that short);
- not a spawn: the wake-word `start_new_session` config key and event field.

tests/scripts/test_check_detached_spawns.py: each shape flagged, non-detached and
commented/string shapes not, the marker opts out on its line or the line above
and nowhere else, tests and the helper are out of scope, main() fails on a new
unmarked spawn, and the repository scan is empty. Teeth: appending an unmarked
start_new_session Popen to tools/file_operations_search.py turns
`scripts/check --only detached-spawns` red (rc 1); restoring it turns it green.
2026-10-29 16:58:09 +05:30
Teknium df128c13bb ci: run the WhatsApp bridge node tests in the JS & TS job
scripts/whatsapp-bridge/*.test.mjs (allowlist, delivery_status,
bridge.native, ...) ran in no workflow: the bridge is a standalone npm
package outside the root workspaces, so run-workspace-checks never sees it.

js-tests.yml now installs the bridge's own lockfile (npm ci
--ignore-scripts; two suites import baileys) and runs
`node --test scripts/whatsapp-bridge/*.test.mjs`. The classifier selects
the frontend lane for scripts/whatsapp-bridge/ and
plugins/platforms/whatsapp/, so a bridge-only or adapter-only PR runs it.
No E2E trigger.
2026-10-28 21:06:25 -07:00
Teknium a3349cf1d5 fix(whatsapp): give delivery receipts their own bridge queue
One reply to a large group brings a receipt per member (delivered, then
read). In the shared 100-entry /messages queue that burst shifted out
inbound user messages still waiting for the adapter's next poll: with 150
receipts the queued inbound DM was gone. Receipts now have their own bound
and are appended after messages in /messages.
2026-10-28 19:34:11 -07:00
Victor Kyriazakos 31d5f1d44d feat(whatsapp): log delivery and read status of sent messages to gateway.log
Baileys reports the delivery status of messages Hermes sends
(`messages.update` with `update.status` in 1:1 chats, a per-recipient
`message-receipt.update` in groups), but the bridge only handled poll
updates there, so operators could not see whether a reply was delivered
or read.

The bridge now queues a `delivery_status` event for status changes of its
own sends (tracked IDs only, so messages typed on the linked phone are
never reported). The adapter logs one gateway.log line per change and
never dispatches it as an inbound message.
2026-10-28 19:34:11 -07:00
Victor Kyriazakos 29b44be139 fix(whatsapp): wait for the old bridge to release its port before respawning
connect() SIGTERMed the previous bridge, slept a blind 1s and spawned the
new one regardless. A bridge that was slow to exit (or ignored SIGTERM)
still held 127.0.0.1:<port>, so the new bridge died on EADDRINUSE and the
reconnect loop retried the same race forever.

- _kill_port_process / _kill_stale_bridge_by_pidfile wait out a SIGTERM
  grace, then SIGKILL survivors after re-checking identity (a PID recycled
  inside the window is never signalled).
- connect() replaces the blind sleep with a bounded bind-probe wait for the
  port and fails with a named retryable whatsapp_bridge_port_busy instead
  of spawning onto a busy port. A holder that is not our bridge is still
  never killed.
- Listener discovery falls back to psutil when neither lsof nor ss is
  installed; without it the scan was silently empty and nothing was reaped.
- A bridge that dies during the post-spawn health wait no longer leaves a
  dead-PID bridge.pid; disconnect() waits for a force-killed bridge to exit.
- bridge.js handles the listen error and exits with a clear EADDRINUSE
  message instead of an unhandled stack trace.
2026-10-28 19:21:20 -07:00
unsupportedpastels 26ba38a098 fix(webapp): report the commit an inspection read, and share the detection cases
The gateway inspection reported which halves a source ships but not the
commit it read, so for an unpinned Install from Git the reviewed tree and
the installed tree could differ if the source moved in between. Return
the inspected HEAD as `sha` (a catalog entry's is its reviewed pin) and
carry it through inspectAgentPlugin, so the install can be held to it the
way the native Desktop probe's will be. A cached answer keeps the sha it
read.

The component-detection rules now live in two inspectors (Python and
Electron). tests/fixtures/plugin_component_detection.json holds one set
of cases both run, registered as a shared fixture so editing it runs the
pytest and vitest consumers.
2026-10-27 21:32:38 -04:00
Teknium 4bc973abd3 fix(tests): share the account tool store only when pm install would not rewrite it
Review follow-up on #145364 (kshitijk4poor):

- tool_store_pins.py now asks pm's own rule, _installed_location (new
  heal=False keyword keeps it read-only), and also requires each entry's
  recorded env to equal what this checkout's package code records. A store
  at the right pins but with a stale env made `pm install`'s
  _refresh_found_env re-record the shared facts.json.
- run_tests.sh: the concurrency comment now says the unlocked check keeps
  runs that START on different pins apart and names the window before
  pm install's lock; UV_PYTHON_INSTALL_DIR is reused only when the pinned
  minor is already cached; dscl parsed with sed, same-home test with -ef;
  PATH caveat and a never-forward note at the env -i allowlist.
- The test separates the checks: setup-hermes' uv copy broken while pm's
  uv entry is fine, a non-executable ripgrep, and a stale node env, each
  asserting the store is left byte-for-byte and mode-for-mode unchanged.
2026-10-27 17:11:28 -07:00
Teknium 1eeef54ca3 fix(tests): share the account tool store only when its binaries are runnable
_store_has_pins checked facts.installed(), which only proves the recorded
entry directory exists. A store with matching facts but a missing or
non-executable uv passed, so the runner shared it and setup-hermes.sh fell
into its uv re-stage, whose unlocked rm -rf + publish races other runs on
the shared store.

Eligibility now also requires every walked package's binary to be a file
with the exec bit (no healing: read-only), plus setup-hermes.sh's exact
$store/uv-$uv_version-$os-$arch/uv path. The check moves from a heredoc to
scripts/tool_store_pins.py so the regression test runs the real logic.
2026-10-27 17:11:28 -07:00
Teknium 2b8220bd87 fix(tests): share the account tool store only when it records this checkout's pins
facts.json selects one entry per package, so a faked-HOME run from a checkout
with other node/python/uv pins re-pointed the account's runtime at them and,
racing a run on the store's pins, failed it with "pinned tool is unavailable".
run_tests.sh now shares only when every tool `pm install` provisions here
(cmd_install's roots, defaults and the venv's deps) is already recorded at the
checkout's pins, via a read-only check with the bootstrap interpreter. An
empty, corrupt or unreadable facts.json and an inaccessible account home now
fall back to the per-home store (quietly) instead of failing setup.
2026-10-27 17:11:28 -07:00
Teknium 5624aefd74 fix(tests): run_tests.sh under a faked HOME provisions from the account's tool store
A test run under a temporary HOME (worktree lanes, probe homes) re-ran
setup-hermes.sh into that home and materialized the whole bundled
toolchain there: 13.8k files / 1.5 GB of python, uv, node, npm, ripgrep,
ffmpeg and chromium, plus 4.9k files / 111 MB of uv's bootstrap
interpreter, per home.

scripts/run_tests.sh now exports HERMES_RUNTIME_DIR (and
UV_PYTHON_INSTALL_DIR for the bootstrap interpreter) pointing at the OS
account's existing store when HOME is not the passwd home, the home holds
no store of its own, HERMES_HOME is unset or under that home, and the
account store has facts.json and is writable. That is the manual
HERMES_RUNTIME_DIR recipe lanes already use, made the default.

Scope: only the runner's own test-environment provisioning shares. The
pytest children start from `env -i` without HERMES_RUNTIME_DIR, the store
resolver (pm.environments.store_root) and setup-hermes.sh are unchanged,
so installer/toolchain/update tests and direct setup or install probes
keep an isolated per-home store. An explicit HERMES_RUNTIME_DIR wins and
is the opt-out; an account without a store installs locally.

Store writes are already serialized: pm/install.py realizes every entry
under Store.install_lock() (flock on <store>/.install.lock), reloads facts
under it and publishes by staged rename.
2026-10-27 17:11:28 -07:00
Teknium 1733a4a653 fix(desktop-update): flush the hand-off log on every exit, encode it only when it opens
Three follow-ups to the queued hand-off log (review on #145712):

- Sync-HandoffLog encoded the whole pending queue before it knew the file
  would open, so a log held through a long step re-encoded a growing queue
  on every poll. It opens the file first and encodes only once it can write.
- -SelfTestLogConcurrency skipped the -InstallRoot requirement but still
  claimed the update marker, and its early exit never released it, leaving
  update state in HERMES_HOME. It no longer claims the marker; its test now
  requires HERMES_HOME to hold no update state afterwards.
- The bounded exit-time flush ran only in the main update's finally. The
  marker helper ops (reclaim/withdraw/custody) and the early marker.ps1
  load failure exited with their lines still queued. Each now flushes after
  its marker lock is released; reclaim/withdraw keep the flush short
  because Electron bounds the whole helper and already has its verdict.
2026-10-27 15:58:38 -07:00
Teknium 3d304a2816 fix(desktop-update): a hand-off log line that meets another holder waits instead of vanishing
The named mutex serializes Hermes's own writers, but any other process holding
the log at the moment of an append (a scanner on the just-closed file, a reader
opened without write sharing) still failed it, and the empty catch dropped the
line for good. The stage relay marks a stage seen before it logs it, so a stage
that lost that race was never written: main's Windows-only job lost "process
cwd set", "running: python ...", the relayed stage and the tail of the
update's output in one run (test_handoff_shows_the_stage_the_update_publishes,
x64), while the same commit passed on arm64.

A line that cannot be appended now waits in a queue and lands, in order, with
the next append that succeeds; the step loop retries every pass and the run
retries for up to 10 s before it exits. Appends use AppendData with
ReadWrite|Delete sharing, so a reader never blocks them and they land at
end-of-file atomically even beside a writer whose mutex wait timed out.
2026-10-27 15:58:38 -07:00
liuhao1024andZCode 0b62510eb3 fix(desktop-update): serialize hand-off log appends across concurrent writers
Write-HandoffLog appended with Add-Content, which is unsafe once a second
writer is live: PowerShell caches a content writer per path, so the second
writer receives a handle the first already consumed. On Windows PowerShell
5.1 that surfaces as a "stream is not readable" error flood; on PowerShell
7 it silently loses lines (58% measured on the issue's 8-writer shape,
#126152). The hand-off log is the only record of why a Desktop update
failed, and overlapping hand-off processes are a real event: the marker
claim is a plain write with no mutual exclusion and its failure path logs
a warning and continues.

Serialize the append behind a named, per-log-path mutex plus one FileStream
per write. The mutex instance is created lazily so any runspace or process
writer joins the same OS object; the bounded wait and the empty catch keep
the "logging must never break the update" contract; an abandoned mutex is
still released so a killed writer cannot leak the hold.

Adds a -SelfTestLogConcurrency fixture (8 runspaces x 100 lines through the
real function, holding the file to the exact count with no torn lines) and
a Windows-lane regression test driving it. Measured with Add-Content
restored: 1026-1157 lines present for 800 written, plus torn lines; with
the fix: 800/800 intact across repeated runs.

Co-authored-by: ZCode <agent@zcode.local>
2026-10-27 15:58:38 -07:00
Teknium ca69d857c3 fix(update): a proxy the user set in .env beats the injected system proxy
The Desktop hands `hermes update` the system proxy in both spellings
(HTTPS_PROXY and https_proxy). The .env load replaces only the spelling
the file uses, and urllib and curl prefer the lower-case one, so the
update still went to the system proxy and the .env NO_PROXY stopped
applying. After the .env load, `hermes update` now undoes everything the
Desktop added when .env sets a proxy (the Desktop injects nothing when it
sees one). A .env NO_PROXY alone replaces the user own entries in every
injected NO_PROXY variant and keeps the bypass the Desktop added; a `*`
there drops the injection.

Review S1 (kshitijk4poor) on #144894.
2026-10-27 13:23:31 -07:00
Teknium 6c4e73376a fix(update): failure tail keeps a progress line's final state; fix proxy env comment
posix.sh stripped CRs, merging a CR-overwritten progress line into one
string whose first 120 chars were its EARLIEST state; it now keeps the last
CR segment, what the terminal showed. checkout-source's comment said the
proxy env comes last, but buildDesktopBackendEnv is spread after it.
(itsflownium review on #144894, small items.)
2026-10-27 13:23:31 -07:00
Teknium df8a320f2a ci: route hermes_cli/env_loader.py to the update suites
update_proxy_env now reads which proxy values a .env load wrote, so the
update path imports env_loader and its changes must start the upgrade lane.
2026-10-27 13:23:31 -07:00
Teknium 20e892f81a fix(desktop): the system proxy reaches only the update's children
Review round on the system-proxy hand-off (#123387):

- The resolved proxy leaked into the relaunched Desktop and everything it
  starts. posix.sh relaunched with the update env scrubbed but the proxy
  env kept, the Windows Start-Process rung and the staged updater pass
  their env on, and gateways `hermes update` restarts inherit it. With a
  NO_PROXY of only localhost, every LAN or internal host the PAC sends
  direct went through the proxy, and the next update read the stale proxy
  as an explicit user setting and never re-resolved it.
  The Desktop now names each variable it adds in HERMES_UPDATE_SYSTEM_PROXY
  and saves a NO_PROXY of the user's it extends as
  HERMES_UPDATE_USER_<KEY>. The Desktop restores its own env at entry (every
  relaunch rung), posix.sh restores it in the relaunch subshells (Linux
  and macOS), windows.ps1 before the tethered Start-Process rung, and the
  gateway and serve/dashboard at start. A marked value is never taken
  for the user's own on the next update.
- NO_PROXY gains 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  and .local, and a user NO_PROXY is extended instead of replaced. The
  user's value previously also overrode the extended one, because
  process.env was spread after the proxy env.
- Each update host is resolved on its own, and the env proxy is exported
  only when every host is either that one plain PROXY or DIRECT. A PAC
  that sends hosts to different proxies gets a logged note and no env.
- SOCKS and HTTPS answers are no longer exported. Chromium's "SOCKS" is
  SOCKS4 and was mapped to socks5h. Beyond that, Python's urllib sends a
  plaintext CONNECT to any proxy URL, npm reads no SOCKS env, httpx refuses
  socks4, and the update's own git calls drop injected git config. They
  get a logged note instead.

Live (Electron 44.9.3 on Xvnc :95):
- Before: the built Desktop launched with a marked proxy env gave its
  Python backend all six proxy variables. After: only the user's
  NO_PROXY=corp.internal.
- The real posix.sh --relaunch-target: `hermes update` still gets the
  proxy, and the relaunched app gets none (before: all six).
- A PAC resolving LAN and corp hosts DIRECT keeps them direct, and so do
  the update's localhost calls.
2026-10-27 13:23:31 -07:00
liuhao1024 e241405cd1 fix(desktop-update): name the failing updater output in the hand-off failure
当 hermes update 在 Desktop hand-off 里失败时,shim 与下次启动的结果
弹窗只显示 "Update failed (exit N)",从不携带 updater 自己的输出,
用户没有任何可达途径看到真实失败原因(#137327)。

从本轮 log 切片(OUT)提取 updater 最后 3 行非空输出(剥离 ANSI/CR、
过滤 hand-off 自身的时间戳日志行、每行限 120 字符)附加到失败文案;
输出为空时保持原文案不变。
2026-10-27 13:23:31 -07:00
PRATHAMESH75 52223a33d2 fix(desktop-update): read macOS creation times with /bin/date under GNU coreutils
proc_ct() parses `ps -o lstart=` with `date -j -f`, a BSD-only flag. The
Desktop hand-off inherits the login-shell PATH, so with Homebrew's coreutils
gnubin first `date` is GNU date: `-j` fails, `2>/dev/null || return 0`
swallows it, proc_ct prints nothing, ident_state returns 2 for the Desktop's
pid, the bridge-marker claim is refused and posix.sh exits 2 on every update.

Pin /bin/date, as the scripts already do for /usr/bin/python3 and xattr.

Fixes #137925
2026-10-27 13:23:31 -07:00
Akloenx123 5a9d72f341 fix(update): compare creation times under LC_ALL=C in ct_close too
ct_close parses two dotted creation times via awk -v. mawk, gawk (also
--posix and --use-lc-numeric) and BusyBox awk all read -v assignments
with a dot under a comma-decimal locale today, so this is no behaviour
change; pinning LC_ALL=C makes the comparison locale-proof by
construction instead of by an awk implementation detail, matching the
write side in proc_ct (#135183).
2026-10-27 13:23:31 -07:00
Akloenx123 7da84c65d4 fix(update): write the hand-off's creation time with a dot under any locale
proc_ct rendered the POSIX hand-off's creation time with awk's printf, and
mawk (the default awk on Debian and Ubuntu) formats %f with LC_NUMERIC's
decimal separator. Under a comma-decimal locale the marker got
ct:1791471938,040, which MARKER_CT_RE and both readers reject, so the
hand-off judged its own claim a previous incarnation, refused to publish
the update child and every Desktop update aborted with exit 3.

Pin that awk to LC_ALL=C, as the ps/date calls beside it already are.

Fixes #135183
2026-10-27 13:23:31 -07:00
Teknium c0016c3cb5 fix(desktop-update): a Close-and-retry runs under its own hand-off run, not an inherited one
Review round on #101789 (R-890-1). A Desktop that the hand-off's tethered
Start-Process rung relaunched inherited that run's HERMES_UPDATE_CORRELATION_ID.
Its next hand-off passed `-HandoffRun Y`, but windows.ps1 kept the inherited id X
for `hermes update`, so take_consent(release, X) dropped the consent bound to Y:
every "Close <program> and retry" in that Desktop session closed nothing and the
dialog came back (fail-safe, but a dead button).

- windows.ps1: -HandoffRun beats an inherited id; an id from the environment is
  kept only when no run was given (older Desktops, other callers).
- windows.ps1: the run's id is removed from the environment before any relaunch
  rung, so the relaunched Desktop never carries it.
- checkout.ts: the Windows script hand-off sets HERMES_UPDATE_CORRELATION_ID to
  the run its consent is bound to (dropped when the script predates protocol 2).
2026-10-27 11:51:03 -07:00
Teknium 4d854d033b fix(desktop-update): offer the Desktop close only when a holder can be closed
With every holder unreadable (a protected process) the result message no longer points the user
at a close button the dialog does not show.
2026-10-27 11:51:03 -07:00
Teknium 719b4c3220 fix(desktop-update): a close-holder consent covers exactly the retry it started
Review round on #101789. The Desktop's "Close <program> and retry" consent stayed valid for an
hour: when the retry failed before the swap (build error, refused apply), the next swap from any
source (gateway /update, cron, another Desktop) closed the program without asking.

- The consent is bound to the retry's hand-off run id (checkout.ts binds it once the run id
  exists; windows.ps1 already exports it to `hermes update` as HERMES_UPDATE_CORRELATION_ID) and
  to the exact processes the user saw (pid + start time + program).
- Every swap attempt consumes the file at its start, matching or not; only the run it was given
  for acts on it. An apply that does not hand off removes it, and the hand-off script removes one
  its run never used.
- A holder whose program path cannot be read (a protected process) is named for the user to close:
  no Close button in the Desktop dialog and no y/N for it at the terminal, since closing it from
  here can never work.
2026-10-27 11:51:03 -07:00
Teknium dc8eccf62d ci: route desktop_release_holders.py to the update suites
main_desktop.py (an update entry point) now imports it to name and close a
process holding release/ on a failed swap, so tests/ci/test_update_ci_routing.py
requires it in _UPDATE_DEPENDENCIES.
2026-10-27 11:51:03 -07:00
Teknium b03699fca5 fix(desktop-update): relaunch the Desktop and point its shortcuts at the user home
The Windows hand-off relaunched Hermes.exe (WMI and Start-Process rungs)
with release\win-unpacked as its working directory, and install.ps1 wrote
the Start menu and desktop shortcuts the same way. Every child the Desktop
starts without its own cwd inherits it, and Windows refuses to rename a
directory that is a live process's cwd, so one helper that outlives the app
blocked every later swap of that folder (#101789). Both now use the user
profile, matching the Electron entry point's own relocation.
2026-10-27 11:51:03 -07:00
Teknium 21ead66c56 fix(desktop-update): windows.ps1 keeps release holders in a plain array (PS 5.1 @() over List[object]) 2026-10-27 11:51:03 -07:00
Teknium ef47d951bd fix(desktop): name the program holding release/ and close it only with consent (#101789)
Windows refuses to rename a directory that any live process uses as its
working directory. A resident helper spawned from a Hermes window (Sogou
IME's SogouCloud.exe in the report) inherits that window's cwd inside
release/win-unpacked and keeps it after the app exits, so every Desktop
stage-and-swap failed with an opaque "Could not publish the desktop build"
although the build succeeded.

The previous commit stopped such processes automatically. A program that is
not part of Hermes is the user's, so this keeps its cwd detection
(hermes_cli/desktop_release_holders.py) and drops the kill:

- When the first swap rename is refused on Windows, every process whose cwd
  is inside release/ is named, one "Desktop app folder held by: NAME (pid N,
  EXE)" line each, and the update failure names it too.
- It is closed only with consent: a yes at an interactive terminal prompt, or
  the Desktop's "Close NAME and retry" choice, which leaves a one-retry consent
  file in release/ naming the programs. Each one is re-checked right before
  (same pid and creation time, cwd still inside, not our ancestor). A hidden
  hand-off or piped run with no consent never closes anything.
- windows.ps1 turns the holder lines into a structured release_holders list
  and a message naming the program; the relaunched Desktop shows it and offers
  the consented close-and-retry (default button: Not now), which runs the
  update hand-off again so the owed app is rebuilt and promoted.

Same goal as #101793 (name the holder instead of failing opaquely), plus the
consented retry.

main.ts: fileExists/directoryExists/isGitCheckout move to electron/fs-probes.ts
to offset the two new lines.

Fixes #101789
2026-10-27 11:51:03 -07:00
Teknium 121393e1e7 fix(desktop-update): the tethered Windows relaunch does not inherit the stage relay
windows.ps1 exports HERMES_UPDATE_STATUS_FILE for `hermes update`'s stage
relay. The WMI and explorer relaunch rungs start the Desktop with a fresh
environment, but the Start-Process fallback inherits this script's, so the
relaunched app (and the gateways it spawns) carried the update's status file.
Clear it on that rung, as posix.sh does on its relaunch.
2026-10-27 07:53:07 -07:00
Teknium 2ff5f4aa2b fix(ci): route desktop_build_reuse to the update and desktop-update suites
source_build imports hermes_cli/desktop_build_reuse.py on the update path, so
tests/ci/test_update_ci_routing.py requires it routed beside desktop_build_lock.py
(its desktop-reuse.mjs script is already covered by the scripts/build/ prefix).
2026-10-27 07:53:07 -07:00
Teknium c9b55ea26d fix(desktop-update): the update window names the stage it is in, on Windows too
The hand-off window showed "Updating code and dependencies" plus a clock for
the whole 20-60 minute update (#139952, #135363, #130460). On Windows the
hermes update child could not publish anything: windows.ps1 never told it
where (posix.sh exports HERMES_UPDATE_STATUS_FILE), and the pipeline's own
stage marks named nothing.

- windows.ps1 exports HERMES_UPDATE_STATUS_FILE and relays what the child
  writes there to the window once a second while the step runs, logging each
  stage to the hand-off log so its timestamps localize the slow one.
- Each pipeline stage mark (snapshot, apply, deps, build) publishes the next
  stage: Downloading updates, Updating dependencies, Building the apps,
  Restarting Hermes services. The desktop build adds "Packaging the desktop
  app" before electron-builder runs.
2026-10-27 07:53:07 -07:00
Teknium 7a954418ce fix(update): reuse the packaged desktop app when no desktop build input changed
hermes update rebuilt and repackaged the Electron app (6-38 min on Windows)
on every commit move, because _packaged_desktop_current_for_head requires the
app's baked commit to be HEAD before the freshness receipt is consulted.

The receipt already content-hashes every desktop build input (sources,
lockfile, build scripts, prepared icons/native trees, renderer env, Node,
platform, arch) and the output. The baked install stamp is the one input it
cannot see. desktop_build_reuse asks whether a build at HEAD would bake a
stamp that differs in anything but commit provenance (commit, commitDate,
dirty, builtAt), using write-build-stamp.mjs's own resolver, and reuses the
packaged app when the receipt is current and it would not.

The reused app keeps naming the commit it was built from: the stamp is never
rewritten (bundle-swap would relaunch into a "swap" forever), and bundle-skew
counts desktop runtime-path commits since it, which identical inputs rule out.
2026-10-27 07:53:07 -07:00
Teknium 349f919c5f fix(sessions): pin the page-first list contract, keep the facade under its cap
Adds the invariant test for #119403: a recency page may rank every admitted
row, but each row outside the page must not also pay the projection (s.*,
resolved prompt, preview subquery over message content). Measured as SQLite
VM steps per extra admitted row at LIMIT 1 versus per projected row: base
413 vs 479 (red), head 143 vs 479.

Moves the compression-tip page projection into hermes_state_session_list.py
so hermes_state_sessions.py stays under 2,000 lines after #125515's page CTE,
and drops the one-off lineage benchmark script from the salvaged branch (its
numbers are in the PR body).
2026-10-27 07:26:36 -07:00
Tugrul Guner 3e8e7bd3bf perf(state): reuse unchanged local lineage projections 2026-10-27 07:26:36 -07:00
unsupportedpastels 3f2b1722b1 feat(nix): ship the Webapp in the Nix package
The Nix package had no Webapp bundle, and `hermes webapp` could not build
one in the read-only store. nix/webapp.nix builds it in the npm workspace
the hook prepares (no native rebuild, no Electron), exposed as
`.#webapp`. The agent assembler takes it as a `webapp` frontend: the
wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the
dashboard.

hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no
freshness check, no build lock, never rebuilt or marked stale, and
`--force-build` says it cannot rebuild it.
2026-10-27 09:42:22 -04:00
unsupportedpastels 3883e02935 perf(webapp): build the browser bundle without downloading Electron
The Webapp recipe installs Desktop's workspace, and node-deps prepared
Electron's binary for any install that selects apps/desktop. The browser
bundle never runs Electron, so prepareNodeDependencies now takes
`electron: false` and build-webapp.mjs passes it. Desktop builds keep the
default.
2026-10-27 09:42:22 -04:00
IAvecilla 11a75355a4 fix(docker): hermes webapp reuses the baked bundle, and local Desktop files stay out of the image 2026-10-27 08:41:33 -04:00
unsupportedpastels 0beda3a109 refactor(build): name the stale-bundle marker outputIntact ignores 2026-10-26 21:30:03 -04:00
unsupportedpastels 148f36ea6c fix(webapp): warn when a previous chunk cannot be kept across a rebuild
keepPreviousGeneration skipped a copy that failed for an unexpected reason (a full disk, a permission error) without a word: the build recorded its receipt and published, and tabs still on the previous generation lost that chunk with nothing in the build log saying why.

The build still never fails here, but a failed copy, an unexpected directory error, or a failed cleanup of the partial copy now warns with the path and error code. The expected cases stay quiet: no previous build, a rejected manifest entry, a new file standing where the name or its directory would go (including EEXIST from COPYFILE_EXCL).
2026-10-26 21:30:03 -04:00
dokterdok f22598136d fix(webapp): keep the previous bundle's lazy chunks across a rebuild
A Webapp tab that was already open crashed the next time it opened a
lazy view such as Settings once `npm run build:webapp`, `hermes webapp`
or `hermes update` had published a new renderer. Publication replaces
apps/desktop/dist-webapp, which the gateway reads per request, so the
old entry's hashed lazy chunks 404 and the rejected import reaches the
app's error boundary until the page is reloaded.

Before the receipt is written, the builder now copies into the new
generation the files the previous generation's Vite manifest names that
the new one lacks: only that generation's own files, so a rebuild keeps
exactly one generation back; only while its bytes still match its
receipt (freshness.mjs gains outputIntact, which ignores the marker a
failed `hermes update` leaves in the bundle: that still forces the
rebuild, but no longer costs open tabs their chunks); only regular
files inside it, never through a symlink and never over a new file. A
bad entry is skipped and never fails the build. The builder names the
manifest itself, so it exists whatever the Vite config says.

Refs #141864, #93508

(cherry picked from commit 096996d1a591917b21c96e104d20e9dc54c5d850)
2026-10-26 21:30:03 -04:00
Teknium 4ea24baeb1 feat(shared-metrics): count prepared-compaction adoption and outcomes
Opt-in shared metrics could not say whether anyone turns on
compression.prepare_ahead, or whether the passes it starts pay off.

- hermes.install.snapshot gains compression_prepare_ahead (on/off), read
  from the profile config with the agent's own truthiness. Pre-upgrade
  snapshot rows keep a legacy field set and the v4 schema field is
  optional, so they still validate and package.
- New closed-enum counter hermes.prepared_compaction.count (mark name =
  metric name, projected by the decision-mark table): event, origin,
  reason, failure_class (compression vocabulary), wait_bucket.
  record_prepared_compaction() takes raw values, normalises inside, is a
  no-op unless collection is on and never raises.
- Vocabularies live in shared_metrics_prepared_compaction.py (the
  contract sits at the 2,000-line cap); the contract imports it.
- v4 schema extended in place (additive), docs row + snapshot bullet,
  relay smoke asserts the snapshot field.
2026-10-25 22:55:11 -07:00
Teknium 95aed4f8de fix(telemetry): smoke waits for the gateway's turn rows instead of assuming the stop settles them
Review finding "Exit can drop queued metrics" (Greptile P2 + independent review W2): the smoke's
docstring claimed stopping the auto-started gateway settles its asynchronous rows, but the gateway's
graceful exit ends in os._exit, which skips the shared-metrics flush. Poll the probe home's store
(read-only, so polling never creates it) with a 30 s deadline until the gateway turn rows validate,
then stop the gateway; on expiry the store validation reports what is missing.
2026-10-25 22:27:40 -07:00
Teknium 7191be2fc5 fix(telemetry): smoke stops only the probe home's verified live gateway
Review finding "Cleanup repeats weaker process checks" (Greptile P2 + independent review W1): the
smoke's gateway stop trusted the probe home's gateway.pid record (kind + home) and signalled whatever
process held that PID, so a live non-gateway process or a recycled PID named by a stale record was
terminated. Resolve the PID with gateway.status.live_gateway_pid_for_home (start-time reuse guard,
gateway command line, home ownership) and do nothing on a miss; psutil binds the create time so
terminate() refuses a PID reused in between. The driver puts --hermes-repo first on sys.path so the
check comes from the tree under test.
2026-10-25 22:27:40 -07:00
Teknium 142dc8bbbd fix(telemetry): relay shared-metrics smoke passes on main again
The smoke's expected counters predate three merges, so on origin/main it stopped at the first
assertion:

- #106742 (unified gateway runtime): `hermes chat -q` is now served by a gateway it auto-starts
  for the home, which adds hermes.gateway.reply_latency and hermes.platform.delivery (platform
  local) and a second hermes.startup.latency row (surface gateway_boot).
- eb2edce53a: the turn's read_file call now also records hermes.tool_batch.count.
- e76fb951a1: a failed update counts the stage it died in, so the fetch-failed receipt adds an
  (apply, failed) hermes.update.stage row.

Expectations now live in one place: a single EXPECTED_METRIC_NAMES set for the SQLite and the
package checks, exact dimension dicts for the new rows, the update stages derived from the receipt
the probe feeds (UPDATE_RECEIPT, also used to build the probe), and both startup surfaces.

The smoke also stops the gateway the chat auto-started (pid record of its own home, then wait),
so a run no longer leaves a detached `gateway run --idle-exit` behind for 10 minutes, and the
gateway's asynchronous rows land before the package export.

The smoke is not run by CI, so the test file now pins its expectations to the contract: every
expected name is a contract counter, every exact dimension dict has the metric's current field set
and passes counter_dimensions_are_valid, the startup surfaces are contract surfaces, and the
expected update rows equal update_receipt_fields(UPDATE_RECEIPT).
2026-10-25 22:27:40 -07:00
Teknium 749cb638ff fix(config): an unreadable config never serves the TUI's cached empty config; review fixes
- tui_gateway _load_cfg_raw: only FileNotFoundError means "no config".
  config_version_or_none folded every stat error into None, so after a
  cached absent config a transient EACCES matched the cached {} and the
  next config.set could save it over the now-existing file. Any other
  stat error is a FailedConfigRead again, which _save_cfg refuses.
- ConfigBackend.read_user_doc replaces read_user_layer/UserLayer: no
  caller used the version it stat'ed on every uncached read; cached
  loaders already ask config_version first.
- Reader lint scopes follow Python: a self.<attr> bound in one method is
  seen by every method of the class, and a comprehension's target no
  longer leaks into the enclosing function. The class rule found
  pm/publication.py's compare-and-swap re-read of self.path, now marked
  like its twin in __init__.
2026-10-25 18:54:09 -07:00
kshitijk4poor adebe1f8be fix(config): gate the readers main added since the rebase; adopt main's lint rules
Rebasing onto current main brought in direct config.yaml readers that the
seam's reader gate (scripts/check_config_yaml_readers.py) now rejects:

- gateway/session_config.py: the session `config.get mtime` getter uses
  config_exists/config_version + the TUI's _version_token, like
  tui_gateway/methods_config.py already does.
- hermes_cli/profiles.py: _strip_multiplex_flag checks config_exists.
- hermes_cli/config_backups.py: the byte comparison sits inside the
  supports_file_tooling()-gated block; marked like its siblings there.
- hermes_cli/update_receipt.py: _bare_collection_enabled runs in the bare
  bootstrap interpreter with no config stack; presence stat marked.

Main also turned on UP006/RUF100/B009 since the branch base: builtin
generics in the seam's new modules, stale noqa comments dropped, and the
newly unused typing imports removed.
2026-10-25 18:54:09 -07:00
kshitijk4poor 8c63d46098 fix(config): address Greptile's third review of the config backend seam
- pm read_home_selection: only a missing config.yaml means "no config". A symlink loop or
  permission error used to look like "no file" (config_exists is Path.exists), so PM published a
  dependency generation without that home's plugins; it now refuses like any unreadable config.
  The stat runs before the YAML stack (FileBackend.version_path stats before importing utils),
  so PM's bare runtime tree still needs no YAML for a home without config.
- check_config_yaml_readers: alias bindings iterate to a fixed point instead of two passes, so
  `first = home / "config.yaml"; second = first; third = second` binds `third` too.
- ConfigIssue/_issue move to hermes_cli/config_issues.py, imported by the facade and its check
  siblings, so config_value_checks.py / config_home.py no longer import the facade at all.
2026-10-25 18:54:09 -07:00