The first CLI launch after a PM install starts tirith's download in the
background, so ensure_installed() returns None and the CLI printed
"tirith security scanner enabled but not available". The scanner was on
its way, not missing; the same warning also fired when lazy installs are
disabled by the operator's own policy.
missing_is_expected() tells those by-design states apart. The CLI logs
them and keeps the visible warning for a missing explicit tirith_path or
a finished download that failed.
`hermes setup` launched by install.ps1 on PowerShell 5.1/conhost printed
`[35m`/`[0m` literally. hermes_cli.colors and the skins emit raw SGR codes
whenever stdout is a TTY, but no Hermes entry point ever set
ENABLE_VIRTUAL_TERMINAL_PROCESSING (origin/main didn't either; only
pm/cli.py did, for its own progress line), and shells hand native children
a console with VT off.
hermes_bootstrap now opts the stdout/stderr console in at import, before
anything prints and before the venv relaunch (the mode lives on the console
buffer, so the child inherits it). Non-console handles are left alone; a
console that refuses VT gets NO_COLOR so it shows plain text instead of
escape garbage. ctypes only: colorama is merely transitive.
release.py was 2,804 lines, 2,076 of them the frozen legacy author dict.
The map and its resolver now live in scripts/releases/: authors.py holds
the directory loader, the merged AUTHOR_MAP and resolve_author, and
authors_legacy.py holds only the frozen LEGACY_AUTHOR_MAP literal (same
1,895 entries, same order). release.py drops to 707 lines.
The contributor-check job and audit_pr_attribution.py grep the legacy
file for quoted emails, so both now read authors_legacy.py. The
importers (contributor_audit.py, add_contributor.py), contributors/README
and the tests read the defining modules. No behaviour change.
The English TTS page already explains that the kittentts extra is gated
off on the managed 3.14 interpreter (misaki requires <3.13); the zh-Hans
page still presented KittenTTS as installable.
The Dockerfile now stages only PM's pinned full Chromium (no headless
shell) for both the slim and -desktop variants. docker.md still listed a
headless shell, and bot-screen.md claimed the official image had no
headed browser and that the -desktop build added Playwright's headed
Chromium. Correct both, note the size cost of the full build on slim
images, and describe the browser pick order browser.executable() uses
(managed first, system first only for non-root under restricted userns).
test_stable_release_graph and test_desktop_build_cache asserted gate text:
`== "always()"`, `== "false"`, `"conclusion != 'success'" in`,
`"!cancelled()" in`. Both files now evaluate the gate with the shared
evaluator, which also resolves `steps.*` now.
- The stable gates (acceptance, publication, complete) must run when every
ancestor failed.
- Deferred desktop e2e never runs.
- The publication reconciler runs after a failed, dispatched Stable Release
of this repository and on manual dispatch. It refuses a successful run, a
push-triggered run, and a fork's run.
- No desktop-build-cache step runs during cancellation. The service-failure
report runs when restore or save failed and stays quiet otherwise.
setup-pm-cache.test.mjs compared `if:` expressions as literal strings and
indexed `build-${platform}-${arch}-{release,commit}` by id. So the per-arch
split, or rewording a gate, broke it while the cache contract still held.
- A small evaluator for the Actions expression subset. Gates are asserted by
how they behave:
- The exact-key wheel save runs only for callers with extras or a test
environment, and never together with the rolling restore.
- The prune runs after a failure but not on cancel.
- The upload needs a successful prune.
- Desktop build legs are the jobs that use the desktop-build-cache action,
keyed by matrix target and cache-mode. A new check requires a write leg and
a read leg for every native target. The write leg runs only for tag builds;
the read leg runs for commit and channel builds.
- Candidate saves are evaluated: they run after a successful prepare of a
trusted dispatch (a tag build, or a main push for the payload). They do not
run on a failed prepare, a cancel, commit or channel builds, pull requests,
other branches, or a foreign ref.
- Selection gates are found by SELECTED_BUILD_SUCCEEDED. Each needs validate
plus one write and one read leg of a single target, and refuses a failed
admission. The selection logic and the "only gates consume legs" invariant
are covered in tests/ci/test_desktop_bundle_smoke.py.
These tests still named desktop jobs by id, or compared `if:` expressions as
literal strings: build-win32-x64, build-win32-x64-release,
assemble-win32-bundle, publish-canary, builds-table, termux-deb.
- tag build summary: found as the table the canary publisher waits on. Its
gate is evaluated to run over failed builds and to refuse a rejected tag,
commit builds, dry runs, stable phases and channel builds. Failed needs are
the selection gates, the termux builder and the Windows updater publisher,
all found by role.
- termux/input archive: the validate archive step, the setup-pm archive step,
the canary prune R2 steps and the Termux native gate are evaluated, not
compared. evaluate() now resolves `env.*` for the R2-credential gates. "No
build needs a separate archive job" now covers every job, not three ids.
- store eligibility and protected canary: jobs come from the assembler, native
leg and canary publisher roles. Stage scripts come from the receipt the
step stages.
test_commit_build_staging and test_channel_build_publication ran staging
scripts from jobs named by id and step name, and checked literal `if:`
substrings. The per-arch build split broke them for that reason alone.
- Staging scripts come from the native leg for a target and trust branch, the
universal assembler, or the termux builder. Within the job, the step is
found by the receipt it stages (--commit-build or --channel-request). The
summary and channel publisher are found by the table they render.
- Summary needs come from the summary job's own needs, not a copied list.
- "Selection env mentions needs.<id>-commit.result" becomes "no native leg is
fail-fast". That is what the commit summary relies on. The selection logic
itself is evaluated in test_desktop_bundle_smoke.
- Channel exclusion is evaluated: on every native leg the tag/commit stage
stands down for a pinned channel build and the pinned-request stage runs.
The commit-only status page is evaluated to run for a commit build and to
stand down for channel and disposable-channel dispatches.
The desktop release tests indexed jobs by id and compared `if:` expressions
as literal strings, so splitting one build job into per-arch legs broke them
while every property they guard still held.
tests/ci/desktop_release_roles.py finds each job by what it does: the build
cache action a native leg uses, the receipt a job stages, the smoke workflow a
job calls, the jobs it needs. A lookup that matches zero or several jobs fails.
It also owns the expression evaluator, now with success()/failure() and step
semantics, so gates are asserted by evaluating them, not by substring.
- tag admission: every signing job checks out the admitted SHA or the run's
own controller, and builds from the admitted SHA outside channel builds.
This is checked by resolving each checkout per dispatch shape. Every native
leg (not one hardcoded pair) checks credentials before it builds, and that
check runs for each downloadable dispatch and not for a dry build.
- bundle smoke: native consumers come from the graph (direct needers of a
selection gate or smoke that are not always() observers). Each must fail
closed in every dispatch that admits it. A new invariant: only a selection
gate may need a native build leg. The canary publisher must refuse every
failed need, not a hand-picked list.
tests/conftest.py had grown to 2,343 lines, past the 2,000-line gate. Move
three self-contained topics out, unchanged:
- env_filter.py: the credential / behavioral env-var name tables the
hermetic fixture blanks;
- live_system_guard.py: the autouse live-system guard fixture, its marks
and the protected checkout roots;
- platform_gating.py: the platforms() marker evaluation used by the
collection hook.
conftest imports them instead of listing them in pytest_plugins: it is not
the rootdir conftest, and pytest fails a run that loads a non-root conftest
carrying pytest_plugins after startup. Imported fixtures register on the
conftest module under their old names, so autouse order is unchanged.
Tests that reached into the moved symbols now import the new modules.
The checkout guard decided whether `git config` writes a URL rewrite by
substring-matching every argument, so `url.<base>.pushInsteadOf` and the
Git 2.46 `git config set <key>` form slipped through, while a value that
merely contained "url." could trip it. Find the key positionally (skipping
option values and the subcommand) and match insteadOf/pushInsteadOf on it.
uv and node already come from pm's sha-verified lock; the base image was
the last input pinned by tag only. Pin both FROMs to the multi-arch index
digest (sha256:e2d08da6f42ef4b09b165d55528a12727aeed8240dc9edf888e3ec07e10ef9da) so every arch resolves the same reviewed base.
pre raised the shadow-storage cap to 128 GB and wrote the originals only
after the whole loop, so a pre that died mid-way left a raised cap with no
record for post to restore from. The cap must stay raised until post (it
is what keeps the snapshot alive during the update), so an exit trap is
wrong; instead record each original before raising it, and have status
list every recorded cap with the vssadmin command that puts it back when
post is never run.
The gateway stop the review flagged (`pkill -f "hermes gateway"`) is
already scoped: the .sh post runs `hermes gateway stop` under this
HERMES_HOME.
The full ladder runs every stage in one PowerShell process, and venv,
python-deps, source completion and launcher publication each called
Get-BootstrapPython, repeating the uv probe and `uv python find`.
Memoize the resolved interpreter in script scope. The existing
find-before-install order is unchanged.
The fixture imports pm and scripts.bundles, which only the prepared
runtime provides. Falling back to py/python3 on PATH failed later with
an unrelated ImportError; fail up front with a clear message instead.
ScopedLocalModelsSettings was one ~760-line component that threaded
`owner` into every child. The pane now provides its owner through
LocalModelsOwnerProvider (still keyed by owner, so each connection/profile
remounts), and renders sibling components that read it from context: the
quickstart hero, runtime, hardware, models (catalog and sideloaded rows)
and browse sections. Request actions live in local-models-actions.ts and
keep the current-owner toast fence.
Two intentional differences: the delete spinner is per row instead of one
shared id, and the runtime install/update buttons pass the pane's owner
and QueryClient explicitly (the unused handleInstallRuntime is gone).
useRemoteSetup covered the credential form, the connection test and the
OAuth login in one hook. The test/feedback leg now lives in
useRemoteConnectionTest and the login/logout leg in useRemoteOAuth, both
fenced by the probe's target generation as before. useRemoteSetup keeps
the form state and composes the three legs; its public shape is unchanged.
The fleet-restart-pending discharge parsed the marker inventory and
probed a gateway-less host inline (CC 36). _marker_owed_gateways now
turns the inventory into the owed set (raising ValueError, which the
caller's existing handler already maps to "keep the marker"), and
_discharge_gatewayless_marker owns the #118742 host probe. Every
verdict is unchanged; the caller is at CC 21.
_cmd_update_check (CC 30, 224 lines) did debris cleanup, channel
resolution, the scoped upstream/origin fetch, shallow-graft repair and
two verdict printers inline. Those steps move to the topical sibling
hermes_cli/update_cmd_check.py; the facade function (a frozen updater
surface) stays in update_cmd and orchestrates them at CC 10.
Output, exit codes and git argv are unchanged. The sibling reads
_capture_head_sha / _no_prompt_git_kwargs through the facade and
imports gitlock, source_releases, source_check and config per call, so
existing monkeypatch seams keep reaching production.
recover_if_needed (CC 33) and _restore_holding_claim (CC 30) carried
their failure bookkeeping and git put-back steps inline. Extract
_missing_environment, _count_failed_attempt and _put_back_paths so
both land at CC 24-25 with the same ordering: a failed restore still
skips the rm, and the attempt bump stays best-effort per marker.
The sealed-tree steward ladder in evaluate_update_admission was four
if-rungs that differed only in the install method whose command
remediates the tree. A steward -> method table plus one helper replaces
them; the refusal code, message and command are unchanged for docker,
nix, apt-termux, desktop-app and unknown future stewards.
The cold-runtime e2e and the uv dependency-leg test ship or digest the host
interpreter as if it were a standalone Python. The earlier skips matched one
file (sitecustomize.py) and one path (/usr), so /usr/local, Homebrew or a
distro stdlib with other absolute links still failed.
Skip instead when the shipped stdlib holds a link tarfile's data filter
refuses, or when the prefix has unreadable files or is far larger than a
single interpreter.
Only a manual `hermes pm gc` reclaimed old app and PM runtime generations
on native installs; the Docker boot already collects after its refresh.
A successful venv sync (hermes update and launch-time completion) now runs
the same collectors, which keep selected, leased and day-young generations
and skip when another install holds the lock. Cleanup errors are logged and
never fail the committed update.
runtime_python() released .prepare.lock on return and the worker/CLI child
only leased its generation once its own code ran. A publish plus `pm gc` in
that window could remove the generation the child was starting from.
The resolving process now leases the generation it returns while still
holding .prepare.lock (which the collector also takes), once per generation
for the life of the process, so the child is covered until it holds its own.
DebPackage kept its own 116-line member walker next to
pm.store.extract_tar. Both enforced the same containment rules, so the
.deb data.tar now goes through extract_tar (which accepts an open
stream) and FilterError maps to InstallError. Link fallbacks on hosts
without symlink support come from tarfile's own copy fallback, which
also resolves chained aliases. The Termux mode normalization stays in
DebPackage, since it is .deb policy and not extraction.
The per-member FilterError/OSError swallow in pm/packages.py was already
removed; Git's MSYS /proc link exemption is unchanged.
Choosing the worker command (including the cold-runtime lazy refusal and
its receipt) and mapping a worker error back to a caller exception move
out of pm.client._request. Behaviour is unchanged; CC drops from 40 to 26.
cmd_install: flag validation and the venv/test-environment phase move
to helpers (CC 40 -> 18). cmd_update: resolution, per-row report, pin
application, uv and npm refreshes move to helpers (CC 51 -> 22). The
messages, order and exit codes are unchanged; a dead `target` local in
cmd_update is dropped.
Extract settling an interrupted publication, the facts/stage-marker
currency check, the verified bundled copy and the repair log out of
pm.install._install. Order of checks, locking and errors is unchanged;
CC drops from 40 to 25.
sync_venv took four mutually exclusive plugin kwargs (plugin_dirs,
extra_plugin_dirs, selection, staged_plugin) and venv_is_current two, with
runtime ValueErrors guarding the combinations. Replace them with a single
`plugins=` argument typed as one of pm.plugin_inputs.Members, Candidates,
Selection or StagedUpdate, so a conflicting request cannot be expressed.
The module also owns the worker wire encoding that client.py and worker.py
each duplicated.
pm.install.sync_venv is split into cohesive helpers (feature policy,
install lock, publication snapshot, target selection, commit) with the
same ordering, receipts and recovery; its CC drops from 54 to 16.
All in-tree callers and tests move to the new argument.
pm imported runtime_state's private helpers (_lock, _atomic_bytes, _bytes,
_digest) at a dozen sites while runtime_state imports pm.environments at
module top. The primitives are pm's: move them into the stdlib-only
pm.filesystem as lock_fd, durable_write_bytes, read_bytes_or_none and
file_digest, and repoint every pm caller.
runtime_state keeps the private names only as import aliases: it still
calls them through its own globals, and pre-PM updaters load them by these
names mid-swap (tests/compat/old_updater_surface.json).
Boot-subset test fixtures now copy pm/filesystem.py, since runtime_state
imports it at process boot; worker-injection tests patch the name
pm.publication now reads.
72df5aa60e dropped HERMES_DISABLE_LAZY_INSTALLS=1 from the image: opt-in
extras install into PM generations under $HERMES_HOME, never the sealed
/opt/hermes tree. The hosted write-policy smoke test still required the
refusal, so the desktop/amd64 image job failed. Assert it is unset.
The workspace npm ci printed nothing until 'added N packages': builders
set CI=1, which turns npm's progress off, and the stage name only went
to the desktop UI's status file.
Print a line before npm ci, and pass --progress=true so a terminal gets
npm's spinner back (npm still shows it only on a TTY, so piped output
such as the desktop app's log stays clean). The flag stays out of the
receipt-keyed args, so existing installs are still reused.
`release.py release` gains two flags. They can be used together.
--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.
--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.
The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.
The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.
A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:
- The next version was derived from it, so the next cut would reuse the
version. It now takes the newer of the R2 head and the newest
published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
their publication pass, so a bundle-less release would re-advance
every 15 minutes. The head is now the newer of the R2 head and the
published release whose final tag binds the Docker stable alias
digest.
`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.
Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:
- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]
Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
A Hermes Cloud agent authenticates through the silent per-agent cascade
(cloudAgentSilentSignIn), which was only ever invoked from the settings
"Use gateway" button. Boot went straight to waitForHermes, so once the
agent's session cookie expired the ticket mint answered 401, the app
reported "not signed in" and latched reauth, even though the portal
session it needed to recover was still live. Every relaunch needed a
manual click.
Boot now runs the cascade once and retries once, only for remoteKind
cloud + authMode oauth on the terminal reauth error and only with a live
portal session. Everything else surfaces unchanged.
Folders already installed by shipped builds have no marker, and reconcile
refused them on every pass, so the stuck "copying…" row never recovered.
Reconcile now stamps such a folder in place when its `plugin.js` is byte
for byte the package's own half — that can only be our pre-marker copy,
and adopting it writes no files. Anything that differs is a standalone
plugin the user installed and is still never touched (#112450).
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
`installDesktopPluginFromGit` published the desktop half with no
`.hermes-package.json`, unlike every other publisher. Without that marker
the Plugins page has no evidence the copy belongs to the agent package:
the agent row sat on "copying…" forever beside a second, standalone row,
and the half loaded default-enabled instead of opt-in.
A repo carrying both halves now lands under the AGENT package name and is
stamped as part of the same staged publication, so this path and
`reconcileUnifiedDesktopHalves` converge on one folder (#100412) instead
of racing to two. A desktop-only repo is unchanged: git-derived folder
name, no marker, standalone.
The Plugins-page test moves with it — pairing is the marker's job, so the
page keeps an unmarked copy as its own row rather than guessing from the
folder name.
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
A catalog install can leave desktop-plugins/<name>/plugin.js with no
package marker. The Plugins page must show that copy on the agent row
instead of a second row stuck on copying.
The incremental Edge/non-streaming TTS fallback re-polls the pending reply every 150 ms until the response finishes. Nothing cancelled that timer when the hook unmounted or the speech session was dropped, so a tick could fire after teardown (CI: 'ReferenceError: window is not defined' at use-voice-conversation.ts poll, from the rearm test file). The poll's cancel is now held in a ref, cleared by dropSpeechSession() and by an unmount effect.
status flips to 'listening' only after handle.start() resolves, so the
synchronous assertion right after waitFor(start x2) raced (2/9 flakes on
'speaks completed fallback sentences before the response finishes').
Wrap it in waitFor; same assertion, no longer timing-dependent.
- poll: flush the sentence buffer when the interim bubble is sealed but a
tool is still running (mirrors feedSpeechSession's session.flush), so its
trimmed last sentence isn't held for the whole tool run.
- playSpeechText gains a syncOnly option; the per-sentence fallback uses it
so each sentence no longer re-runs directTtsConfig/resolveSpeakStreamUrl
and a speak-stream WebSocket that already answered fallback.
- single ownedSequence baseline per sentence.
Drop the ad-hoc <think> handling (narrower than markdown-preprocess's
REASONING_TAGS; prose mentioning '<thinking' held the rest of the reply
until finish) and the unused minSentenceChars parameter. The fallback is
fed text-parts-only reply text, and the streaming session's ingest does no
think stripping either, so this restores parity.
Keep: speaks completed sentences before the reply finishes; Stop halts the
queue without re-arming. Buffer unit tests dropped — the splitter is
cutSentences(), already covered in speech-text.test.ts.