Commit Graph

1256 Commits

Author SHA1 Message Date
Teknium
00ba600fc0 refactor(cli): container exec routing moves out of main.py into main_container.py
main.py may only shrink vs main (code-health FILE_LINES); this branch grew it
by 9 lines. _probe_container and _exec_in_container (the NixOS container-mode
re-exec, 96 lines) move verbatim to the topical sibling
hermes_cli/main_container.py; the entry point is now public
(exec_in_container) and main() late-imports it at its only call site. The
test import and the managed-runtime resolution allowlist entry follow the
move. No re-export shim. Behaviour is unchanged.
2026-10-06 04:36:02 -07:00
Teknium
2d36c7b643 fix(update): a custody refusal is what hermes update reports, and every refusal is receipted (R8 m2)
CustodyRefused is an OSError, and many readers swallow one (_git_stdout, _capture_head_sha),
so on a refusing host the first refusal could end the update as a misleading downstream
error. update_custody now keeps the run's first refusal (and whether any update child had
run in custody, or the update was past its apply stage), and the command's failure path
(cmd_update; the completion child's _finish) prints it in place of the generic error:
it names `hermes update`, says nothing was changed when no update step had run, and how to
retry. A refused bind and a job that cannot be handed to a build launcher are now receipted
like the refused launcher join; the resume moved to update_lock.resume_suspended_child.

Tests: Linux (the cmd_update failure path after a swallowed refusal, receipt step) and a
Windows live cell (the real readers swallow a real refused job assignment).
2026-10-06 04:36:02 -07:00
Teknium
5a7a692af7 fix(update): owner-liveness update marker v2 and a checkout lock held by the whole update tree
hermes_cli/update_lock.py implements update-marker contract C1 and the checkout lock (C1.7):
- the marker lives in the profile-tree ROOT home, never profiles/<p> (desktop V9c)
- v2 body pid / started_at / ct:<creation time>; an owner is live while its pid is
  alive and its creation time matches (2.0 s) — no age ceiling for v2, the 20-min
  ceiling only ages out v1 markers (cli V10/V11, desktop V3, V22)
- O_EXCL claim with one compare-and-delete reclaim of a dead owner; every delete
  is compare-and-delete; never truncate-and-write over a marker (desktop V4)
- adopting a hand-off partner's claim writes a line-4 delegate and restores
  lines 1-3 byte-identical (or deletes when the partner is dead) on exit
- `hermes update` holds <install root>/.hermes-update.lock (flock / msvcrt) so two
  updates of one checkout from different homes exclude each other (cli V3); completion
  children inherit the locked fd (pass_fds) on POSIX and run in a kill-on-close job on
  Windows, so the lock is never free while the update tree runs
- an unwritable marker dir or install root refuses via exit 2 instead of running unlocked
- process_create_time: psutil when importable, a stdlib probe of the same clock otherwise
- update_in_progress(install_root) for readers: live marker or held checkout lock

Smallest hunks outside the lane: main.py passes install_root=PROJECT_ROOT;
update_completion.py passes the lock fd to both completion children.
2026-10-06 04:36:02 -07:00
Teknium
22bc7eac71 refactor(solstice): rebuild the provider as a standard model-provider plugin
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
  to the generic PKCE flow as token_request. A refresh that NAS rejects
  (it folds every Google failure into 502) is treated as a dead grant
  needing re-login.
- transport.py: a GeminiNativeClient subclass on the
  generateContentPerUserQuota methods with Bearer auth (~30 lines, where
  the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
  default_aux_model).

Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
2026-10-05 23:33:41 -07:00
Ben Barclay
8e015f483f feat(providers): add Solstice as a secret-launch OAuth inference provider
Per-user-quota OAuth inference for Hermes Agent, hidden by default until
public launch is cleared. Auth (OAuth2 + PKCE with the user's account) is
brokered by nous-account-service, which holds the confidential client secret;
inference goes direct to the per-user-quota endpoint.

Built fresh on current main; commit history carries only the codename.

Provider surface:
- plugins/model-providers/solstice/ — profile + hidden enable gate
- hermes_cli/solstice.py — PKCE loopback login, NAS-brokered config/exchange/
  refresh, token store, quarantine; callback path unchanged
- agent/solstice_adapter.py — per-user-quota transport over the native
  adapter's OpenAI-compatible facade
- providers/: _HIDDEN_GATES + register_hidden_provider_gate + a hidden filter
  in list_providers (kept out of the picker/registry/doctor until enabled)
- wired into main.py (_PROVIDER_MODEL_FLOWS), auth_commands.py (_PROVIDER_ALIASES,
  _OAUTH_ADD_SPECS, _is_known_provider allowlist), models.py (catalog fetcher),
  runtime_provider.py (resolution rung), model_setup_flows.py, agent_runtime_helpers.py

Kept verbatim (the live wire contract, until the broker is renamed): the
inference host, the OAuth scope, the deployed broker routes, and the
registered redirect callback path.

The enable gate is unchanged (open item: gate on stored credentials).
Verified: 226 focused tests pass; runtime resolution, client construction
and auth-add all exercise the real paths.
2026-10-05 23:33:41 -07:00
teknium1
64ad33e32d refactor: remove Skill Sync (hermes sync) and the org skill mirror from core
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:

- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
  `hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
  `[org-shared]` / name-collision labels, the org provenance header and
  `org_provenance` field on skill_view, the org delete guard and the
  auto-propose `org_sharing` note on skill_manage

The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
2026-10-03 23:05:48 -07:00
emozilla
5059f20e1b Merge remote-tracking branch 'origin/main' into ethie/llamacpp-0.5.0-linux-cuda-main
# Conflicts:
#	hermes_cli/local_runtime/bootstrap.py
#	hermes_cli/main.py
2026-10-03 22:53:47 -04:00
emozilla
d729da70da fix(local-runtime): stop only orphaned incumbents; owners free the server on exit
_stop_state_server received _state_endpoint()'s {base_url, api_key} and
read a "pid" it never carries, so stale-preset replacement and the rescan
bounce never stopped an adopted router: the old one kept its VRAM and the
new one fell back to an ephemeral port. Its tests mocked a pid into the
endpoint. It now resolves the process from the identity-guarded state
record and stops it only when the recorded owner is gone; a live owner's
watchdog would respawn its router beside ours, so that server is left as
is and no second one boots.

Only the desktop backend stopped the server on exit; every other process
that booted it on demand (CLI, -z, `hermes model`, gateway) orphaned it
with its VRAM. The process that boots the server now stops it at exit
(atexit; one-shot's os._exit path calls it from its cleanup table).
Processes that merely adopted a server never stop it.

Verified on WSL: `hermes model`, `hermes -z` and interactive `hermes`
leave no llama-server behind after exit.
2026-10-03 22:32:14 -04:00
emozilla
400cd69261 feat(cli): local models in hermes model
`hermes model` (and `hermes setup`) gain a Local models row whenever PM
pins an engine for this machine. The flow drives the handlers behind the
desktop's Local Models pane in-process, so both surfaces run one code
path: the catalog priced for this machine (★ = recommendation), models
already on disk, quickstart (pinned engine, model download, server start,
default assignment) and activate. Progress renders from the job view the
desktop polls; Ctrl+C pauses a download and choosing the model again
resumes from the kept bytes.

Verified: a catalog download paused at 3% and resumed to completion,
activation of an on-disk model, and `hermes -z` / interactive chat against
the result.
2026-10-03 22:32:13 -04:00
teknium1
4b2c2ae999 fix(telemetry): one-shot CLI runs report entrypoint one_shot, not interactive
Fleet telemetry labelled every `hermes -z` / `chat -q` / `-Q` run as
entrypoint=interactive because the cli surface mapped straight to
interactive and no caller declared anything else, so 95% of CLI sessions
read as one-turn humans and scripts could not be told apart.

- New closed entrypoint `one_shot` (contract + v3 schema enum). The task
  scope start declares it for cli-surface runs carrying the
  HERMES_SINGLE_QUERY_SESSION marker the one-shot paths already set (the
  signal session source and cache_ttl: auto read). Children Hermes
  dispatchers spawn (kanban worker source=kanban, A2A forward source=a2a)
  read the existing `background`. Delegated children, other surfaces and
  the REPL are unchanged.
- Engagement keeps treating one_shot like interactive (its prior label).
- `hermes -z` exits via os._exit, skipping the atexit hook that closes the
  metrics session: its pre-exit cleanup now runs the runtime shutdown, so
  task_run.finished and session.count are actually written (before, only
  task_run.started survived and the store kept a hot journal).
- Smoke expectations move to one_shot (it drives `chat --query --quiet`).
- relay-shared-metrics.md documents the entrypoint vocabulary.
2026-10-03 13:25:25 -07:00
kshitijk4poor
c044b80d86 refactor(serve): name the Desktop backend lock file once
Gate quality finding (Low): the bare "backend.lock.json" literal now sat
in main.py (the owner watchdog's lock path) and dashboard_procs (the
reaper), both of which must stay in step with the Desktop writer. Add
BACKEND_LOCK_NAME next to _REMOTE_LOCK_SUBDIR and use it in both.
windows_ssh_runtime keeps its own literal (separate Win32 runtime; left
as is, optional per the review).
2026-10-03 19:51:54 +05:30
kshitijk4poor
79f6f944bb fix(serve): retire an SSH-isolated backend once its ownership lock names a newer spawn
A Desktop reconnect that cannot prove the recorded pid is its own (dead-looking
pid, FOREIGN argv proof) drops desktop-ssh/<ownershipId>/backend.lock.json
without signalling that pid and spawns a new nonce. The superseded serve had no
signal that it lost ownership: it lived until the idle watchdog fired (900s
grace, gated on zero WebSockets, which a half-open tunnel keeps non-zero for up
to the 600s ping timeout), so a reconnect storm stacked detached backends, each
an extra state.db writer (#132034).

web_server_owner_exit mirrors web_server_skew_exit: a pure decision (retire only
when a VALID lock names another spawnNonce and the process is past the reaper's
180s settle window; missing, unreadable or skewed locks keep it up) and a daemon
watchdog that needs two consecutive observations, then exits through the
retirement fence so it never tears down mid-turn and admission is closed first.
The lock is read through dashboard_procs' existing validator (split out as
read_valid_backend_lock, shared with _lock_owned_serve_pids) rather than a
second parser, and its path is the validated --ssh-session-token-file's
ownership directory. Server-side only: cleanupStale is untouched, so this
composes with #126035.

Root-cause analysis and watchdog design from the #132034 report by @VexterZ;
lock-ownership watchdog first drafted in #89452 by @pmwanje2 (prior art).

Refs #132034 (POSIX SSH hosts; Windows-host locks are not validated yet)
2026-10-03 19:51:54 +05:30
itsflownium
2eadea653a fix(cli): let uninstall dry-runs run without a TTY and list the install record
A dry run never prompts, but plain `hermes uninstall --dry-run` still went
through the interactive-terminal gate and was refused when stdin was not a
TTY. Exempt --dry-run from that gate, matching --gui and --data.

The GUI removal plan also omitted desktop-installed-apps.json, which
uninstall_gui() deletes. List it under "Will remove" so the preview matches
what a real run removes.

Adds a CLI-level regression test covering `--gui --dry-run` with and
without --yes, and plain `uninstall --dry-run`, asserting nothing is
prompted or removed.

Refs: #128974
Salvaged-from: #131147
2026-10-02 18:06:40 +00:00
liuhao1024
049028e0f1 fix(cli): honor --dry-run in uninstall --gui
`hermes uninstall --gui --dry-run` really deleted the GUI artifacts
(including the workspace node_modules) instead of printing a plan:
run_gui_uninstall() had no dry-run branch, so the confirmation prompt
still ran and answering "yes" executed uninstall_gui() for real. The
--data and default uninstall modes already honored the flag.

- run_gui_uninstall(): after printing the removal plan, print the dry-run
  notice and return before the confirmation prompt (mirrors
  run_data_uninstall), so both the interactive and --yes paths are covered
- cmd_uninstall(): exempt --dry-run from the --gui TTY gate, like --data
  already does — a dry run never prompts, so it needs no TTY
- make _hermes_path_markers' docstring a raw string: its git\cmd / git\bin
  examples emitted a SyntaxWarning on every uninstall run (reported in the
  issue's output)

Refs: #128974
Salvaged-from: #128981
Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-10-02 18:06:39 +00:00
teknium1
7a06cb50d7 feat(telemetry): shared-metrics opt-in offer on every surface
One answer per profile, asked once, with the Desktop strip's three equal
choices (Send to Nous / Local only / No thanks) now offered by:

- `hermes setup`: at the end of every flow (Quick, Full, Blank Slate,
  Portal, --quick), via the setup-completed record they all funnel into.
- `hermes` / `hermes --tui`: once before an interactive chat on a profile
  that never answered. Skipped for -q, piped/JSON output, spawned actions
  (HERMES_NONINTERACTIVE), Desktop-hosted panes and managed installs.
- Web dashboard: a banner for the profile being managed, backed by
  GET/PUT /api/shared-metrics/consent.

"No thanks" is the terminal default so Enter never opts anyone in; Esc and
the banner's X leave the question open.

Also fixes the answer that could not stick: a CLI "no" equals the shipped
defaults, so save_config stripped it and every surface (Desktop strip
included) kept asking. The answer is now written to config.yaml directly,
and `decided` has one definition shared by the RPC, the CLI and the router.
2026-09-30 21:15:08 -07:00
kshitijk4poor
db17e07389 fix(update): mark current updater frames with a sentinel, not missing locals
The pre_update_version co_varnames check added in af26acab73 rejected the
current updater's entrypoints, but it also rejected historical updaters
from 2026-07-26..08-16, whose _cmd_update_impl/cmd_update never declared
that local — their in-process dependency installs would have continued on
the old interpreter instead of handing off to the takeover child.

Invert the gate: the CURRENT updater declares the
_hermes_current_updater_frame sentinel local in both entrypoints; no
historical on-disk version can contain it. in_historical_update() now
hands off on entrypoint match unless the sentinel is present, restoring
the handoff for every historical window while still refusing current
frames. Verified live for all four frame classes; mutation check: with
the sentinel check removed, the current-updater refusal test fails.
2026-09-29 16:47:48 +05:30
kshitijk4poor
fbb0eb35e1 fix(cli): missing sticky profile error names the recovery command
When active_profile names a deleted profile, the error still said
"Create it with: hermes profile create <name>", a command that exits with
the same error. Name the command that works instead:
`hermes profile use default`. An explicit -p keeps the create hint.
2026-09-29 15:45:52 +05:30
kshitijk4poor
167d3bb6f3 fix(cli): stale-profile recovery refuses uninstall --full and abbreviated --data
The recovery allowlist checked for the literal string "--data", but the
uninstall subparser accepts argparse abbreviations: `hermes uninstall --dat`
passed the guard and removed the default root's data. `--full` was allowed
outright, and in the default root it rmtree's every other profile under
profiles/ as well, a wider wipe than the --data mode the guard refuses.

Parse the uninstall argv with the real subparser (output silenced; the
handler reports errors itself) and allow it only when neither data nor full
resolves true. `uninstall --help` exits 0 during that probe and is let
through so the handler prints help once instead of help plus an error.
The predicate lives in a topical sibling (main_profile_recovery.py) rather
than growing the main.py facade.
2026-09-29 15:45:52 +05:30
Gille
a4748e9224 fix(cli): allow recovery from missing active profile 2026-09-29 15:45:52 +05:30
teknium1
c13a4abc8d feat(telemetry): v5 signals — tool_unavailable, provider_setup, feature_adoption, feature_disabled
- hermes.tool_unavailable.count: model called a shipped built-in (BUILTIN_TOOL_NAMES) not enabled in
  the session; tool name + catalog provider/model. Unknown names stay in v4 unknown_tool quality.
- hermes.provider_setup.count: started/completed/abandoned/failed per provider and surface
  (cli_setup, cli_model, tui, desktop, dashboard) with a closed failure_class. Pending marker per flow;
  a dead or stale marker is reported abandoned at the next start (v4 process-marker pattern).
- hermes.feature_adoption.count: once per feature per install at first real use, derived from existing
  counters in the subscriber (metric -> feature table) plus Bot Mode / Projects hooks; bucketed by the
  owning profile's install age.
- hermes.feature_disabled.count: turning off a default-on toolset/skill/plugin/platform/setting (and the
  re-enable back to default), diffed at the config write chokepoints; once per (kind,name,event)/day.
  Surface comes from the user entry point; setup/migrations record nothing.
2026-09-28 12:43:03 -07:00
teknium1
c4fe596783 style: split the begin_process one-liners (E702)
The v4 process-exit hooks put an import and a call on one line with a
semicolon in hermes_cli/gateway.py and hermes_cli/main.py.
2026-09-28 12:43:03 -07:00
teknium1
040dc77490 feat(metrics): hermes.process.exit from per-process markers and turn watchdogs
A crash or SIGKILL cannot record itself. CLI, TUI, gateway, serve and cron-tick
drop a small marker under the profile's store dir at start (only when
collection is on), restamp it clean at exit, crash+exception family from the
excepthook, or watchdog before the gateway's os._exit. The next start in that
profile reports every marker whose owner fails the canonical start-time
liveness check (running => killed), on a daemon thread with rename-claims so
startup never waits and concurrent starts never double count.

Turns killed by the turn liveness watchdog or the gateway inactivity watchdog
count as exit_kind=watchdog once per turn, in the turn's own profile (home
captured at turn entry; off-thread watchdogs bind it explicitly).
2026-09-28 12:43:03 -07:00
Teknium
a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00
Brooklyn Nicholson
53a785449f fix(update): rebuild Desktop for an installed app with no checkout build
`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.
2026-09-26 21:42:48 -05:00
brooklyn!
8684bf3ddc fix(desktop): forward --profile into the packaged desktop launch
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.
2026-09-26 18:00:30 -05:00
Brooklyn Nicholson
87d1c5b0c3 refactor(tui): drop redundant native-mode guards 2026-09-25 17:57:38 -05:00
brooklyn!
93f7617406 feat(tui): add native terminal mode defaults 2026-09-25 17:57:38 -05:00
Hermes Agent
954d94c3b0 fix(cli): name Windows Smart App Control when it blocks the dashboard runtime
When Windows Smart App Control or an Application Control policy blocks the
embedded Python runtime's _ssl module, the fastapi/uvicorn import in the
dashboard startup fails with the "DLL load failed ... _ssl" signature, but
the handler printed the generic missing-deps message — so users looped on
`hermes pm install` / `hermes pm repair`, which can never lift a policy
block.

Detect the signature and say so instead: the runtime's DLL is blocked,
repair cannot fix it, and the recovery options are a trusted system
Python, an IT exemption, or the CLI/gateway (which use the system Python).

Fixes #63796

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-25 16:28:14 -05:00
calvinnwq
2e0243b158 fix(desktop): never attach to an isolated serve found in the spawn ledger
hermes serve --isolated (the backend another machine's Desktop spawns
over SSH) opts out of the host singleton on the CLI side, but its spawn
ledger row carried no structured marker, so the local Desktop's
attach-first discovery adopted it. Nothing on this host owns that
process, so a Desktop-driven update left the local app on stale code.

Record isolated=True in the ledger row and skip such rows in
parseSpawnLedger. An ordinary serve is still attached even when an
isolated record is newer.
2026-09-25 12:09:37 -05:00
ethernet
268820136f fix(update): repair an install whose venv runs another checkout
An install's in-tree venv whose editable record names another checkout
(what project_venv_dir used to cause) turns <install>/venv/bin/hermes
into that checkout's CLI. Every update Desktop hands to the launcher
then pulls the other tree: the install never moves, the hand-off
reports success, and posix.sh swaps the install's stale release/ build
back over the app. Pressing Update can never get out of that loop.

`hermes update` now notices it is running on another checkout's in-tree
venv and re-runs itself with that install's own code (PYTHONPATH=<install>,
cwd=<install>). The install's updater pulls the install and reinstalls it
into its venv, which points the editable record home again, so the next
relaunch runs a fresh build. A wrapper that put the running checkout on
PYTHONPATH on purpose chose that tree and is left alone.
2026-09-25 01:21:28 -04:00
ethernet
a021b61bfc fix(cli): keep hermes_cli import off os.environ; run the import-purity test on Windows
Importing hermes_cli on a host whose stdout is not UTF-8 (a cp1252 pipe on
Windows, a latin-1 locale on a Pi) set PYTHONUTF8/PYTHONIOENCODING in
os.environ as a side effect. Every library importer -- gateway.relay, the
compute host, agent.auxiliary_client -- then leaked that into each child it
spawned, which is what
test_library_imports_of_dual_use_entry_modules_stay_side_effect_free
catches on Windows (changed == [PYTHONIOENCODING, PYTHONUTF8]).

The package import now only repairs its own streams and records whether it
had to. hermes_cli.main.main() turns that record into the child-process
hint, so `hermes` still steers its Python children to UTF-8 on such a host.
On Windows the entry-point bootstrap and configure_windows_stdio() already
export both variables; the tool subprocess env builders set them for
children independently.

The test was unmarked, so the OS lanes (`-m "platforms and not
integration"`) deselected it and Windows never ran it. Mark it
platforms("any"): list_os_marked_tests.py lists the file for every lane and
the -m expression now selects the test.
2026-09-24 14:47:06 -04:00
ethernet
8eb20a50ae Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	agent/learning_graph.py
#	hermes_cli/backup.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_doctor_journal_modes.py
#	tests/hermes_cli/test_sessions_set_journal_mode.py
2026-09-24 08:51:52 -04:00
John Paul Soliva
4194919f5d fix(import): exit 1 and say "incomplete" when archive members were skipped
`_import_members` catches the PermissionError/OSError of each member it
cannot publish and records it in `errors`. That includes the state.db the
live-safe restore refuses because a gateway or dashboard still holds it
(#100960, #110179). `run_import` printed those under "Warnings (N files
skipped)", then printed "Done. Your Hermes configuration has been
restored." and returned None. `cmd_import` did not forward a return value,
so the shell status was 0. A script chained on `hermes import --force`
carried on over a partial restore, and the dashboard's import action
showed the green "done" badge. For the refused state.db case, the user's
sessions were never restored.

`run_import` now returns 1 when `errors` is non-empty. It words both the
summary header and the final line as "Import incomplete", and `cmd_import`
forwards the return code, the same contract `hermes backup` has for an
incomplete archive. Runtime files the import deliberately keeps
(gateway.pid, SQLite sidecars) and the older-backup session warning stay
warnings. The gateway revive still runs, so the files that did land come
up as before.

Measured on main with a fresh HERMES_HOME through `main()`: a 3-file
backup with one read-only target directory, and a refused state.db (live
DB left at 3 sessions / 12 messages). Both used to exit 0 after "Done ...
restored". Both now exit 1 after "Import incomplete: 1 file(s) were not
restored". A clean import still exits 0 and prints "Done".

(cherry picked from commit a0f808ec9f868128f4174e4816e095ac17f18d28)
2026-09-24 18:00:44 +05:30
ethernet
d75d3fe15c Merge origin/main into ethie/pm-clean
Conflict resolutions and semantic fixups:

- tools/environments/base.py: main's hard-exit kill fence (kill a spawn the
  fence missed, deregister from _live_foreground in a finally) wrapped around
  pm-clean's output collector.
- pyproject.toml: pm-clean's marker list plus main's new `live` marker.
- hermes_cli/main.py: pm-clean runs startup recovery from hermes_bootstrap, so
  the old early-recovery block stays gone; main's interrupted-pull restore
  (auto-merged above it) runs right after bootstrap, as on main.
- hermes_cli/update_cmd.py: main's interrupted-pull marker now guards
  pm-clean's first tree mutation (release-tag detach, ff-only, or reconcile)
  and is cleared once git is done. The marker's target is the ref git actually
  moves to (a release tag, not always origin/<branch>), since the restore
  compares against it.
- hermes_cli/_early_recovery.py: restore `import subprocess`, which pm-clean
  had dropped and main's auto-merged restore needs (NameError on the first
  launch after a killed update; test_update_interrupted_pull red -> green).
- apps/desktop/src/i18n/{de,es,fr}.ts: main's new locales carry the full
  settings.about block; trim it to `updates` as pm-clean's type and the other
  overlays do (tsc: 27 errors -> 0).
- main's new e2e tests: `import yaml` -> hermes_yaml; wake-word import table
  names pyopen_wakeword (pm-clean's wake-openwakeword extra); the anthropic
  key-leak switch leg needs the SDK, and the api_server two-tenant test needs
  aiohttp, both PM runtime extras the test env does not carry.
2026-09-23 21:55:59 -04:00
teknium1
e30f8c6344 fix(update): a hermes update killed mid-pull no longer bricks every entry point
Git moves the checkout file by file and HEAD last, so an update killed while
"Pulling updates" (Ctrl-C, closed terminal, power loss, OOM) left HEAD on the
old commit with a prefix of the files already new. That mixed tree failed at
import in every entry point, `hermes update` included
(`cannot import name 'load_yaml_file_readonly' from 'utils'`), and only a
manual `git reset --hard` could bring the install back.

The updater now brackets the fast-forward (and the divergence reconcile) with
`.git/hermes-update-pull` naming its pid, the pre-pull commit and the target.
`hermes_cli.main` calls `_early_recovery.restore_interrupted_pull()` right
after `hermes_bootstrap`, before any other checkout import: when the marker's
owner is gone and HEAD is still the pre-pull commit, every path that differs
between the two commits goes back to HEAD (the tree the venv was built for),
the dead git's index.lock is cleared, and the command re-execs itself (modules
already imported, main.py included, may be the half-written ones), so a rerun
`hermes update` repairs the install and then updates normally. Paths the update does not change keep local edits; the
updater's autostash stays in `git stash list`. Our own pid is never treated as
a live owner (containers hand a retry the killed updater's pid).
2026-09-23 17:42:24 -07:00
ethernet
44f99caacd fix(entrypoints): tolerate only an absent bootstrap, not a broken one
Every entry point wrapped `import hermes_bootstrap` in
`except ModuleNotFoundError: pass` for a partial update that left the
bootstrap unregistered. It also swallowed a module the bootstrap itself
failed to import, and since the bootstrap now owns PM activation that
silently ran the tree on stale dependencies: exactly how a pre-PM
editable venv hid its unreachable `pm` until it crashed on ruamel.

Re-raise unless the missing module is hermes_bootstrap, at all six entry
points. The stale "only Windows UTF-8 stdio suffers" comments go with it.
2026-09-23 14:15:29 -04:00
ethernet
c13ea774e6 refactor: make install-stamp.json the single runtime version identity
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.

Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).

Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
2026-09-23 11:41:01 -04:00
ethernet
c6b358592d Merge origin/main into ethie/pm-clean 2026-09-23 10:43:45 -04:00
ethernet
f4a38b1ee8 Merge origin/main into ethie/pm-clean 2026-09-23 10:13:26 -04:00
teknium1
746f7ea21b fix(dashboard): Desktop children publish their own host role; SSH spawn shape is Desktop-owned
Review follow-up on the host-rendezvous isolation:

* Excluding Desktop-owned children from ROLE_SERVE broke #119644's terminal
  path: `hermes plugins install` found "the running Desktop backend" only via
  read_record(ROLE_SERVE), so on a Desktop-only box open chats no longer lit
  up. A Desktop child now publishes ROLE_DESKTOP_SERVE (own lock, record and
  0600 token). The attach/refuse ladder (_host_backend_attachment) still reads
  ROLE_SERVE only, so a supervised public dashboard is never blocked by it;
  notify_serve_backend prefers the host owner and falls back to the Desktop
  record. /api/host/identity reports the role actually published.

* is_desktop_owned_backend() missed Desktop's SSH spawn — `env HERMES_DESKTOP=1
  hermes serve --isolated --ssh-session-token-file F` carries NO token env var
  (remote-lifecycle tests assert the var name never appears) — so the SSH child
  still claimed ROLE_SERVE on the remote host and its MCP discovery flipped to
  deferred. The predicate now accepts the token-FILE argv shape; the argv half
  lives in _startup_fast.is_desktop_ssh_backend_argv (stdlib-only, importable
  before main.py's import wall) and replaces the two duplicate substring checks
  in main.py and dashboard_procs.py.

* web_server.py's remaining three bare HERMES_DESKTOP reads (cron ticker,
  managed-gateway teardown, orphan serve reap) route through the predicate;
  the tests that modelled a Desktop child with the bare flag set the spawn
  token, as a real pool child does.
2026-09-23 07:09:45 -07:00
teknium1
9ca3b54e91 fix(dashboard): one desktop-owned-child predicate, exit 78 on host-owner refusal
Widen the two salvaged fixes to the whole class and make the refusal
supervisor-safe:

- `process_identity.is_desktop_owned_backend()` is the single discriminator
  (HERMES_DESKTOP=1 AND the per-spawn HERMES_DASHBOARD_SESSION_TOKEN). The
  attach bypass, the named-profile reroute, the env sanitizer, the MCP
  discovery timing and the host-rendezvous publish all key on it now; a shell
  that merely inherited the flag from Desktop is treated as a normal launch
  (#119210). The publish skip from #119832 keyed on the bare flag, which
  would have hidden a supervised service launched from a Desktop shell.
- `_attach_to_host_backend` refuses with exit 78 (EX_CONFIG) instead of 1.
  Exit 1 under `Restart=always` was an infinite restart loop with nothing
  listening on the ingress port (2035 restarts, #119824); 78 is the
  deliberate-refusal code `RestartPreventExitStatus=78` parks on, the same
  contract the gateway unit already uses.
- Docs: the systemd example carries RestartPreventExitStatus=78 and the
  user-side remedy (stop the owner, or `--isolated`).
- Tests trimmed to ≤2 invariants per fix; the control case in the desktop
  tests sets the token so it models a real pool child.
2026-09-23 07:09:45 -07:00
KoNit-K
269a982b64 fix(dashboard): require Desktop spawn credential for host bypass 2026-09-23 07:09:45 -07:00
John Paul Soliva
bd112bc8b3 fix(docker): the image's gateway run keeps the root profile, and a leading -p reaches hermes
Inside the s6 image a bare `gateway run` (the image's CMD) redirects to the
supervised slot of the current profile. The profile pre-parse applied the
sticky active_profile first, so after `hermes profile use <name>` (or a
dashboard profile switch) every container boot started that named slot:
the one the boot reconciler had just registered down, because a started
named slot is a second gateway beside the multiplexer. The redirected run
now keeps the root identity like any supervised slot (#74872);
--no-supervise keeps the foreground behaviour that follows active_profile.

The obvious workaround, pinning the CMD to `-p default gateway run`,
restart-looped the container: main-wrapper.sh probes `command -v "$1"`,
and `command -v -p` parses -p as an option to `command` and succeeds, so
the wrapper exec'd "-p". A leading flag now always goes to hermes.
2026-09-23 06:24:19 -07:00
ethernet
9de542c15b merge origin/main (16 commits) into ethie/pm-clean
main folded the auto-archive housekeeping into per-profile state.db maintenance; the plugin
update-check chore stays. The consolidated-away TestWorkdirParallelPool stays removed. Two new
utf-8 reads in gateway/host_rendezvous.py read utf-8-sig.
2026-09-21 06:09:56 -04:00
teknium1
9eb90b0a03 feat(gateway): host-wide singleton lock + rendezvous record
Multiplex-only (Teknium ruling): exactly ONE `hermes serve` and ONE
`hermes gateway run` per host, each multiplexing every profile. The existing
gateway lock/PID files are anchored to HERMES_HOME, so N profiles were N
independently acquirable locks and `hermes serve` had no singleton at all.

- `gateway/host_rendezvous.py`: a host lock + a published record (pid,
  creation time, port, protocolVersion, tokenFingerprint, served-profile set)
  under the one cross-profile lock root already in the tree. A record whose PID
  is dead, or alive with a different creation time (PID reuse), is stale and is
  never attached to. Removed on clean exit.
- Gateway: takes the host lock ALONGSIDE the per-home lock and publishes the
  record. Observe-only — a second gateway still starts, and logs the owner.
- Serve: publishes the record plus a 0600 token file on bind, and a second
  `hermes serve` for ANY profile discovers it, prints the live pid/port and
  exits 0 instead of binding a second port. The bare-TCP `_dashboard_listening`
  probe (which proved only that *something* answers) is replaced by
  record-based discovery with creation-time proof; the unified re-exec stays as
  the no-record fallback. `--isolated` and HERMES_DESKTOP=1 opt out.

`spawn-ledger.json` keeps being written unchanged, so the merged Desktop attach
ladder (#117983) keeps working.
2026-09-21 02:51:58 -07:00
ethernet
a537981a90 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-21 05:07:33 -04:00
funky-xamarin
e179056739 fix(doctor): return failure for unresolved findings 2026-09-21 01:57:05 -07:00
ethernet
9f2ba1b74d merge origin/main (779 commits) into ethie/pm-clean
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).
2026-09-21 00:58:39 -04:00
teknium1
eaef5ec717 feat(update): hermes update --list-venv-holders prints the venv guard's holders as JSON, exit 3
Scheduled `hermes update --yes` runs on Windows loop against the venv-holder guard when the
Desktop app relaunches its backend, and the refusal text is the only clue. The new read-only
flag runs the same scan (_detect_venv_python_processes, late-bound through hermes_cli.main)
and the same classifiers (pausable-gateway matcher, _hermes_holder_subcommand) and prints
[{pid, exe, argv, kind}], exiting 0 when the venv is free and 3 when holders remain, so
automation can stop exactly those PIDs and retry. Nothing is terminated; the flag is
handled in the update preflight before the lock, backup, or any mutation. Off Windows the
guard never fires and the list is [].

Fixes #117246
2026-09-20 18:51:36 -07:00
rodricksz4h5
f50c6f43a2 fix(cli): let the early interface probe follow the profile's home
`_suppress_mouse_residue_early()` runs at import time, before
`_apply_profile_override()` sets HERMES_HOME, so the first read of
`display.interface` always saw the default home. The result was memoised
in a cache keyed on nothing, so every later caller — the Termux fast
paths and the TUI launch decision, which all run after the profile is
applied — was pinned to the default home's interface for the whole run.
`hermes -p coder` with `display.interface: tui` in that profile booted
the classic REPL, and the mirror case booted the TUI for a profile that
asked for cli.

Key the cache on the config path it read. The hot path still parses the
YAML once per home, and the first call after the process is re-homed
re-reads the file it should have read.

Fixes #116902
2026-09-20 18:26:14 -07:00