Review (#141651): _msvc_identity hand-wrote the vswhere.exe install path,
which is the known-path table the hermes_platform rule keeps in one place.
Add visual_studio_installer_dirs() to resolver/known_dirs.py and locate
vswhere with locate_command (PATH first, then the installer directory).
Return None explicitly when vswhere reports no installations, before selecting
the newest instance.
Warn on stderr with the reason whenever MSVC identity detection returns None:
missing vswhere, no matching installations, or a failed probe/version read.
onboarding.run fell back to install_has_history(root): a session row or a named
profile in the active root. Two gaps: an empty session row (desktop resume,
onboarding.start) read as use, and pointing HERMES_HOME at an empty folder made
any machine fresh again.
hermes_cli/install_state.py answers for the machine instead. probe() walks the
roots the machine names (active and default roots, sibling and package roots,
roots named by gateway service definitions, the Windows registry and the
launcher on PATH), runs a table of read-only checks per root (onboarding done,
named profile, provider config/key/login, a message row), and keeps a marker at
a fixed per-user path that no Hermes setting moves. Unreadable or protected
roots count as used and never write the marker. Path literals live in
hermes_platform/resolver/known_dirs.py. service_definition_homes() is pulled out
of the temp-home check so both readers parse service files the same way.
should_run() now uses probe().fresh. tests/conftest.py confines the probe to the
test's directory so no test reads the developer's real roots or writes the
real marker.
onboarding.run gets its entry in user-guide/configuration.md (null on a
fresh install, true while onboarding is open, false once finished or
skipped; always the default profile's config).
The questionnaire the host summary, machine.facts and onboarding.run were
written for was dropped; their docstrings and contract docs now name the
first-run onboarding that reads them.
The salvaged host summary kept its own Spark detector: win32 + arm64 +
an NVIDIA GPU, or a dgx|spark|gb10 regex on the CPU name. That is one
of the readers the previous commit's products.nvidia_platform() replaces,
and the two disagree (the regex calls any CPU string with "spark" in it a
DGX Spark).
summary() now asks nvidia_platform() once and answers it as
nvidia_platform (rtx_spark | dgx_spark | discrete | null) instead of the
is_spark bool. machine_kind is "Spark" for the two Spark platforms.
machine.facts has no client yet, so the contract field is renamed, not
kept beside the new one.
machine.os_release came from platform.release(), which on macOS is the
Darwin kernel version (25.x), not the macOS version the questionnaire
shows as "macOS <release>". On darwin (by the os_family fact the summary
already reads) it now uses platform.mac_ver()[0], falling back to
platform.release() when that is empty; other systems are unchanged.
(cherry picked from commit 4e1849dc9c47a087bdff8fa6d79f860dc9d3d89a)
hermes_platform/host/summary.py answers what the first-run questionnaire
needs to know about this machine: the measured hardware facts, the
machine kind (Spark, Mac, PC, computer), whether it has an NVIDIA GPU,
whether it is a Spark, the OS locale and the account's full name.
The Spark predicate, the account and locale readers are copied from
agent/initiate_setup_facts.py, which PR4 deletes with the agentic setup.
Cached per process; OS records only, no environment input, no subprocess,
no network.
(cherry picked from commit ed545b59f60fa94de741e290c00975ebb4633f76)
Windows on arm64 with an NVIDIA GPU counted as rtx_spark even without
an N1X marker. It now needs the N1X CPU name or a GPU in the N1X PCI
range (the range the local catalog's measured row uses); any other
NVIDIA GPU there is discrete.
facts.nvidia_gpu_names() becomes facts.nvidia_gpus(), name plus packed
PCI id per GPU: Windows reads DEV_ from MatchingDeviceId, Linux reads
/sys/bus/pci/devices/<bus>/device for each driver procfs bus. The
fixture table uses the CPU name and display-class rows measured on an
N1X laptop.
The registry CPU name, the two NVIDIA display adapters and their PCI
device ids were read on an N1X laptop; nvidia_platform() returned
rtx_spark under native arm64 and emulated x64 Python.
Audit of hermes_platform/host/facts.py, verified on an NVIDIA N1X laptop
with native arm64 and emulated x64 Python 3.14:
- process_arch: on Windows platform.machine() names the native machine,
so an x64 Python on arm64 reported arm64. It now reads the
interpreter's own build (sysconfig.get_platform()).
- native_arch: the PROCESSOR_ARCHITECTURE env fallback is deleted;
facts take no environment input. IsWow64Process2, then
platform.machine().
- cpu_vendor: macOS reports Apple on Apple silicon and
machdep.cpu.vendor on Intel; arm64 Linux names the CPU implementer
(aarch64 /proc/cpuinfo has no vendor_id).
- gpu_class: Intel Macs read PCI display controllers from IOKit
(new darwin_pci.py) instead of returning unknown; Windows skips
virtual adapters (remote display) instead of counting them as
unreadable; WSL counts the Windows NVIDIA driver's bridged libcuda.
- nvidia_gpu_names: Windows reads DriverDesc of NVIDIA display adapters.
- _read_text reads utf-8-sig; the Linux-only os.getuid line carries the
footgun marker.
The setup facts kept their own Spark rule (win32 + arm64 + NVIDIA, a
dgx|spark|gb10 regex on the CPU model) beside is_nvidia_arm_soc(),
which existed only for that caller. Both now read nvidia_platform(), so
there is one answer, and the wrapper is deleted (no-compat ruling).
hermes_platform.host.products.nvidia_platform() returns rtx_spark,
dgx_spark, discrete or None from cached host facts only: no subprocess,
no driver library, no network. RTX Spark is Windows on arm64 with an
NVIDIA GPU or the N1X CPU marker; DGX Spark is Linux on arm64 whose
driver names a GB10 GPU (new facts.nvidia_gpu_names(), read from
/proc/driver/nvidia/gpus) or whose CPU/device-tree model names GB10 or
DGX Spark; discrete is any other NVIDIA host.
is_nvidia_arm_soc() now delegates to it, so the setup facts reader
agrees with the new answer; looks_like_nvidia_arm_soc and its cpu_vendor
check are removed (aarch64 /proc/cpuinfo has no vendor_id, and the
check missed a Windows N1X whose CPU string lacks the marker).
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.
- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
`Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
these quotes alone and only strips the two that are genuinely redundant, which this commit
strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
With no nvidia-smi answer and no discrete engine device, probe_budget budgeted
all of RAM as unified memory. Windows caps an integrated GPU at its GPU memory
(DXGI dedicated plus shared, Task Manager's figure): an Arc B390 on a 31.5 GB
machine gets 18 GB, yet Local Models showed 31.5 GB and priced the 27B as
fitting. The pool is now capped at that figure, read through DXGI for the
adapter the engine named, else the only non-NVIDIA adapter when a Vulkan or HIP
engine will run the model. The CPU engine keeps the RAM budget, whether the
user chose it or it is the only build for the machine (a Snapdragon). The
engine's own number would overshoot: ggml-vulkan sums every heap of an
integrated GPU, host-visible ones included. The DXGI reader is a cached host
fact in hermes_platform. The cap only lowers the pool; CPU-only machines,
other platforms and a failed DXGI read keep the RAM figure.
* feat(platform): app declarations list every place an app may be installed
An app block's location may now be one path or an ordered list. A list
item is a path, where a `*` segment stands for a versioned folder and the
highest version is tried first, or a mapping naming a location kind:
command (PATH), uninstall_registry (InstallLocation plus a file, Windows),
app_bundle (/Applications, ~/Applications), flatpak (app id) and snap.
The directories behind each kind live in known_dirs.
Each kind's OS, key, presence and locator are one LOCATION_KINDS entry in
resolver/app.py. The declaration parser validates against that table, so
a new kind is one entry instead of three coordinated edits in two modules.
With requires.min_version, availability reads the version of every
present copy and returns the first that qualifies, so an old copy found
first does not hide a newer one; if none qualifies it reports the highest
readable version. A Linux block may omit a version source (Linux has
none) and is then gated on presence only; Windows and macOS blocks still
need one, and a Linux-only declaration cannot set a minimum.
Brought over from #129246. The Blender 0.2.0 catalog pin stays out: older
clients reject a list location, so it follows a release that ships this.
* fix(platform): bad app declarations and vanished registry entries no longer break app lookup (review)
A location with a non-string kind raises DeclarationError instead of TypeError.
A location's file and key value are checked after stripping, so a leading / or \ or a drive cannot escape the install folder.
An uninstall registry entry that cannot be opened or read is skipped instead of ending the scan of its root.
The root file is project context and was 38.7k chars, past the context-file budget
(20k floor, 30,720 on a 128k window), so every session lost its middle: routing table,
testing rules, code-shape rules. It now keeps the two invariants, a condensed rubric
(every rule kept), environment, structure, one-line rules that apply everywhere and the
routing table (11.8k).
Moved verbatim to where they load: Testing -> new tests/AGENTS.md; dependency pinning and
PM ownership -> new pm/AGENTS.md; machine facts -> new hermes_platform/AGENTS.md;
session-scoped tool availability -> tools/AGENTS.md; the full rubric with its real-close
examples -> developer-guide/contributing.md. Pointers in lint, checker messages, docs
and test comments name the new homes.
When every DRM card's vendor file or every Windows display adapter's
MatchingDeviceId was unreadable, classify_gpu_vendors returned 'none' and
availability refused with unsupported_gpu, contradicting the documented
'unreadable GPU passes' rule. Windows now records an unreadable numbered
adapter as an empty id so the classifier sees it.
Also drops the type-narrowing assert in availability() and makes
_action's gpu_label required.
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
The install snapshot needs coarse hardware facts, and machine facts belong in
hermes_platform.host (root AGENTS.md). ram_total_bytes reads /proc/meminfo,
sysctl hw.memsize or GlobalMemoryStatusEx; gpu_class reads /proc/driver/nvidia
and DRM PCI vendor ids on Linux, the display-adapter registry class on Windows,
and native arm64 on macOS. Both are cached per process, take no env input and
never spawn a subprocess or load a driver library, so the daily snapshot can
call them on its path. A discrete vendor wins over an integrated one on hybrid
machines.
Post-write .js/.ts lint on the local backend runs PM's node/npx instead of
the terminal shell's PATH (user dirs first). pm.activate always puts the
store dirs in front, also on drift, activating only packages whose whole
chain is installed. tui_gateway puts the store dirs ahead of ~/.local/bin.
Bare npx/npm/node/uv/uvx MCP commands resolve to PM's copies; the system
fallback tables are gone and an absolute command: stays the user's.
The #37589 uv/uvx fallback spelled its directory table (~/.local/bin,
/opt/homebrew/bin, /usr/local/bin) inline in _launcher_fallback, which the
managed-runtime ratchet flags as an unreviewed known_path_table outside
hermes_platform/. known_dirs is the module whose contract is 'every table in
Hermes lives here', so add uv_tool_dirs() there and compose it at the call
site. Behavior is unchanged: same four directories probed in the same order
(managed <home>/bin first, then uv's install order), bare uv/uvx still
resolves under a GUI-style PATH that lacks them.
* feat: expose interactive and live endpoint facts
* feat: hydrate application-backed MCP liveness
* feat: export interactive session fact
* fix: classify unsupported declared hosts
* test(mcp): a runtime file without a token connects without an Authorization header
* fix(mcp): server_json liveness fields default to http/token/pid; an invalid declaration degrades to static instead of failing the server task
* fix(mcp): a connected server is offerable regardless of the interactive-session rule; the rule only shapes the not-connected sentence
* test(mcp): follow main's ToolSearchConfig fields and patch _bump_server_error at its origin module
* feat(platform): resolver core with locate/inspect/probe tiers and ordered candidates
Every resource lookup needs one result shape and one cost contract. `locate` reads
metadata only, `inspect` may open files and call OS APIs in-process, `probe` is fresh
and the only tier that may spawn or connect. `Resolution.candidates` keeps probe order
so fan-out consumers can try every present binary.
Linear NS-921.
* feat(platform): AppResolver over AppDef with plist, PE, registry, and server.json sources
Desktop apps need presence, version, and liveness as separate observations. The runtime
file's bearer token is parsed, used for one request, and discarded inside the probe;
no public type carries it. Endpoints are accepted only when loopback with a numeric port.
* refactor(copilot): gh candidates through locate_command and the Homebrew table
First consumer of the resolver. The gh token probe still tries every present binary in
order; the allowlist loses its two copilot_auth rows.
* feat(platform): availability() over an application declaration
locate() + inspect() only, never probes; the fail-closed _version in
app.py treats a vendor's plist/PE/registry entry as untrusted input.
Salvaged from PR #118122; reads any object with requires_app,
min_version, app_for(os) — nothing here imports the MCP catalog.
* feat(platform): application declarations parsed into AppDef per OS
The parser slice of PR #118122's catalog manifest, re-homed as a
catalog-free module: whoever owns an MCP server declares the app it
fronts per OS and what it needs, and registers it here. Stdlib +
hermes_platform.resolver only. register/lookup/clear are the one seam
the MCP check_fn and the skill gate both read.
* feat(mcp): check_fn honours a registered application declaration
_make_check_fn ANDs the declared app's availability into the
connection-alive check; with nothing registered for the server the
behaviour is the pre-PR3 connection check. Provenance is explicit
registration, not endpoint matching. Returns a plain bool: the registry
caches bool(fn()).
* feat(skills): requires_apps gate through registered declarations
Offer-time filter beside environments:; names resolve through
hermes_platform.declaration, an unknown name hides the skill (fail
closed). The disk snapshot carries requires_apps and the fast path
re-evaluates it (snapshot version bumped to 3): app presence is a host
fact that changes without SKILL.md changing.
* docs: application declarations page
The plugin-facing schema reference: app: and requires: blocks,
availability() states, and the two gates that read the registry.
Registered under Extending > Plugins in the docs sidebar.
* test(platform): declaration parser, availability, gates
The PR3 app-block tests re-homed off the catalog: fixtures are dicts
passed to parse_declaration, the check_fn gate keys on explicit
registration (not endpoint matching), and the import-hygiene probe now
covers hermes_platform.declaration and resolver.availability.
Host facts and runtime predicates need one stdlib-only source that stays correct under architecture emulation.
Hardware recognition reads host facts, not environment variables or GPU proxies.
The package boundary gives later resolvers and catalog checks a shared foundation.
Linear NS-920.