Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".
- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
installed copy or None. Every caller already pm.ensure()s on None, so a
missing runtime is now provisioned instead of silently borrowing the
user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
another npm.
- source_build.source_product_current: run the freshness reader only with
PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
artifact; delete the "uv on PATH if new enough" developer shortcut.
The #83714 fix hardened only the tool-call args renderer; five other
renderers (plus three same-idiom siblings) still composed the bare
truncation marker, which the model imitated from replayed context into
new durable writes (#83435).
This routes all model-visible elision in agent/ through shared helpers
in agent/compression_marker.py:
- elide(text, limit): head + marker cap with accurate omitted/total counts
- elide_middle(text, head, tail): kept head/tail with the middle elided
The elision marker's first sentence is byte-identical to the args marker's,
so the existing _COMPRESSION_MARKER_RE (and the dispatch-boundary guard in
tool_dispatch_helpers) rejects a copied marker regardless of which renderer
leaked it; only the tool-call-specific second sentence is dropped so the
marker still fits small caps (the clarify summary cap is 199 chars).
Routed sites:
- context_compressor.py: static-fallback turn renderer, _sum_clarify(),
summarizer prompt builder (middle elision), active-task snapshot,
lean user-message quotes (2 sites)
- skill_preprocessing.py: inline-shell output embedded into skill bodies
- lsp/reporter.py: truncate() for <diagnostics> tool output
- verification_stop.py: verify-on-stop nudge output summary
Regression test asserts the imitable idiom appears nowhere in agent/
strings (comments excluded), so a new open-coded renderer cannot land
silently.
Fixes#121548
(cherry picked from commit 69c63d50b4ababedf4fb4ff88c08f5d1e144a79f)
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
A language server that missed its budget once marked its (server, root) pair broken for the
process lifetime, the same 5 s steady-state budget was applied to a cold server that also had
to spawn, initialize and build its program, and the only escape (servers.<id>.disabled) switched
the server off for every workspace. Three new keys under the existing `lsp` block, all defaulting
to today's behaviour:
- lsp.broken_retry_seconds (0 = lifetime): the broken set stores a monotonic retry deadline per
pair; an expired pair gets one more try, and the INFO skip line names the retry time.
- lsp.warmup_timeout (0 = wait_timeout): the first request against a root with no running client
waits up to this budget (outer join budget follows); warm requests keep wait_timeout.
- lsp.exclude_roots ([]): glob patterns matched against the resolved project root (a bare path
also covers everything beneath it); a matching root never spawns, logged once at INFO. A
non-list value fails closed — WARNING naming the expected shape, every root skipped — because
silently excluding nothing would re-pay the stall the key was meant to avoid.
Part of #116446 (the diagnosability slice landed in #116839, salvage of #116459 by @kokhlo).
The failure-details rewrap re-instantiated the caught exception with a single
string; LSPRequestError takes (code, message, data), so a JSON-RPC error to
`initialize` surfaced to log_spawn_failed as a TypeError with none of the exit
status / stderr details. Attach the details to the original exception in place
and re-raise. Adds an `init_error` mock-server script and one invariant test
(red on the previous head).
After one timeout `_mark_broken_for_file` disables the whole (server, root)
pair for the life of the process, and every later request returned [] with no
log line at all — at default levels a skipped file and a clean file
(`log_clean` is DEBUG) looked identical, so a workspace that silently lost
TypeScript feedback was indistinguishable from a healthy one (#116446, ask 3).
`LSPService.enabled_for` — the gate every production path (snapshot,
get_diagnostics_sync, file_operations_lint) runs through — now emits
`eventlog.log_skipped_broken`: INFO once per (server, root), DEBUG on repeats,
same dedup bucket pattern as the other announce-once events.
kill_process_tree is synchronous (taskkill /T /F with a 15s timeout on
Windows), so the hard-kill in _cleanup_process blocked the loop for the
duration. Run it via asyncio.to_thread. The graceful path is unchanged:
shutdown() already waits SHUTDOWN_GRACE on proc.wait() after `exit`, so a
well-behaved server exits 0 before cleanup ever reaches the kill (probe:
returncode 0, no kill_process_tree call).
Conflicts resolved toward the branch: PM owns dependency preparation, the
Windows shim re-exec/hand-off path stays retired (main's shim-parent wait,
gateway-resume env token and update_cmd_deps tests dropped), docs describe
the PM update flow. The docker workflow parks install-stamp.json around the
toolchain step instead of deleting it so tests/docker can compare provenance.
Both are process-lifetime per-path structures with the same growth shape as the caches
this PR already bounds: _workspace_cache only cleared on service shutdown, the
(server_id, file_path) dedup bucket never. Each resets past a fixed cap (512); for the
stat cache that costs a few re-stats, for the dedup bucket one re-fired INFO line per
key, and a single clear() keeps the lock-free stat cache thread-safe.
The pop/insert/evict sequence mutates the process-wide baseline dict from arbitrary
gateway/subagent threads; on main the write was a single atomic assignment, so the cap
introduced a race (RuntimeError/KeyError past 256 entries) that would surface in the
write-tool path. Callers never hold the lock, so the helper takes it.
The cap test now drives snapshot_baseline and _apply_delta instead of the helper, so
reverting either production call site to a direct assignment is red, and asserts every
mutation runs under the lock.
Long-running gateway/TUI processes grew four in-memory structures for their
whole lifetime:
- agent/lsp/client.py: `_docs` pinned every opened file's full text (and the
server's mirror) until the idle reaper killed the client. Now an
insertion-ordered LRU capped at MAX_TRACKED_FILES=64; evicted files are
didClose'd and re-didOpen'ed on their next touch.
- agent/lsp/manager.py: `_delta_baseline` kept a pre-write snapshot for every
path ever written; capped at 256 with write-recency eviction.
- tui_gateway/methods_complete_helpers.py: `_fuzzy_cache` only checked its TTL
on read, so a root queried once (one .worktrees/<id> per worktree flow) was
pinned forever; the write path now sweeps expired roots under the lock.
- gateway/run_turn.py: `logging.getLogger(f"hermes.tool_calls.{id(log_queue)}")`
registered one permanent Logger per logged turn; a single fixed-name Logger
with one RotatingFileHandler is shared by all turns.
Salvaged from #62934 by @Vissirexa, re-targeted onto the decomposed modules.
The per-task file-tool tracker hunks were dropped: f7a422ee4a already pops
them in AIAgent.close()/cleanup_vm via clear_file_ops_cache.
(cherry picked from commit 668f43b2ea9)
pnpm prints ERR_PNPM_* to stdout, so the install warning showed an empty
reason. The composite live pass hit ERR_PNPM_EXOTIC_SUBDEP installing
@vue/language-server@2 (git-hosted transitive dep) with lsp.package_manager: pnpm;
the policy stays fail-closed, the docs now say why and how to opt out.
Review follow-up: an unknown `lsp.package_manager` value (a typo such as `pnmp`)
warned and then ran npm anyway, bypassing exactly the pnpm/yarn supply-chain
policy the option exists to enforce. It now warns with the allowed set and skips
the install, the same outcome as a configured-but-missing manager.
Yarn's argv moves to the global `yarn --cwd <staging> add ...` form, which both
Yarn Classic (1.x) and Yarn Berry (2+) accept (`add --cwd` is Classic-only), and
the docs note that Berry's default PnP linker writes no `node_modules/.bin`, so
the staging dir needs `nodeLinker: node-modules`. The test moves above the
`__main__` guard and now covers the yarn argv and the fail-closed typo case.
`_install_npm` hard-coded npm, so users who standardise on pnpm/yarn for their
supply-chain policy (minimumReleaseAge, allowBuilds, ...) could not extend it
to the LSP install path (#35448). `lsp.package_manager: npm|pnpm|yarn` (npm
default) selects the manager; the argv table `_NODE_PM_ARGV` keeps every
install inside <HERMES_HOME>/lsp/node_modules. A configured manager that is
not installed skips the install with a warning rather than silently using
npm; unknown values warn and use npm.
Ports the config shape of #70001 (@tupe12334); the request and the first
implementation (#35475, `terminal.npmCommand`) are @temalo's.
Fixes#35448
Co-authored-by: Ofek Gabay <tupe12334@users.noreply.github.com>
Co-authored-by: temalo <temalo@users.noreply.github.com>
relpath() calls getcwd(); with the cwd deleted it raised OSError from the
DEBUG log line inside get_diagnostics_sync, so the write now succeeded but its
diagnostics were silently dropped for the rest of the process lifetime (found by
the composite live pass with real pyright). Same class as the resolver guard.
Salvage trim of #115355: the one-line try/except lives in
resolve_workspace_for_file itself instead of a _current_dir helper, and the
regression test chdirs into a real directory and removes it (no monkeypatched
os.getcwd), so it exercises the kernel behaviour the report describes. The
helper's own change-detector test goes with the helper.
resolve_workspace_for_file evaluated os.getcwd() unguarded, so after a scratch
workspace is removed under a running worker, every write_file/patch reported
[Errno 2] from the LSP enrichment step even though the write landed and
hash-verified. The resolver now reads a removed cwd as "no cwd anchor" and falls
through to the file's own worktree; _maybe_lsp_diagnostics gates through the
guarded _lsp_will_handle helper like the rest of the lint tier, so a
workspace-resolution failure degrades to "no LSP for this write".
Fixes#115342
(cherry picked from commit 6277d2a036b75881ea529e3268832a3f6262b139)
- LSPClient.shutdown: after the protocol `exit` notification wait up to
SHUTDOWN_GRACE for the server to leave on its own before SIGTERM. Every
well-behaved server was being SIGTERMed on top of a clean exit (returncode
-15 on Linux); on Darwin the reaped PID can already belong to another
process.
- gateway shutdown diagnostic: run without GNU `timeout` when the host has
neither `timeout` nor `gtimeout` (stock macOS) instead of skipping the
diagnostic entirely; sort the `ps` listing with `sort` rather than the GNU
`--sort` flag; fall back to `sysctl vm.loadavg` when `/proc/loadavg` is
absent.
- s6 skeleton: chmod after chown so the setgid bit on event/ survives.
Salvages the still-open atoms of #72932 by @sasan1200 (LSP grace, portable
diagnostics, chown ordering); the BASHPID and /private/var/folders atoms
had already landed (911d380296, d4cec15b47).
Review follow-up on the Windows .cmd fix: the only always-on test drove the
`_native_binary_candidates` helper directly, so reverting the production wiring
(`_existing_binary` probing npm's bin dir, wrapper-first order, `_spawn_pyright`
keeping the resolved suffix) stayed green on every non-Windows lane.
`is_windows` is now threaded as data through `_first_existing`/`_existing_binary`
(no sys.platform patch), and the test drives the production entry over an npm-shaped
staging tree (POSIX shim + `.cmd` pair under `lsp/node_modules/.bin`) and asserts
`_spawn_pyright` picks `pyright-langserver.cmd` when handed `pyright.cmd`. Each of
the three wiring reverts turns it red; the windows_only live test stays as-is.
npm writes a POSIX `#!/bin/sh` shim under the bare binary name next to its
`.cmd` wrapper. `_native_binary_candidates` listed the bare name first and
`os.access(X_OK)` is always true on Windows, so `_existing_binary` handed the
shell script to CreateProcess (WinError 193) and `_install_npm` staged the
shim instead of the wrapper. Python/TS/YAML diagnostics were silently gone
for the whole session on native Windows.
- wrappers first, bare name last; the ordering takes `is_windows` as data so
the invariant is testable on every host
- npm's `%~dp0`-relative `.cmd`/`.bat` wrappers stay in `node_modules/.bin`
(a copy in `lsp/bin/` points at nothing); `_existing_binary` probes there
- PATH probing tries the wrappers before the bare name too
- pyright: the langserver sibling keeps the resolved binary's suffix
Salvages #73088 by @BrierAinz (same diagnosis and staging fix, rebuilt on
the current install.py shape).
`_file_ext_or_basename` used `os.path.splitext`, so `home.blade.php` became
`.php` and Blade templates went to the plain-PHP server (or nowhere useful).
Recognise compound extensions first (`_COMPOUND_EXTS`), register `laravel-lsp`
(`laravel-lsp lsp`, manual install via `composer global require laravel/lsp`)
ahead of intelephense for `.blade.php` only, languageId `blade`.
Salvages #75720 by @timoteo7 (stale against the rewritten registry; same
design: compound-extension match + manual-install entry before intelephense).
Fixes#75718
Co-authored-by: timoteo7 <timoteo7@users.noreply.github.com>
- `FileOperations._lsp_handles_extension` asks the active `LSPService`
(`handles_extension`: config-declared servers + the built-in registry) and
falls back to the static `SERVERS` only when no service is running. Why: it
read `SERVERS` alone, so a file whose extension is claimed only by a
custom server never had its pre-write content captured and the stale-
diagnostic line-shift remap was degraded for exactly the servers #100257 adds.
- `test_service_gets_diagnostics_from_config_declared_server` now writes an
`lsp.servers.panache` block into a temp `HERMES_HOME/config.yaml` and builds
the service via `LSPService.create_from_config()` — the only production path
from config to a running server — so dropping `extra_servers=custom_servers(
servers)` from `create_from_config` goes red (it stayed green before). The
custom-extension capture case rides in the same test.
Review follow-up on #115714.
An `lsp.servers` key that names no built-in server and carries `command` +
`extensions` now becomes a ServerDef (optional `root_markers`, `language_id`,
`description`; `env` / `initialization_options` work as for built-ins).
Custom entries are consulted BEFORE the registry so they can claim an
extension; malformed entries are logged and skipped. `hermes lsp status|list`
show them; no auto-install (the command must resolve on PATH or as a path).
Why: users integrating a server that is not (yet) first-party had to edit
agent/lsp/servers.py and carry merge conflicts (#100257).
Salvages the shape of #103372 (custom servers ahead of built-ins, per-entry
language_id) without its unrelated client.py / lint changes.
Fixes#100257
Co-authored-by: Zhang ChuanJin <ZHCHJ888@users.noreply.github.com>
- `_VUE_TUNNEL_MSG` / `_VUE_TSDK_MSG` no longer hardcode an npm argv: they
tell the user to delete `<HERMES_HOME>/lsp/node_modules/@vue` and the staged
`lsp/bin/vue-language-server*` launcher, then run
`hermes lsp install vue-language-server` — the recipe (and whatever package
manager the installer is configured with) does the rest. Why: `hermes lsp
install` skips when the binary exists, so a stale 3.x never auto-migrates,
and a hardcoded `npm install` contradicts a non-npm install policy.
- `test_js_toolchain_recipes_pin_a_javascript_typescript_sdk` asserts the
invariant (typescript major < 7 and vue major < 3, parsed from the recipe
strings) with the import inside the test, so on main the parametrized spawn
cases are what go red instead of a module-level ImportError at collection.
- docs: same reinstall instructions.
Review follow-up on #115726.
@vue/language-server 3.x no longer runs its own TypeScript service: it
forwards every TS request to the *client* over a `tsserver/request` /
`tsserver/response` notification tunnel that VS Code and Neovim host and
Hermes's generic LSP client does not, so `getLanguageService()` never
resolves and no diagnostics are ever published (or the process crashes on
first use when `require('typescript')` yields the Go-native TypeScript 7).
- install recipe pins `@vue/language-server@2` and co-installs
`typescript@6`, the last JavaScript-based line — `typescript@latest` is
now 7.x (Go port, no `lib/tsserver.js` / `lib/typescript.js`), which also
broke the typescript-language-server recipe ("Could not find a valid
TypeScript installation"); both recipes share `TYPESCRIPT_SDK_PKG`.
- new `_spawn_vue` starts 2.x with `vue.hybridMode: false` and an explicit
`typescript.tsdk` (launcher tree → Hermes staging → project node_modules);
a stale 3.x install or a Go-only TypeScript is skipped with a one-time
WARNING carrying the reinstall command instead of failing silently.
- docs: the TypeScript SDK / Vue pinning paragraph in the LSP feature page.
Live repro (temp HERMES_HOME, real recipe, reporter's 6-line .vue in a git
repo, real write_file_tool): before — 3.3.11 installed, `lsp_diagnostics`
absent, agent.log `fresh diagnostics timed out` / server crash on
`ts.server.protocol`; after — 2.2.12 + typescript 6.0.3 installed,
`write_file` returns `ERROR [5:7] Type 'string' is not assignable to type
'number'. [2322] (ts)` for App.vue and the `.ts` control returns the same
TS2322 from typescript-language-server.
Fixes#88869
`LSPService` kept one client per `(server_id, workspace_root)` for the life
of the process. In a long-running gateway that outlives its coding sessions
the client for a removed worktree stayed registered with its stdio pipes
held open — tsserver heaps of several GiB pointed at trees that no longer
existed (#102345). The idle reaper (d7578018c5) does not cover this: a
client whose root vanished is not idle from the server's point of view.
- `LSPService.release_workspace(path)`: detaches every client whose folders
live under `path` under `_state_lock`, waits for in-flight spawns so a
concurrent `_get_or_spawn` cannot reinsert a client after release, prunes
the delta baselines and broken-set entries beneath the path, and shuts the
clients down on the service loop. Multi-root servers (pyright) only drop
the folder (`LSPClient.remove_workspace_folder`) so siblings keep their
shared process. Idempotent; best-effort; returns the count.
- The reaper sweep now also uses that primitive for clients whose every
workspace folder no longer exists (externally deleted roots).
- `agent.lsp.release_workspace()` reaches every started service without
creating one; `hermes_cli.worktree_ops.release_lsp_clients()` is called
from both worktree-removal paths (`cli._cleanup_worktree`, kanban
`_cleanup_worktree_workspace`) BEFORE `git worktree remove`.
Salvages the direction of #102381 (@Sahilvishnaliya, release_workspace +
cli hook) and the deleted-root eligibility of #95047 (@israellot); both
matched on `key[1]`, which is `""` for multi-root servers and would have
reaped pyright on every sweep — the primitive here matches on
`client.workspace_folders` instead.
Co-authored-by: Sahil Vishnalya <222165401+Sahilvishnaliya@users.noreply.github.com>
Co-authored-by: Israel Lot <840042+israellot@users.noreply.github.com>
Salvage trim of #75559: the docstring of _open_and_wait_async still said the
snapshot mode "uses the default wait budget", and the user docs described
wait_timeout as the post-edit wait only. The above-default test now uses 7s
instead of 10s (same red-on-base signal, 3s less wall time) and the join-budget
comment says what the ceiling is for in two lines.
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.
Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.
Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.
Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.
Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
Two LSP freshness bugs reported by @tobific (#108882, #108881):
- `_current_diags_async()` keyed the client lookup by the enclosing
workspace root while `_get_or_spawn()` stores single-root servers under
`srv.resolve_root(...)` (a nested package.json project). The lookup
returned [] for a live client with diagnostics, so the delta baseline was
refreshed from nothing. Use the same resolved-root key.
- `open_or_change()` published `_DocState.version` only after awaiting the
didChange write. A versionless publishDiagnostics read during that await
was credited with the OLD version and judged stale once the send resumed.
Bump the version before the send; a failed send (swallowed by
`_send_notification`) leaves a version nothing satisfies, i.e. "no
verdict", which is the existing contract.
The mock server gains a push-only `versionless` script so the race is
reproducible without a real language server.
Under gateway.multiplex_profiles a secondary profile's turn ran with the LAUNCH
profile's working directory, command allowlist, redact_secrets switch, credential
file mounts, browser engine/headed flags, LSP service, auxiliary-provider health
marks and MCP stderr log, and several TERMINAL_ENV consumers read the process env
instead of the routed profile's terminal scope. A standalone `hermes -p X gateway
run` never behaved that way.
- tools/terminal_scope.py: resolve the terminal.cwd placeholder inside the
profile scope with the same rule gateway/run.py applies at import (local ->
$HOME, sandbox default otherwise) so the system prompt, context files and the
terminal of a routed turn start where the profile's standalone gateway would.
- tools/image_source.py, credential_files.py, image_generation_tool.py,
skills_tool.py, delegate_tool_progress.py, agent/tool_executor.py: read
TERMINAL_ENV / TERMINAL_CWD through the terminal scope.
- tools/approval.py (+ approval_floors.py): one permanent allowlist per routed
profile home; the unscoped module set stays for single-profile processes.
- agent/redact.py: `_redact_enabled()` resolves security.redact_secrets for the
routed profile (scope .env, then config); launch snapshot kept when unscoped.
- tools/credential_files.py, agent/auxiliary_health.py, agent/lsp/__init__.py,
tools/browser_tool_cloud.py, tools/mcp_tool_config.py,
tools/tool_result_storage.py: key process caches by profile home (or bypass
the slot under an override).
Tests: tests/tools/test_multiplex_turn_parity.py (4, red on base).
Docs: multi-profile-gateways.md isolation table.
Use PM-selected interpreters and tool entrypoints for Browser Use, Hindsight and Python language servers. Sync the declared Google Chat extras instead of changing the active environment with pip.
Verified the affected 11-file Nix test subset: 359 passed, 7 skipped. The full suite and real third-party package installation were not run.
Pyright selected the Hermes interpreter ahead of an explicit or local
project environment. Keep PM as the fallback rather than hiding the
project's dependencies.
The regression drives the real server registry and spawn options with
disposable venvs, then executes each selected interpreter. Correct the
neighboring Windows pip fixture to patch the installer seam it uses.
No language-server subprocess or user project was changed.
Merge upstream b1f003e186 while preserving PM runtime ownership and
Python 3.14 worker startup, Windows signing, and macOS wait recovery.
Keep retired runtime modules deleted. Port upstream updater preflight
checks into the checkout strategy and preserve live build logging.
Carry checkpoint filename handling and process recovery into the current
module layout. Regenerate locks and adapt incoming platform test markers.
Focused Python and JavaScript tests, desktop and root-test typechecks,
conflict-path lint checks, lock validation, and retired-import checks pass.
The full test suite and packaged release builds were not run.
Carry the existing write fence across Hermes-owned spawn boundaries without
dropping board routing or changing credential policy. Grant dispatcher and
managed tool runtimes explicit task scope; align CLI task mutations with tools.
Verify real shell/CLI descendants, dispatcher startup, and supervised stdio
transport against isolated SQLite boards. This is cooperative runtime scoping,
not OS confinement.
Refs #103974, #104058, #104904
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:
git revert <this sha>
removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.
What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)
Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.