Packaged windows claim `com.nousresearch.hermes` as their Wayland app id
(electron-builder bakes product-identity.cjs's `appId` into
extraMetadata.desktopName; Electron hands that string to the compositor
verbatim), while the entry was written as `hermes.desktop` with
`StartupWMClass=Hermes` — so GNOME matched neither StartupWMClass nor a
`<app_id>.desktop` file name and every launch fell back to the placeholder
icon, with the raw app id in the tooltip.
- write `<app_id>.desktop` with `StartupWMClass=<app_id>` (Name= stays "Hermes")
- retire a leftover `hermes.desktop` once the new entry is on disk, and only
when the file still names this app and launcher management is enabled;
foreign files at that path are left alone
- nix/desktop.nix derives the entry file name from the module instead of
hardcoding it
- tests: the installed entry carries the app id, legacy retirement, foreign-file
preservation, opt-out preservation, plus a node probe asserting
APP_ID == product-identity.cjs appId
Known consequence: an existing taskbar pin points at the old entry id and has to
be re-added once.
Let the shared status field builder accept an explicit owning home and have the
multiplexed TUI/Desktop session.status path pass its session profile_home.
Unscoped CLI/gateway callers keep the historical process-home fallback.
Add focused coverage for a secondary-profile session and for the launch-profile
fallback.
Fixes#124500.
Every non-zero gh exit collapsed to a generic "is gh installed and
authenticated?" — a lie whenever gh was fine and the real failure was
"no commits between main and feature", a missing upstream, or a refused
push (#87731). The user had to drop to a terminal to learn what gh
already printed.
- apps/desktop git-review-ops.ts runGh() now resolves {ok, stdout,
stderr} (execFile's err.stderr carries the exit's own stderr), and
reviewCreatePr() prefixes the surfaced message with gh's reason,
keeping the generic text only when gh reported nothing.
- hermes_cli web_git.py _gh() keeps (ok, stdout, stderr) through the
same collapse — _run already captured stderr; it was discarded at the
tuple boundary — and review_create_pr() surfaces a bounded stderr
tail (400 chars) plus the gh context.
Tests pin the contract with unique stderr markers and non-zero exits on
both wrappers; successful-path return contracts unchanged.
Electron half salvaged from PR #87751 (author preserved); CLI half added
per the issue's acceptance scope.
Fixes#87731
The #119975 display-name lookup called get_live_catalog_entry() inside
the per-plugin loop of _plugin_rows, paying a full catalog resolution
(load_catalog_live: fetch or cache read + parse of every in-tree catalog
yaml, no memoization) once per installed plugin. plugins.manage list went
from O(1) to O(installed plugins) catalog resolutions, and a dead
catalog host cost one request timeout per plugin — the exact per-candidate
cost resolved_removed_entries() exists to eliminate.
Hoist the resolution next to pins/versions: catalog_titles() builds
{catalog_name: title} in one resolution and _plugin_server_rows reads
from the pre-resolved map, mirroring catalog_pins/catalog_versions.
Regression test counts load_catalog_live calls across a 3-plugin
listing: 3 before, 1 after.
Bundled/sealed app updates never run hermes update's maintenance tail, so
their pre-update catalog snapshot stayed authoritative for the cache TTL.
Add a boot home step (once per installed revision, per profile) that drops
the active home's cache (#119340).
Salvages the intent of #119354 by JoaoMarcos44 (its _invalidate_update_cache
hook no longer exists on main — the update pipeline was rewritten around
source_completion/update_finish; the invalidation is re-landed at the new
post-update maintenance seam).
A plugin added to plugin-catalog/ stayed "not in the plugin catalog" for up
to 6h after hermes update: LIVE_CATALOG_TTL_SECONDS keeps the on-disk live
snapshot authoritative for 6h, the update pipeline never touched it, and
load_catalog_live() iterates only the live entries — so the stale pre-update
snapshot out-voted the newer in-tree catalog the update just installed
(#119340).
Drop HERMES_HOME/cache/plugin-catalog.json under the active home AND every
sibling profile's (the checkout is shared, mirroring the post-update state.db
guard's home + siblings sweep) in _run_post_update_maintenance. The next
fetch_live_catalog() re-fetches the published doc, or falls back to the
in-tree catalog while the network is down — both newer than what was deleted.
Fixes#119340 (fix A)
Passive checks (`hermes --version`, the banner, the Desktop/dashboard
update check) now make one channel-read attempt again. Offline usually
surfaces as DNS EAI_AGAIN or ENETUNREACH, which pm.network.is_transient
classes as transient, so wrapping every read in retry_network added 7 s
of backoff to the synchronous version line, stretched a hung CDN from
30 s to ~127 s, and logged a WARNING to stderr on every retry.
`release_channels.retrying_reads()` opts a block in; update_cmd wraps
the channel resolution of `hermes update` in it. Tests: keep the
Retry-After retry test (now scoped to the update path) and replace the
budget-exhaustion test with one pinning that passive reads and 404s make
exactly one attempt.
resolve() on both sides also collapses a venv interpreter onto the store
binary it links to, so a caller still running in its old venv stopped
re-executing into the store interpreter (two tests in
tests/pm/test_source_update_launch.py go red). The loop in #122513 is a
spelling difference, not a symlink: PM spells the store Python through a
HERMES_HOME that may contain '..', and the OS reports sys.executable
normalized. normcase(abspath()) matches those spellings and keeps a venv
interpreter distinct.
Tests: the '..' spelling is current (red on main); a venv python symlinked
to the store binary still relaunches (red with resolve()).
The detached watcher runs as `<python> -c <program>`, so `hermes_cli` resolved only
because update_completion happened to spawn it with cwd=<checkout>. The program now puts
the checkout on sys.path itself, and the bare-Python test runs it from an unrelated cwd
(red without the sys.path line).
Also drops the two unused re-export aliases in gateway.status (`_posix_is_zombie`,
`_pid_exists_win32_ctypes`): nothing imports them and neither is in the old-updater
compat surface.
After the package-manager handoff, hermes update finishes on the bare store
interpreter and spawns the detached restart watcher as sys.executable -c.
The watcher imported gateway.status (utils -> hermes_yaml -> ruamel) and
hermes_cli.config, died with ModuleNotFoundError before relaunching, and
left every manually started gateway down after the update (gated on #124649).
Move the stdlib liveness probe (zombie-aware POSIX kill(0), Windows
OpenProcess) into hermes_cli._subprocess_compat, have gateway.status's
fallback delegate to it, and import only stdlib-backed modules in the
watcher.
Fixes#124649.
The #107002 guard keeps an inline ``-c`` program's trailing argv as data. Every
Hermes launcher runs the entry point IN the ``-c`` process, so the guard hid
real gateways on every OS (#124318, #124588):
- the store launcher / Windows updater relaunch (_launchers.runtime_command)
- the published launcher script (POSIX shell launcher: every PM-install
systemd/launchd gateway) and its Windows .cmd base64 wrapper
- the venv_sync re-entry, whose argv is assigned inside the source
gateway.status.inline_bootstrap_argv recognises exactly those emitted source
shapes, anchored at both ends so a program merely CARRYING one (the restart
watcher's respawn argv) still never matches, and rewrites the process to the
equivalent ``python -m <entry> <argv>``. /proc, psutil and ``ps`` space-join
argv, which splits the source across tokens; the shortest token run ending
in a recognised tail is the source whichever reader joined it. Both
canonical matchers (looks_like_gateway_command_line and
update_cmd_windows._hermes_holder_subcommand) use it.
Live on a real PM install (Linux, bwrap): a gateway started through the
installed launcher script or runtime_command read "Gateway is not running"
on main; with this change both read running, find_gateway_pids and
get_running_pid see them. Drops the four #124318 known_failure gates in
tests/e2e/core/windows_update.
Co-authored-by: Hermes Agent <dmyou@users.noreply.github.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Co-authored-by: DianaBudin <dianabudin0307@gmail.com>
Rework of the #124676 salvage onto the canonical fleet scope instead of a
second path heuristic:
- the pause, the cold-start guard and the post-relaunch readiness poll all
filter the host-wide find_gateway_pids(all_profiles=True) scan through
update_cmd_fleet._scoped_manual_gateway_pids, the same home scope the
POSIX fleet restart uses (#93349). A PM install's venv lives under
installs/, so exe-under-PROJECT_ROOT rarely proved an own gateway; its
live HERMES_HOME (or the LOCALAPPDATA default) always does.
- a foreign gateway with no HERMES_HOME resolves to its own default home and
no longer blocks this install's cold start (#124659, second bullet).
- a gateway whose home cannot be read is named in the pause output instead
of skipped silently; the spawn ledger's verified (pid, create_time) entry
proves an own gateway whose environment is unreadable.
- tests trimmed to two invariants on real processes; a real-Windows journey
(two installs, one updates while the other serves) replaces the harness
comment that documented the bug.
A git-less Windows ZIP install has no .git and never runs git, yet
_prepare_git_command called expose_pm_git() before it checked
use_zip_update: offline or with no pin, pm.ensure raised and aborted an
update that never needed git. expose_pm_git now takes the project root
and does nothing unless it is a git checkout (both callers).
When install.ps1 runs as one process, the git it staged into PM's store
is on the inherited PATH, so expose_pm_git returned early and PM's facts
never recorded it; plain hermes processes (plugin git installs, doctor,
version info) had no git until the first `hermes update`. A git found
under PM's store is now treated as that unrecorded copy and ensured, so
the source completion writes the git fact at install time.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
expose_pm_git() probed with a bare shutil.which, which the managed-runtime
resolution guard rejects outside hermes_platform/. Route the lookup through
hermes_platform.resolver.locate_command instead.
On a Windows machine with no git, scripts/install.ps1 stages the pinned Git
for Windows into the pm store for its own process only, and pm's facts
never record it. Every later product process that ran a bare `git` failed:
- `hermes update` died before its first step:
"✗ Update failed: [WinError 2] The system cannot find the file specified"
- the source completion that finishes an installer re-run or an update
found no commit, deleted the install stamp, and the next boot wrote an
adoption stamp that names no commit. It also printed
"Could not refresh release history ([WinError 2] ...)".
expose_pm_git(): when Windows resolves no git, ensure PM's git explicitly
(both callers are user-initiated, like ensure_tools_for_sync) and put its
composed PATH (cmd and usr\bin) on the process, so children inherit it.
The update calls it before its first git, and the source completion calls it
before its builds and stamp. A machine with a working git is untouched.
main_desktop already ensures PM's git for its build for the same reason.
When the update replaced a user's untracked file, _apply_stash returned
False after the tracked changes and the other untracked files were
already in the tree, so _restore_stashed_changes skipped the syntax,
critical-import and reject path: a restore that broke Hermes finished
the update instead of resetting the tree and exiting 1. _apply_stash now
returns the replaced paths; the restore validates the tree as before and
only skips the stash drop, recording it as parked.
A refused path counts as replaced only when HEAD tracks it
(git cat-file -e HEAD:<path>), so a file that was undeletable at stash
time and changed since (#70127) is no longer reported as the update's.
Both update shapes move HEAD off a detached commit: the branch update checks
out main, the release update checks out the release commit. A commit made on
the detached HEAD is on no branch, so after the move only the expiring reflog
reached it, and the branch path printed nothing about it (gated on #124643).
One helper, _park_detached_head, now runs at both sites before HEAD moves.
When HEAD is detached at a commit no ref contains (refs/stash excluded: the
autostash is dropped after the update, which is why the branch path parks
before stashing), it writes refs/hermes-update-backups/detached-<branch>-<ts>-<sha>
(the divergence rescue refs' scheme, now pruned on the same terms) and prints
the ref and how to list the commits. If the write fails the update stops
rather than orphan the work. A detached HEAD already on a branch or tag (a
pinned release) gets no ref. It replaces the release path's silent, never
pruned refs/hermes/pre-release/<sha>.
DELETE /api/sessions/{id} removed the DB row but never passed the
profile's sessions dir to SessionDB.delete_session, so the on-disk
transcript artifacts survived the UI delete: legacy session_<id>.json
snapshots (which can carry plaintext secrets) and the gateway's
request_dump_<id>_*.json dumps. The CLI delete path threaded the
directory all along; the endpoint was the outlier.
Also sweep the legacy session_<id>.json snapshot name in
SessionDB._remove_session_files so deletes and prunes clear it from
installs whose older builds wrote it.
Fixes#60207
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Review follow-ups on the Windows skip:
- Most people who hit the boot loop launched Desktop from the Start menu and
never open a terminal. The in-app update also rebuilds the app: the
Windows shim waits for Desktop to exit, and the already-up-to-date path
still completes with desktop=True. The notice and updating.md now name
Update now in Settings -> About next to `hermes desktop`.
- cmd_gui took the skip's None as "no launchable app was found". A build
that fails raises instead of returning None, so None there only means the
skip, and `hermes desktop` now reopens the app that was kept.
Tests fold to the two invariants, each red when its half of the fix is
reverted: the stop spares its ancestor Desktop on every platform, and a
Windows packaged build under its own Desktop is skipped (now driven through
the real _desktop_ancestor_in with a fake process tree, instead of a stub).
An update interrupted after its dependency sync leaves source-completion-
pending, and the next Desktop launch finishes it from the Desktop's own
backend (venv_sync._finish_source_update). On Windows the tail's desktop
build reached _stop_desktop_processes_locking_build, which spared ancestor
Desktops only on POSIX, so it terminated the Desktop it was running under.
The pipes every child logs through went with it, the whole tree died before
the tail could clear its markers, and every launch repeated it (#123499).
Stopping an ancestor can never free the exe lock for the process doing the
rename: that process dies first. The ancestor spare now applies on every
platform, and build_prepared_desktop skips a Windows packaged build running
under its own Desktop with a notice, since its promotion could not succeed
while that Desktop holds the lock. The tail finishes and clears its markers;
the app's content stamp stays stale, so `hermes desktop` run outside the
app rebuilds it.
Closes#123499.
A `terminal(background=true, heartbeat=N)` tick queued a notification every N seconds
whether or not the process had printed anything, and every queued event costs the owning
session a full model turn. On Desktop and the TUI that turn painted the wake as a user
bubble ("[Background process ... heartbeat #9 ... (no new output since the last
heartbeat)]") followed by the model's "Still running normally." — over and over, for a
process whose row on the status stack already said it was running — and while the wake
held the session's turn, the user's own prompt sat queued behind it.
- `ProcessRegistry._emit_heartbeat` skips a tick with no new output. The sequence counts
delivered beats only; the "(no new output)" placeholder in the formatter is gone.
- TUI/Desktop type heartbeat rows `display_kind: hidden` (the kind both clients and the
transcript preview already honour); the CLI paints a one-line receipt and persists the
row hidden, so reopening the session in Desktop shows only the agent's reply.
- Desktop hydration drops heartbeat rows persisted by older backends the same way.
- `display.background_process_notifications: off` is honored by the TUI/Desktop poller and
the CLI drain, not just the messaging gateway. `off` mutes process-driven wakes only:
a finished `delegate_task(background=true)` still lands.
Supersedes #123123 (cherry-picked; scoped so `off` keeps subagent results) and #119202
(cherry-picked; `heartbeat: 0` is schema-valid so models that materialize every field
stop tripping the foreground guard).
The documented `off` mode gated only the gateway's completion injection
(#9290); the CLI drain never consulted the key, so setting it silently did
nothing on the CLI — leaving no escape hatch against the post-Ctrl+C
notification turn restarts (#123114).
Mirror the gateway semantics: the drain still claims and acknowledges
completion events (durable rows converge instead of replaying on restart),
but the turn-starting `_pending_input` injection is suppressed.
Fixes#123114
(cherry picked from commit d009992c9e64b6083951b27a8261d138ae669ccb)
YAML parses a bare `0` as int, which hit neither the bool nor the str
branch of _desktop_launch_options, so the unquoted off-switch silently
kept the tree on. Accept numeric scalars explicitly (bool checked first —
it is an int subclass), and add `disabled`/`enabled` to all three word
lists (launcher _A11Y_OFF_WORDS, readDesktopLaunchConfig, RENDERER_
ACCESSIBILITY_OFF_WORDS) so both launch paths accept the same
vocabulary.
Chromium only builds the renderer's accessibility tree when it detects
an assistive technology — and dictation tools that insert text through
the OS accessibility APIs (Wispr Flow etc.) do not register the way a
screen reader does, so the tree never appeared: the window exposed only
its chrome and the contenteditable composer was unreachable. No
setAccessibilitySupportEnabled call existed anywhere in the electron
tree, on any platform.
- apps/desktop/electron/renderer-accessibility.ts (new): the
platform/env decision in a dependency-free module (app object
injected) — ON by default on darwin/win32 where the typed API exists,
opt-out via HERMES_DESKTOP_RENDERER_ACCESSIBILITY=0. Linux is untouched
(ATK flow needs no manual enable; the API is not typed there).
- main.ts calls it right after app ready (the API's requirement).
- hermes desktop launcher: `desktop.renderer_accessibility: false` in
config.yaml bridges to the env var — only the opt-out is bridged, so
the default never depends on the launcher path. Same shape as
desktop.disable_gpu. config_defaults documents the key.
Family note: the same seam on Windows is #92607 (Wispr Flow there);
this one enablement should resolve both platforms, but the Windows leg
is unverified on this machine, so #92607 stays open. The dictation
keybind half of #118271 already landed separately in 3cc24b0130
(composer.dictate action; rebindable-key ask tracked by #112118/#71658,
left alone).
Tests: 4 electron-side unit tests (default-on per platform, Linux skip,
opt-out words + explicit env precedence, injected app switch flips
exactly when enabled); Python side: 12-case normalization parametrize +
env-bridging precedence test (ON sets nothing, opt-out bridges, explicit
env wins) in test_gui_command.py; _desktop_launch_options signature
consumers updated. All green; the remaining lucide-react TS2307 in
onboarding setup.tsx is pre-existing on base (missing dep of that
workspace file, untouched here).
Fixes#118271
_install_startup_entry swallows the .cmd unlink failure, so a locked legacy
launcher left both entries firing at logon while reconcile reported a
migration and doctor --fix counted it fixed (#80569).
hermes update now runs the autostart reconcile when it refreshes the
launcher scripts, and hermes doctor reports a Startup entry that
duplicates the Scheduled Task (removing it under --fix), so installs
made before the install-time fix converge too.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
A successful Scheduled Task install returned without removing an
existing Startup-folder Hermes_Gateway.vbs or legacy .cmd, and the
fallback path wrote a Startup entry even while a task was still
registered. Both fire at logon, so the gateway launched twice.
install() now removes Startup entries after the task registers, the
fallback is skipped while a task exists, and reconcile_autostart_launchers()
converges an existing install to one mechanism.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
On macOS `hermes desktop` launched the release/ bundle even when an
installed Hermes.app existed, so the app ran from two paths (two Dock
icons, TCC grants keyed to a different bundle) while the one Finder opens
stayed stale. Refresh the installed copies first and launch the one that
now matches the checkout build; release/ stays the fallback.
`hermes update` only rebuilt Desktop when apps/desktop/release/ or dist/
existed, so an installed Hermes.app that only the checkout update refreshes
(a bootstrap build) never got newer once release/ was gone or never built.
Count such bundles as a Desktop to keep current. Ownership is read from the
bundle's install-stamp.json: `updateMechanism: self` (or a stamp older than
the field). Self-updating releases and commit builds are never rebuilt or
copied over, and only the checkout an installed app actually runs (the one
under the default Hermes home) claims it. The post-build install now uses
the same set.
The desktop ticker's per-tick profile_gate only arms in the multiplex path;
the fail-open paths (profile enumeration failure, empty served set, external
provider) start an ungated single-store ticker that races a live gateway's
tick-lock on the same HERMES_HOME. When the desktop wins, delivery has no
live platform adapter and the cold send hangs until script_timeout (600s).
Bail out before resolving the provider when a live gateway is running on this
backend's HERMES_HOME; on probe failure fall through to the existing per-tick
gating instead of standing down blindly.
Fixes#52202
A headless `hermes serve` emitted only HERMES_BACKEND_READY. A packaged
Desktop artifact whose readiness parser predates the neutral token
(the CLI-only `hermes update` path never rebuilds the packaged app)
matched nothing, then killed the healthy backend after the
port-announcement timeout — the artifact-skew signature of #60772.
Announce the neutral token first and the legacy HERMES_DASHBOARD_READY
one after it; current parsers match either, and stale packaged parsers
match the legacy line. The legacy `dashboard` backend keeps its token.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Gateway /resume rewrites sessions.source to the resuming platform, which is
correct routing behavior (source feeds find_latest_gateway_session_for_peer,
Telegram listings, and the TUI ghost pruner) but destroys creation
provenance. Add a separate immutable created_source column stamped once at
session creation and preserved by all upsert paths; surface it in
hermes sessions list as '<created>→<current>' when the two diverge.
Fixes#56439
The uninstaller only removed the current launchd label's plist, so an
older io.nousresearch.hermes-agent.gateway* agent kept respawning the
gateway after the code tree was gone. Boot out (gui and user domains) and
unload every plist matching either label pattern before unlinking.
Also remove per-user leftovers earlier uninstalls left behind: macOS
Library Caches/Logs/WebKit/HTTPStorages/Saved Application State entries
named *hermes* and the XDG cache dir; the XDG data dir
(~/.local/share/hermes) is removed only on a full uninstall and is
explicitly reported as preserved otherwise. ~/.hermes stays intentional
in keep-data mode and is already reported.
Fixes#62209
Windows has no POSIX parent-death supervisor/killpg safety net, so an
ungraceful exit of the hermes process left every stdio MCP child tree
(npx.cmd -> node.exe) running as orphans with ParentId=null, piling up
across session restarts.
- _run_stdio now attaches the process to a KILL_ON_JOB_CLOSE job object
before spawning stdio children (self-guarded no-op off Windows), so the
whole child tree dies with the parent at the kernel level.
- Windows reaps kill the process tree (direct child + descendants) in the
lifecycle orphan sweep and the spawn-ledger startup sweep, where there
is no pgid to group-kill.
Fixes#61059
Electron booted from active-profile.json and ignored argv. hermes
desktop and hermes -p <name> desktop never appended --profile, so
the packaged app kept the stored profile.
Parse both --profile spellings before startHermes, persist that
name, and append the same flag on the packaged launch. A missing
flag does not change the stored profile.
Review of the run-history fallback:
- Merge agent sessions with script-only output docs per execution instead of
branching on 'any sessions exist': a job converted to no_agent keeps its id
(update_job supports it), so a surviving historical agent session must not
hide newer script-only fires. Same-execution rows dedupe by session span.
- Decode output-doc filenames and read the execution ledger inside the owner
profile's home scope: hermes_time resolves the configured zone through the
current HERMES_HOME, so a cross-profile request decoded with the
dashboard's zone and read the dashboard's executions.db.
- Attach per-run status from the execution ledger (one row per fire) matched
by claim window instead of last_run_at proximity, and surface terminal
attempts with no surviving doc as their own rows — the latest failed run
no longer disappears behind (or relabels) an older successful document.
- Read output docs with encoding='utf-8-sig' (Windows footgun: PowerShell and
some editors BOM files; plain utf-8 breaks on BOM-prefixed docs).
Three regressions cover the converted-job merge, the per-profile timezone
decode, and the missing-newest-doc case.
Script-only (no_agent) jobs deliberately skip SessionDB, so their run history
was permanently empty — the desktop showed "No runs" next to hundreds of
completed fires. When no session rows exist, the runs endpoint now falls back to
the job's output docs under cron/output/<job_id>/, one row per fire, with the
latest status prefixed; with no surviving docs but a recorded last_run_at, a
single metadata row is surfaced instead. Rows mirror /api/sessions shape with
source='cron_output' and a cron_output: id prefix that cannot collide with
SessionDB cron_{job_id}_* session ids.
Filename timestamps are read back with hermes_time.get_timezone() — the same
configured zone save_job_output writes them with — not a fixed-offset snapshot
of today's local offset (wrong by hours when HERMES_TIMEZONE differs from the
server zone, and by an hour across DST).
Fixes#42433 (run-history facet)
Salvaged from #61403 by @LeonSGP43 (fallback design, metadata-only row, tests),
reworked per its review: timezone handling uses the configured zone and the
tests pin started_at under a configured-zone mismatch.
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
HERMES_DESKTOP_IGNORE_EXISTING=1 only wrapped the PATH probe, so a usable
active install or system Python still started a local serve. Skipping those
rungs on every resolve would make the post-bootstrap re-resolve return
bootstrap-needed and start the installer again. The first pass now falls
through to connect/onboarding; the re-resolve keeps the runtime just installed.
Fixes#117682