A child that streams megabytes without a newline grew LiveTail's partial
line (and the retained tail) without limit: 8MB of output kept ~25MB.
Keep only the last MAX_LINE characters of an over-long line; its end is
the informative part.
The streamed-backend-output test now runs in verbose mode: live backend
output is the verbose/CI contract, and quiet mode deliberately contains it.
About showed a bare "v0.21.5" for a checkout 1913 commits past the tag
(`hermes --version`: "v0.21.5+1913.gf83a9e9"), because /api/health only
reports the base release; the statusbar showed the version plus the exact
commit beside it.
VersionInfo.display_version is the short `<release>+<distance>` form and
/api/health carries it as displayVersion (older backends fall back to the
bare release). One renderer formatter, shortVersion, turns any version into
that form, and About, the updates overlay, the statusbar and the command
palette all label through it: "v0.21.5+1913". The full commit stays in the
statusbar tooltip and the expanded version details.
`hermes desktop` packs the app from a source checkout with the same
`bootstrap` payload the Desktop installer shell carries, so an install.sh
checkout that ran `hermes desktop` showed "Desktop app (installer)" in
About. Released shells are CI-stamped and never rebuilt (the checkout under
them updates), so a local/fallback build source now selects
"Source (install script) + hermes desktop" or "Source + hermes desktop".
A source checkout on the main channel failed every update check with
"Could not resolve the main source channel: Channel object not found:
releases/channels/main.json" until R2 publishes that record, in the CLI
and in Desktop (which asks hermes_cli.source_check). main IS the source
branch -- its record can only add a retirement -- so a missing main record
now resolves to the main branch and the update continues via git. Other
channels, and transient read failures for main, still refuse.
Main-era installs selected [all] and then lazily installed opt-in extras
(FAL, messaging SDKs, ...) into the checkout's venv with no ledger. The
legacy -> PM migration (launch completion and the historical updater
takeover) selected only [all], so the first launch after `hermes update`
prompted "Extra 'fal' requires: fal_client" for a feature that already
worked.
pm.extras.legacy_selection reads the old venv's site-packages (never
imports it) and selects every non-umbrella, platform-supported extra whose
anchors it held. The install prompt that remains for genuinely new
features names the feature instead of Python module names.
The PM-clean installers dropped the old "restart your terminal" /
"source ~/.bashrc" closing line, so a fresh install ended on "Run: hermes"
in a shell that could not find it.
install.ps1 now also prepends the bin dir to $env:Path. That variable is
process-wide, so under the documented `irm | iex` path (and `& .\install.ps1`)
the caller's own window resolves `hermes` immediately. When run as a script
file whose inherited PATH lacked the bin dir (e.g. `powershell -File`, a
child that cannot touch its parent), the ladder ends with one arrow line:
restart the terminal, or reload $env:Path from the User and Machine values.
Setup/gateway already launch hermes through the resolved runtime command.
install.sh prints the equivalent "open a new terminal, or run: source <rc>"
line after the ladder when the inherited PATH lacks ~/.local/bin; the
installer is always a child and cannot change its parent's PATH.
Same presentation as install.sh, in the pre-pm installer's ASCII glyphs
(-> [OK] [!] [X]; PS 5.1 reads a BOM-less script as ANSI). Invoke-Logged
wraps the git, uv, pm and source-completion calls: on a console one status
line plus logs\install.log and a failure tail; CI, -Verbose or redirected
output (the -Json stage driver) stream through Out-Host as before. It keeps
Invoke-Native's contract: $LASTEXITCODE stays the caller's to judge, and
nothing reaches the pipeline, so value-returning functions can call it.
windows-build-deps.ps1 runs under pm (installer, hermes update) with the
console as stdout, so the vcpkg clone/bootstrap, the OpenSSL port build
(patch application and all) and rustup get the same status line, logged to
build-tools\build.log; discovery notes print only when streaming. -Verbose
exports HERMES_INSTALL_VERBOSE so that child streams too.
The pm-era installer printed "[hermes]" lines between raw git, uv and pm
output. Restore the pre-pm installer's look (→ ✓ ⚠ ✗, banner on the full
ladder) and route every child command through run_logged: on a terminal it
shows one status line rewritten with the command's newest output line
(git clone phases via --progress), appends everything to
$HERMES_HOME/logs/install.log, and on failure prints the last 20 lines plus
the log path. CI, --verbose, HERMES_INSTALL_VERBOSE and a non-terminal
stdout (the Hermes-Setup --json driver, E2E transcripts) keep the full
stream, so their parsers see what they saw before. Errors stay on stderr.
uv, npm ci, icon generation and the desktop build printed hundreds of
lines on every interactive install, update and `hermes desktop`.
pm/progress.py holds one policy. CI (CI / GITHUB_ACTIONS) or
HERMES_VERBOSE=1 streams child output unchanged. Otherwise each child is
one "→ label…" line, rewritten in place with its latest output on a
terminal, or just the start and finish lines when piped. A failure
always prints the last 80 lines. HERMES_VERBOSE=0 forces containment.
Applied to PM's uv runs (no uv --verbose outside CI; quick steps stay
silent unless they fail), the source build scripts (node-deps, TUI,
icons, web; npm warn lines stay out of the live line), the desktop
build and package steps, and the Windows ARM64 vcpkg/OpenSSL provider.
The policy reads the parent's environment, so the CI=1 the builders
force on their node children does not switch it to verbose.
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.