The Desktop client writes the SSH session token under $HOME/.hermes/desktop-ssh
(a literal ~/.hermes/desktop-ssh in apps/desktop/electron/remote-lifecycle.ts,
expanded against the account's $HOME), independent of HERMES_HOME and the active
profile. But _read_ssh_session_token_file validated it against
get_hermes_home()/desktop-ssh, which a non-default sticky profile re-homes to
<root>/profiles/<name>/desktop-ssh (and any custom HERMES_HOME points elsewhere).
relative_to() then rejects every token as "not under the desktop-ssh directory",
so SSH remote mode is broken under any non-default profile.
Anchor to Path.home()/.hermes/desktop-ssh so the validator matches the exact
directory the client writes to, across default, profile, and Docker layouts.
Adds profile / custom-root acceptance tests and a profile-local rejection test.
Fixes#69551.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop lint rule added after the original PR bans mirroring reactive
values into refs via useEffect. Rework the setting to seed from draft-null
state, guard profile switches by stale-config identity, and derive the
save base + rollback value from the shared config record instead of
latestConfigRef/lastSavedRef/seededRef.
Adds a new config option terminal.font_family that lets users customize the
CSS font-family for the desktop app's embedded xterm.js terminal.
Previously the font was hardcoded in use-terminal-session.ts:
'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace
Now the value from config.yaml (terminal.font_family) is threaded through:
useHermesConfig → PersistentTerminal → TerminalTab → useTerminalSession
When font_family is empty or unset (default), the built-in fallback is used,
preserving backward compatibility. Users with Nerd Fonts installed (e.g.
CaskaydiaCoveNerdFont) can now set:
terminal:
font_family: 'CaskaydiaCoveNerdFont', 'JetBrains Mono', monospace
Closes: #terminal-font-config
The no-args /model picker calls list_authenticated_providers(), which walks
every provider through load_pool(). For copilot, _seed_from_singletons()
re-runs the raw-token -> API-token exchange on every pass. When the exchange
is rejected (HTTP 403: token not Copilot-entitled, revoked, org-blocked),
the transient-network retry loop slept ~4.5s (1.5s + 3.0s backoff) before
degrading to the raw token — and nothing cached the failure, so EVERY picker
open, provider discovery pass, delegation spawn, and dashboard credential
listing paid the full 4.5s again.
Measured on a machine with a 403-rejected gh token: /model picker payload
build went from 7.3s to 1.0s cold and 0.06s warm.
Fixes:
- Permanent HTTP rejections (401/403/404) skip the retry backoff entirely —
the loop exists for startup network races, not auth rejections.
- Negative cache keyed on token fingerprint: failed exchanges are not
re-attempted for 30min (auth rejection) / 60s (transient network error).
- Success and evict_cached_exchanged_token() both clear the negative-cache
entry, so the runtime stale-credential recovery path still forces a fresh
exchange.
Two regressions from moving Edit Models into the shared catalog:
The row opened its own separator group, so the composer showed two rules in
the trailing block where it had always shown one. Edit Models now renders
inside the host footer's group — same single separator, same order.
The panel also read the catalog with a non-reactive getQueryData. With no
model in the session store yet (a fresh draft), currentPickerSelection falls
back to the catalog's reported current, and a cache peek never repaints once
that resolves — the menu could sit on a stale or empty selection. Back to a
live useQuery on the same key, which React Query dedupes against the menu's
own subscription rather than double-fetching.
The board listed every model while the composer honoured the user's Edit
Models shortlist, because visibility arrived as a prop each caller opted into.
Curation is one stored preference, so a per-caller opt-in guarantees the two
surfaces eventually disagree about what "my models" means — the exact drift
extracting the menu was meant to end.
ModelCatalogMenu now reads $visibleModels itself and renders the Edit Models
row, so every picker shows the same shortlist and offers the same way to
change it. The composer's footer keeps only Refresh Models. No plugin surface
is involved: the menu is core and the plugin just mounts it.
Also restores the session-scoped catalog fetch the extraction dropped. The
composer's query key must carry its session id — the app invalidates the
session-scoped key on model changes, so a global key would have gone stale
mid-conversation.
The desktop board had no model control at all: a task ran whatever the profile
you assigned it to happened to be configured with, and the only way to point
one task elsewhere was `hermes kanban set-model` or the browser dashboard's
flat provider:model select.
Adds a Model row to New Task and to the task drawer, rendering the composer's
own picker via the SDK — same search, same provider groups, same submenu — so
the board and the chat bar cannot drift. Unset reads "Profile default" and
changes nothing; a pin reads "provider: model · High" with an inline clear.
Presets are read-only here: picking a model seeds the depth from what you last
used for it, but a per-task choice never rewrites what the composer opens at.
Fast mode is omitted rather than shown-and-ignored — it's a live-session
request parameter with no worker-spawn equivalent.
The New Task dialog opts out of DialogContent's clip: the dialog publishes
itself as the portal container for popovers opened inside it, so its
overflow-y-auto cropped the menu at the dialog's edge. The general fix is in
flight as #75600; this override is scoped to one dialog to avoid conflicting
with it and disappears when that lands.
The model menu — search, provider grouping, -fast family collapse, keyboard
selection, the per-row thinking/effort submenu — was welded to the chat
composer's session writes, so any other surface wanting a model picker had to
fork it and drift.
Splits rendering from meaning. ModelCatalogMenu owns the catalog and the
navigation; a ModelMenuController decides what a selection DOES. The composer
is now one controller over it, keeping its session scoping, sticky manual
pick, preset restore, MoA presets, and rollback-on-failed-write intact.
ModelEditSubmenu becomes pure: it reports edits instead of performing them.
It previously called setCurrentReasoningEffort and config.set inline, so any
non-composer host would have silently retargeted the user's live chat when
they picked an effort. Its default effort is passed in rather than read from a
store, which is what lets it render outside a session at all.
Exported through the SDK so plugins consume the real component instead of a
copy. The composer's existing behaviour suite passes unchanged against it.
Carries the new column through create, PATCH, and bulk. Clearing is an
explicit clear_reasoning_effort flag rather than a null, because a null in a
PATCH body means "field not sent", not "set to NULL" — the same shape the
model override already uses, and the reason "none" can stay a real value.
Tests cover normalization, the depth-survives-a-model-clear invariant, both
spawn-argv branches, and the REST round-trip. One asserts the worker CLI
actually accepts the --reasoning flag the dispatcher emits: a spawn arg no
parser accepts would fail every dispatch while every unit test stayed green.
A task could already pin a model and provider, but not how hard the worker
thinks: reasoning effort came from the assigned profile's config and nothing
per-task could reach it. Pairing a small model with high effort, or a big one
with thinking off, meant editing the worker profile itself.
Adds a tasks.reasoning_effort column (migrated, NULL = inherit the profile)
with set_reasoning_effort(), a create_task kwarg, and a --reasoning spawn flag.
Kept deliberately independent of model_override: a task may run the profile's
own model at a different depth, and clearing a model override no longer resets
the depth the operator chose. "none" is a value (thinking off), not a clear.
--reasoning is new on the CLI too — the level was only reachable through the
/reasoning slash command, so the dispatcher had no flag to pass. It overrides
agent.reasoning_effort for one run and is never persisted.
The micro-action strip owned this skin inline: a full-radius hairline pill
on the composer's own fill behind a blur, sized to `--composer-control-size`.
It's the right look for anything that floats over the composer, so lift it
into `composer-dock` next to the other shared composer surfaces and have the
strip compose it with its own width cap and disabled state.
Follow-ups from sweeper review of #43763:
- tests/docker/test_tini_compat_shim.py asserts the dispatcher
ENTRYPOINT (with /init delegation check) instead of a bare /init
- tests/docker/test_smoke.py gains a docker run --init regression
for the non-PID-1 fallback (#38349)
- website/docs/user-guide/docker.md and the s6 supervision skill
document the dispatcher and its wrapped-runtime fallback
Replace the bare /init ENTRYPOINT with entrypoint-dispatch.sh: exec
/init + main-wrapper when the image owns PID 1, fall back to a direct
stage2 bootstrap (with the s6 helper PATH restored) on wrapped runtimes
where s6-overlay-suexec would abort with 'can only run as pid 1'
(Fly Machines, docker run --init, podman/FreeBSD setups).
Cherry-picked from PR #43763 by @konsisumer, conflicts with current
main resolved (tests/test_dockerfile_tini_compat_shim.py was moved to
tests/docker/, container_boot argv tests were reshaped upstream).
Fixes#38349
A local pin/unpin fires reconcile synchronously via the
$pinnedSessionIds listener — before any PATCH exists — so
pullRemotePins() read the still-stale server row and immediately
undid the user's action: !row.pinned && heldLocally reverted a fresh
pin, and row.pinned && !heldLocally re-pinned a fresh unpin, after
which the push pass saw nothing to write and no PATCH ever fired.
Fence local intent ahead of the pull:
- Run the push pass first, so pending/unconfirmed record the local
intent before the page is read, then pull.
- Skip the pull for ids still in `pending` (row not yet resolved) —
local intent awaiting its PATCH is newer than any loaded page.
- Update mirrored bookkeeping before mutating the pin store inside
the pull, so the re-entrant reconcile doesn't echo adopted state
back as redundant PATCHes.
Regression tests cover both directions (fresh pin over a stale
pinned=false row, fresh unpin over a stale pinned=true row) plus the
deferred-pin case where the row loads stale after the toggle. All
three fail against the previous reconcile order.
Fixes#74570
Extends the shared _GATEWAY_LIFECYCLE_PATTERN (used by BOTH the cron
creation-time guard in cron/lifecycle_guard.py and the terminal
execution-time hard-block in tools/terminal_tool.py) so Branch B covers
launchctl submit and bootstrap alongside kickstart/unload/load/stop/
restart, and normalizes POSIX shell line continuations before matching
so the exact multi-line reported shape in #62891 cannot slip past.
Also extends the execution-aware, label-independent detector
(contains_launchctl_submit_command, cherry-picked from #63272) to cover
launchctl bootstrap, since a neutral label like ai.hermes.svc-reload-tmp
defeats any label-anchored regex — the second production reproduction.
Regression tests cover both sites, including
`launchctl submit -l com.foo -- /path/gateway` and the bootstrap
variant, plus outside-gateway pass-through.
Branch B regex extension and continuation normalization drawn from
PR #62896; bootstrap coverage and test shapes drawn from PR #51003.
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
- Resolve guard cwd against get_session_cwd(session_key); fall back to env.cwd
when no session record exists yet, matching current main's per-session cwd
architecture.
- Make referenced-script reads backend-aware: local read first; if missing,
fall back to env.execute('cat ...') for SSH/Modal/Daytona backends.
- Reuse the recursive scanner in check_gateway_lifecycle so nested cron
wrapper scripts are caught, and resolve relative refs inside a script
against that script's directory.
- Add regression tests for remote-backend reads, two-session cwd, and nested
cron wrappers.
Verification: 80 passed tests/hermes_cli/test_gateway_restart_loop.py;
694 passed tests/cron; ruff + git diff --check clean.
A successful finalize edit can carry only the last streamed preview
snapshot: deltas generated between the last preview edit and stream
completion never reach any Bot API call, yet final_response_sent /
final_content_delivered were set from the call's success and suppressed
the gateway's normal final send — losing the tail permanently.
The stream consumer now records the exact cleaned payload of every
turn-final delivery (delivered_final_matches tri-state), and gateway/run.py
reconciles that record against the completed final_response before
trusting either suppression flag. On a demonstrable mismatch it edits the
streamed message up to the complete response, falling back to the normal
final send if the edit fails. Multi-message split deliveries and legacy
paths without a record keep the existing flag-trusting behavior, so
overflow splits and the failed-finalize handling (#51828/#33793) are
untouched.
Fixes#71643
CI's plugin-test slice runs without the discord optional extra; the raw
import failed with ModuleNotFoundError while every other test in the
file uses injected mock modules.
Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).
Fix (per-adapter-instance gate reads, whole class):
- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
read: under an installed profile secret scope with multiplex active, a
missing key returns the default instead of falling through to os.environ
(which may hold another profile's value). Single-profile behavior is
byte-identical to os.getenv.
- Discord adapter:
- connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
profile's runtime scope into a per-adapter dict; new accessors
(_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
_get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
_gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
-> scope-aware env, replacing every raw os.getenv gate read: on_message
channel gates, _is_allowed_user allow-all flags, slash authorization,
fail-closed diagnostics, missed-message backfill, bot-message gating,
and _component_check_auth (component buttons).
- _apply_yaml_config always seeds gate values into PlatformConfig.extra
(incl. new allowed_roles / allow_all_users keys) and SKIPS the
process-global env writes when loading a profile-scoped config under
multiplex; the legacy first-writer env bridge is preserved verbatim for
single-profile deployments.
- _resolve_allowed_usernames no longer unconditionally rewrites
os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
callback-auth fallbacks, _telegram_auth_env_configured, and the
allowed/ignored chats-topics-threads getters now read via the scoped gate
reader; _apply_yaml_config skips authorization env writes for
profile-scoped loads and seeds free_response_chats/ignored_threads extras.
Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.
Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).
Fixes#72348
Builds on #72787's current_root guard (cherry-picked with authorship
preserved). Two further hardenings for #72776:
- require the settled cwd and the workspace to share the same common .git
dir (the shape 'git worktree add' produces), so a git workspace visiting
an UNRELATED repo is a browsing visit, not a re-home (repro'd by
Johnny-xuan in the issue thread);
- never reconcile over an explicitly chosen workspace (explicit_cwd),
while a settle-adopted cwd stays followable via a cwd_from_settle
marker cleared by _set_session_cwd / project switch.
Fixes#72776
`_reconcile_session_cwd_from_terminal()` treats a settled terminal cwd in a
different git working tree than the session's workspace as a relocation. But
when the session's workspace is not itself in a git repo, `_git_repo_root_for_cwd(current)`
is None, so the `landed == _git_repo_root_for_cwd(current)` guard never matches
and the FIRST git directory a tool call steps into hijacks the session: its
cwd/git_repo_root flip to that repo, later tools run from the wrong place, and
the repo's AGENTS.md gets injected into an unrelated conversation.
Only reconcile when both the current and settled cwds resolve to valid, differing
git roots. A non-git workspace visiting a git repo to read a file or run a command
is a browsing visit, not a re-home — matching the docstring's own intent that
`cd`-ing away must not re-home the chat. The intended "follow into a worktree"
behavior is unaffected: that path starts from a git checkout, so current_root is
valid there.
Adds a regression test covering a non-git workspace that touches a git repo.
Follow-up to the a11d0bdb01 dedupe of test_doctor.py. Two gaps in the
surviving copies:
- Nine tests stubbed get_nous_auth_status, but run_doctor calls
get_nous_auth_status_local (hermes_cli/doctor.py:1373-1380) — the
stubs weren't stubbing the called function. Point them at the local
variant, keeping the gemini OAuth stub where the newer copies had it.
- The catalog-alias parametrize lost its nvidia and moa cases in the
dedupe; restore them alongside ai-gateway.
54/54 pass (test_doctor.py + the shadow guard).
handOffWindowsBootstrapRecovery() writes the update marker
unconditionally, same as applyUpdates() before the previous commit.
It's reachable during boot whenever resolveHermesBackend reports
bootstrap-needed, which a relaunch mid-update can plausibly trigger
on Windows -- clobbering a live updater's marker through this second
path. Apply the same updateHandoffConflict() guard here: refuse to
spawn a second updater when one is already alive, and quit instead
so the live updater can finish and restart us.
writeUpdateMarker unconditionally overwrites HERMES_HOME/.hermes-update-in-progress
before every hand-off. If the user retries "Update" while a prior updater is
still alive and parked (e.g. waiting for the desktop to exit), the retry's
pre-write clobbers the still-running updater's claim, so the older updater is
no longer recorded as the owner even though it's actively mutating the
checkout. A second updater can then run concurrently over the same tree.
Add updateHandoffConflict() to check for a live foreign marker owner before
spawning a new updater, and refuse the hand-off (surfacing an "update already
running" message) instead of overwriting the marker.
Ref: #75778
Two amplifiers behind the 779K api.osv.dev DNS queries/16h report:
1. tools/osv_check.py: check_package_for_malware() hit OSV on EVERY
call. MCP reconnect ladders, stdio recycles, and parked-server
self-probes re-run the preflight for the same package on every spawn
attempt, so a flapping server became a sustained OSV query/DNS
stream. Verdicts (clean or blocked) are now cached for 1h
(OSV_CHECK_CACHE_TTL to tune); network failures stay uncached so
fail-open never masks a real advisory once connectivity returns.
2. hermes_cli/security_audit.py: cmd_security_audit() ran full
component discovery twice per audit (_count_components + run_audit).
Discovery now runs once via _discover_components() and run_audit()
accepts the pre-discovered list.
Both regression tests fail against the previous code (verified via
sabotage run).
The parallel tool-batch planner treated search_files as unconditionally
parallel-safe (_PARALLEL_SAFE_TOOLS) with no path reservation, so a
batch of patch(path=X) + search_files(path=dir(X)) landed in one
concurrent segment and the search could observe pre-mutation file
content — a same-block write->read stale-read race.
Fix the class, not the site: path-scoped reservations now carry a
reader/writer role.
- search_files joins _PATH_SCOPED_TOOLS as a READER, reserving its
search root (default '.', matching the tool's default) instead of
bypassing path checks entirely.
- Overlap only conflicts when a WRITER is on either side: a write into
a searched/read subtree splits segments (ordered behind the write),
while reader<->reader overlap — previously split needlessly — now
stays parallel (concurrent reads commute).
- write_file/patch keep their existing writer barrier semantics.
Prior art surveyed for this design: Codex CLI's RwLock read/write
barrier (readers share, writers exclusive), Claude Code's
isConcurrencySafe partitioning, and gemini-cli's contiguous
parallelizable batching — all converge on reader-shared/writer-
exclusive with contiguous-order preservation, which this planner
already had for read_file/write_file/patch; this closes the
search_files gap and adds the missing reader/reader concession.
Verified by sabotage run (tests fail against the old planner) and an
E2E script exercising the real planner + real file I/O.
Live staging (2026-08-01): relay semantic thread rename still declined
silently despite both #74482 and #75581 deployed — thread kept its
initial-words name, session title generated fine. Root cause is the
no-clobber guard string mismatch (see paired gateway-gateway PR): the
gateway can't reproduce the thread's initial name byte-for-byte, so the
connector's only_if_current_name check always failed.
- relay rename lane now passes prefer_connector_created=True instead of
the fragile initial-name string; the connector resolves the guard from
its own created-name memory. Native-marker lane keeps the legacy
only_if_current_name string (source carries the real initial name).
- rename_thread: prefer_connector_created param -> only_if_connector_created
on the wire, precedence over the legacy string.
- INFO logs at rename dispatch (thread/lane/new_title) and result
(applied=bool): the whole failure hunt needed telemetry the gateway
never emitted — this makes the outcome visible in fly logs.
Tests: connector-guard wire shape + precedence over legacy string; the
title-turn race test updated to assert the connector-owned guard. Relay
suite 149 passed; ruff + footguns clean.
Converged Phase 2 finding (two reviewers independently): _discard_staged
only ran when phase-1 staging failed. A phase-2 (commit) failure rolled the
live tree back correctly but orphaned staging copies for every not-yet-
swapped entry — up to most of a full tree. The retry's up-front free-space
check runs BEFORE the lazy per-entry leftover cleanup, so the litter makes
the retry fail 'not enough free disk space' on exactly the space-constrained
machines the 1.2x threshold was chosen for: the same 'retry fails harder'
failure mode _discard_staged's docstring says it exists to prevent.
Two tests: a behavioral one pinning rollback+discard leaves the old tree
intact with zero litter, and an AST wiring contract on _update_via_zip so a
refactor can't silently drop the cleanup. Mutation-verified: removing the
try/except around _commit_staged_replacements fails the wiring test.
Phase 2 review findings: (1) _commit_staged_replacements' docstring cited
os.replace while the code uses os.rename — the atomicity claim holds (same-
filesystem rename is atomic on POSIX and NTFS) but named the wrong function.
(2) venv_bin_dir's remaining hand-rolled site list missed agent/lsp/servers.py:270.
Phase 2 review HIGH (empirically reproduced): a hard kill between
os.rename(dst, backup) and os.rename(staging, dst) leaves dst missing and
the backup as the ONLY copy of that entry. On retry, _stage_replacement
deleted that backup as a 'leftover' BEFORE staging the fresh copy — so a
staging failure (disk exhaustion is likeliest exactly after writing a full
staging copy) left a hole in the install with nothing to roll back to.
Restore the backup to dst first when dst is missing; it's a same-filesystem
rename. Mutation-verified: removing the restore makes the new test fail.
CI slice 8/8 red:
test_verify_core_dependencies.py::test_uses_virtual_env_from_environment
AssertionError: assert None == PosixPath('.../newvenv/Scripts/python.exe')
The Phase 2 reviewer flagged this exact risk (W4) and I under-weighted it as
"latent, not broken". It was neither — it was already failing.
The suite exercises Windows-only paths on Linux CI by patching predicates
(`hermes_cli.main._is_windows`, `is_windows`, `platform.system`). Routing
those call sites through a helper that reads `sys.platform` unconditionally
meant the patches no longer reached the path derivation: the test built
`Scripts/python.exe` while the code looked for `bin/python`.
venv_bin_dir/venv_python_path now take an optional `windows=` verdict,
defaulting to the host. Every converted site passes its own predicate, so
the patched-predicate coverage is restored — the dedup keeps the layout in
one place without hijacking the platform decision.
Verified by causation: dropping `windows=` reproduces the CI failure exactly;
restoring it goes green. Added two regression tests, including one asserting
a patched `_is_windows` still reaches the derivation.
Phase 2 review findings on the first commit.
C1 (critical) — the two-phase replace covered directories only, so the 20
first-party modules at the repo root (run_agent.py, cli.py,
hermes_constants.py, model_tools.py, toolsets.py, ...) were still copied
one-at-a-time with shutil.copy2 straight onto live paths. A failure in that
loop left all directories new and the root modules stale: precisely the
ImportError shape this PR exists to prevent. Worse, copy2 truncates in place,
so a crash mid-copy could leave a half-written cli.py — strictly worse than
stale on the flaky-AV path this code runs on.
Stage files the same way as directories and swap them in the same commit
phase. The docstring's "wholly new or wholly old" is now actually true.
C2 (critical) — a phase-1 failure (disk exhaustion being the likely one)
orphaned one staging copy per entry already processed, up to a second copy
of the tree. The user then follows our "re-run hermes update" advice with
LESS free space and the retry fails harder. Added _discard_staged() on the
staging path. Verified: staging failure now leaves zero litter.
W1 — _stage_replacement duplicated _atomic_replace_dir's first half verbatim.
_atomic_replace_dir is now a 1-line shim over the two-phase helpers; its
#49145 regression test still passes.
W2 — the failure message still said "some directories were replaced and
others were not", which the fix makes false. Now says the install was left
in place.
W3 — the free-space gate demanded 2x the tree when only the staging copy is
new (the live tree already occupies its space; swaps are renames). Relaxed
to need * 1.2, so we stop blocking updates that would have succeeded on the
space-constrained machines most likely to hit this.
W5/W6 — the lint-style guard used `"if" in line`, which matches "modify" and
"verify" and still missed os.path.join(venv, "Scripts"). Rewritten as an AST
check; it immediately found the real offender the substring version missed
(stdio.py, now explicitly exempted — it lists literal Windows-only PATH
candidates, not a cross-platform derivation). Softened venv_bin_dir's
"single source of truth" claim, since sites outside hermes_cli/ remain.
S1 — the rollback loop now logs instead of silently swallowing OSError.
Both C1 and C2 fixes are mutation-verified: reverting either makes the new
tests fail.