Commit Graph

4162 Commits

Author SHA1 Message Date
John Paul Soliva
3acd949217 feat(desktop): keep-awake follows the turn — a "while working" mode alongside off/always
"Keep computer awake" was a boolean: off, or a machine-global power-save
blocker held for as long as Hermes Desktop is open. That is the wrong shape
for its own stated purpose. An unattended overnight run dies when the
machine sleeps unless the user predicted, before starting, that they would
need the toggle — and a user who did flip it leaves a laptop pinned awake
all week, including the idle hours after the run finished (#101991).

Main already knows, live, whether any turn is in flight:
`mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact
expression that drives stream throttling. This change wires the blocker to
that same signal behind a three-way mode:

  off            never hold the blocker (unchanged default)
  while-working  hold it only while a turn is in flight; release at idle
  always         hold it whenever the app is open (what `on` used to mean)

Both persisted copies migrate in place. The renderer's localStorage boolean
(`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are
read once and mapped `true -> always`, `false -> off`, so an existing user
sees exactly the behaviour they had. The IPC channel keeps its name and
accepts either the mode or the old boolean. An unrecognised saved value is
refused rather than trusted and falls back to `off`.

No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork`
computes the merged picture once and hands it to both consumers, and the
cold-launch apply reads the mode instead of the flag. No trailing delay
between back-to-back turns — a sub-second release cannot let a machine
sleep, and `powerSaveBlocker` start/stop are cheap.

The policy is three pure functions in power-save.ts (parse, read, wanted)
so it is testable without the main process; main only wires them.

(cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
2026-10-03 17:11:39 +05:30
John Paul Soliva
618a4d5dbd docs(sessions): a restored export keeps its pinned, hidden and archive flags
(cherry picked from commit c04df9dbe560c1de862d020f69073c535e021995)
2026-10-03 16:41:37 +05:30
EloquentBrush0x
675bad762a fix(discord): resolve allowlisted usernames by username only, not display name or nickname
_resolve_allowed_usernames resolved a DISCORD_ALLOWED_USERS name through member.name,
member.display_name or member.global_name, and the first match took the slot. Any member of a
shared server could set a nickname or display name to an allowlisted username, get the
resolved ID (DMs, tools, approval buttons) and lock the real user out.

Match the unique account username only, as #125034 did for SimpleX display names. An entry
that matches only a display name stays unresolved and gets a connect-time hint. Tests cover
a copied nickname and a copied display name seen before the real user, and the hint; the
Discord docs now say display names and nicknames are never matched.

(cherry picked from commit 252f0bccab39afe94f0f71254e47a8f785ec1fb0)
2026-10-03 16:37:12 +05:30
itsflownium
5b33f3128a fix(sessions): include protected rows in retention backups
(cherry picked from commit 891575265e657acf5d0b5e6179d11013407a7f8c)
2026-10-03 16:36:00 +05:30
kshitijk4poor
2358519860 refactor(sessions): one export-guard helper and refusal text for CLI and console
`hermes sessions export` and the console's `sessions export` each
re-implemented the per-session max_export_messages loop and carried a
near-identical refusal string, so a wording or limit-resolution fix had to
land twice. SessionDB.assert_exports_safe resolves the limit once and
asserts each id; export_too_large_message is the single refusal text.

The CLI now resolves the limit before the bare-export search_sessions
scan, so `max_export_messages: 0` skips that scan entirely instead of
listing every session only to check nothing. The round-trip test now
asserts the refusal names the config key, and the export-guard docs say
the cap counts every stored message, not just active ones.
2026-10-03 16:32:10 +05:30
Froraut
0f548a9cf9 fix(sessions): address review — guard the JSONL backup with max_export_messages; document it
The backup now loads every stored row, archived generations included,
but `_cmd_export` skipped the per-session `assert_export_safe` guard
that the console export applies. A heavily compacted session with a
small live tail could therefore load an unbounded archive into memory.

All three selection paths (`--session-id`, filters, bare export) now
run the `sessions.max_export_messages` guard over the stored row count
before loading anything, and print the refused session id. Transcript
views keep their display projection and are not guarded, as before.

website/docs/user-guide/sessions.md no longer says JSON/JSONL exports
are live-only. It now says they carry every stored row, including
compaction-archived and rewound or edited-away messages, with their
flags. It also says an import restores those rows as archived, and
that the guard applies to the backup.

(cherry picked from commit 36cdc89e342b7bda4e362294c1bd0ba5d44fed6d)
2026-10-03 16:32:10 +05:30
FroRaut
c84ef16384 fix(cron): unreachable-model re-runs no longer use up repeat slots
A recurring job with a finite repeat count (for example repeat=3, every
24h) lost one repeat slot for each automatic re-run after a fire that
could not reach the model. The failed occurrence counted, and then each
5/15/30-minute re-run of that occurrence counted again, so a half-hour
outage finished a repeat=3 job with zero model calls. With
cron.retry_unreachable off, the same outage costs only the one failed
occurrence.

Cause: cron/jobs.py:_advance_after_run bumps repeat.completed on every
mark_job_run, and the re-runs parked by
cron/unreachable_retry.py:plan_retry go through it like scheduled
occurrences. cron/unreachable_retry.py:will_retry also treated a re-run
on the last slot as the job's final run.

Fix: cron/scheduler.py:_finish_completed_run passes ladder_rung=True
when the dispatched instant is the parked re-run instant (new
is_retry_run, which compares the dispatch snapshot's _scheduled_instant
with the ladder state). mark_job_run then calls _advance_after_run with
count_run=False, and will_retry leaves that run out of its final-run
check the same way. The first failed occurrence still counts, and manual
runs still count.

(cherry picked from commit 27038f7064bb611811efee8c527dddecd4fcb57b)
2026-10-03 16:31:54 +05:30
Froraut
d584836ffa fix(matrix): keep every outbound event under the homeserver's byte cap
The homeserver rejects any event whose canonical JSON is over 65,536 bytes
(M_TOO_LARGE), but Matrix chunked outbound text on characters, 16,000 by
default with a 65,535 ceiling. Each message carries its text twice (body plus
formatted_body HTML), and an edit carries it four times (the "* " fallback
plus m.new_content).

On the default config this caused three failures:

- A formatted chunk of CJK text came to about 95 KB and was rejected, so
  _send_with_retry fell back to a plain 3,500-char truncation.
- A streamed edit near the limit came to about 80 KB even for English, so
  edits failed mid-stream.
- The standalone sender (cron without a running gateway) posted each
  16,000-char chunk as one event, and hit the same failure on non-Latin text.

The fix measures Matrix text in UTF-8 bytes, the unit of the cap:

- The adapter's message_len_fn now returns UTF-8 bytes, so send() and the
  stream consumer's split and progress-edit budgets count the same unit.
- A chunk gets a third of a 45,000-byte content budget, which leaves room for
  the event envelope and E2EE's 4/3 base64 inflation. That makes 15,000 the
  default and the ceiling for max_message_length, so English chunks stay
  about the same size.
- _build_text_message_content drops the HTML when escape-heavy text would
  push the content past the budget.
- An edit that would still be too large keeps its full m.new_content and
  shortens only the top-level fallback, which edit-aware clients never
  render.
- The standalone sender re-chunks on UTF-8 bytes and applies the same HTML
  guard.

(cherry picked from commit cab00c94175955a68ecd4d38c5748d63148ca6be)
2026-10-03 16:31:35 +05:30
John Paul Soliva
001a3d0203 fix(cron): a bare custom base_url takes a stored key only at a configured origin
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.

(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
2026-10-03 16:31:09 +05:30
John Paul Soliva
1903b2b6e3 fix(cron): bind a stored provider key to its configured origin, not its hostname
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.

(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)
2026-10-03 16:31:09 +05:30
John Paul Soliva
978fe337bf docs(env): an OPENAI_BASE_URL-bound OPENAI_API_KEY goes only to that origin
(cherry picked from commit e2a64b83239b22e97a02ed4d7898dbc68c11ee3d)
2026-10-03 16:30:27 +05:30
John Paul Soliva
e6ec4aa9ed fix(auxiliary): the inherited main key comes from the same source as the anchor endpoint
_read_main_api_key_if_same_host read base_url and api_key through the
per-field readers, which fall back to config.yaml field by field. A live
main runtime that is keyless or uses key_cmd therefore paired its own
base_url with config.yaml's key, and an auxiliary task pointed at the live
endpoint received the key issued for config.yaml's endpoint.

The anchor and the key now come from one source: the runtime bound for the
turn, else config.yaml. Documents the origin rule (EN + zh-Hans).

(cherry picked from commit ad3de0cf6fd35ee783c5d999ed98e913d23ffcab)
2026-10-03 16:30:27 +05:30
kshitijk4poor
c4343445c4 fix(email): warn when a listed sender's mail fails authentication
r2 review: moving explicit failing verdicts to debug hid a listed contact whose
own domain breaks DKIM or whose forwarder breaks alignment. Those drops warn
again, with the verdict and no opt-out hint; forged stranger mail under open
access stays at debug. The two operator hints now live in one reason->hint
table behind a single warning call.
2026-10-03 16:30:02 +05:30
kshitijk4poor
a4dadad7cd fix(email): warn when a mis-pinned authserv_id drops an admitted sender
The previous commit moved every non-missing-header auth failure to debug,
which made a mis-pinned authserv_id (the only stamp comes from another
authserv-id) drop allowlisted mail silently at default log levels. That is
an operator misconfiguration, not routine forgery, so it warns again, with
a hint pointing at platforms.email.authserv_id instead of the opt-out.
2026-10-03 16:30:02 +05:30
kshitijk4poor
4c982e9ca4 fix(email): warn with the opt-out hint only when Authentication-Results is missing
Under open access every stranger is granted, so each forged or DMARC-failing
message logged a WARNING telling the operator to disable sender
authentication. Keep that warning (and hint) for the one verdict that points
at a server that never stamps the header; log every other unauthenticated
drop at debug, including an explicit failing verdict for an allowlisted
address. The reason string is lifted to _NO_AUTH_RESULTS_REASON so the
parser and the check share it.
2026-10-03 16:30:02 +05:30
kshitijk4poor
fc8307f35b docs(email): open access no longer skips the authenticated-From check 2026-10-03 16:30:02 +05:30
kshitijk4poor
7b6a6c595f docs(codex): note that Ultra reaches the app-server as codex's own mode 2026-10-03 16:29:34 +05:30
kshitijk4poor
c1541ff4ca docs(codex): reasoning effort and /fast reach the app-server runtime 2026-10-03 16:29:34 +05:30
John Paul Soliva
b932c53264 fix(profiles): the Teams pipeline store and mem0.json are credential stores
teams_pipeline_store.json persists each Graph subscription's client_state, the shared secret
MSGraphWebhookAdapter._verify_client_state checks on every notification. mem0.json is read for
api_key (the documented self-hosted layout keeps the server key there). A named export shipped
both and a distribution could install both; they are on the shared inventory now.

The two invariant tests name both stores, the Teams one through the writer's own filename
constant, so they fail without the inventory entries at both boundaries.

(cherry picked from commit 6e44670c62c077521c90cb270c1ace3dacef6651)
2026-10-03 16:29:29 +05:30
John Paul Soliva
64a073ad88 fix(profiles): every store in a profile home, and Hermes' copies of them, stay out of both boundaries
A named export still shipped credential stores the list did not name, most with the secret intact:
home/ (the subprocess HOME, where git, ssh, gh, npm and skill CLIs such as xurl keep credentials;
.git-credentials and private keys have no suffix the scrub edits), weixin/accounts/ (WeChat bot and
context tokens), proxy/ (iron-proxy CA key and proxy tokens), chrome-debug/ (the /browser connect
Chrome profile), .copilot_jwt.json and runtime/photon-sidecar.json. They are on the list now.

The recovery copies (backups/, state-snapshots/, auth.json.*, .env.bak*, config.yaml writer copies)
move into profile_path_is_private, so a distribution can no longer plant a state snapshot or an
auth.json.corrupt either; before, only the export dropped them. config.yaml copies match the writers'
formats, so a hand-named config.yaml.bak-my-note (user-owned per config_backups) is exported again,
and the export scrub now covers config.yaml.* names so its secrets are redacted like config.yaml's.

The three invariant tests become two: the export test runs the real recovery writers and checks
case-folded names; the distribution test covers plant and replace for the legacy and
distribution_owned layouts.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 2bd63066f48798a849394df131860ed3140d1597)
2026-10-03 16:29:29 +05:30
John Paul Soliva
69613ebc5f fix(profiles): honcho.json is a credential store, and a file cannot replace a store's directory
honcho.json holds the Honcho apiKey and the OAuth grant (oauth.refreshToken,
a key name the export scrub does not mask), so it joins the profile credential
stores: never exported, never installed or overwritten by a distribution.

A distribution payload that ships a regular file where the profile has a
directory holding credential stores (a file named `platforms`, or
`platforms/whatsapp`) was a whole-directory replace that deleted
platforms/pairing and the platform sessions. The update is now refused in the
pre-write guard, before any file is replaced.

(cherry picked from commit 10a3b017c56862b73ed4affffc2786ef65e2dcbe)
2026-10-03 16:29:29 +05:30
John Paul Soliva
8c1adb7d6d fix(profiles): a distribution can neither plant nor replace a nested credential store
PROFILE_CREDENTIAL_PATHS was honoured by the export, but a distribution
install only checked top-level names. A distribution that owns platforms/
(the legacy whole-payload layout, or an explicit distribution_owned entry)
planted its own platforms/pairing approvals and platforms/whatsapp/session
into the installer's profile, and replaced the installer's on update.

profile_path_is_private() / profile_path_contains_private_store() match
root-relative paths case-folded. The install skips a private path, refuses
it in distribution_owned, and merges any ancestor of one instead of
replacing it. The export uses the same predicate.

Also lists the legacy single-user Google Chat token file (it carries the
OAuth client_secret, which the text scrub does not redact), the legacy
Google Chat PKCE directory, and the Google Meet node RPC token.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 395959f4ae4ca2c92a45199709f54ccfd9fd1724)
2026-10-03 16:29:29 +05:30
John Paul Soliva
fc33275f22 fix(profiles): keep Hermes' recovery copies of credential stores out of named exports
A named-profile export dropped the stores themselves but shipped the copies Hermes writes of them: backups/ (pre-update zips of the whole home, config.yaml copies), state-snapshots/ (per-profile update snapshots with pairing stores), auth.json.corrupt, and the config migration's .env.bak-<stamp> / config.yaml.bak-<stamp>. None of their suffixes is one the text scrub edits. The default export's root allow-list already omits all of them.

Also lists feishu_comment_pairing.json beside the other pairing stores, and mirrors the credential-store wording in the zh-Hans distributions page.

(cherry picked from commit 0a24aeaaf55279bfa9af4cc9f5e80873fb611020)
2026-10-03 16:29:29 +05:30
John Paul Soliva
9c18f8c28c fix(profiles): keep every credential store out of profile exports
Named-profile exports dropped only auth.json, .env and bot-desktop, so
other credential stores Hermes reads from a profile home were archived
as-is: .op.env (1Password service-account token), npmrc, OAuth token
files, mcp-tokens/, vault/, browser profiles and platform sessions.
.op.env and npmrc also escape the text scrub pass.

Add PROFILE_CREDENTIAL_PATHS (root-relative) as the one list, exclude it
from named exports, and fold its root entries into
DEFAULT_EXPORT_EXCLUDE_ROOT so distribution installs treat them as
user-owned.

(cherry picked from commit fb8ce7a9999049c1bc11aaf7a3d9b2190d8ed1aa)
2026-10-03 16:29:29 +05:30
John Paul Soliva
c30ec25ec7 fix(proxy): judge Sec-Fetch-Site and compare Origin by host and port
Browsers send no Origin on a GET, so a cross-site page (or a DNS-rebound
page on a wildcard bind) could still reach /v1/models with the operator's
credential. Sec-Fetch-Site, which a page cannot forge, now refuses those.
The Origin check compares the parsed host and port instead of the raw
string, so an Origin that omits the default :80 or spells an IPv6
address differently is matched the way a browser means it.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 72bf4e9c34f6c6200658e42345f775291462697c)
2026-10-03 16:29:17 +05:30
John Paul Soliva
b7ea7a7759 fix(proxy): refuse every browser Origin on a wildcard bind
A wildcard bind accepts any Host name, so a DNS-rebound page reaches it
with Origin equal to its own Host and the same-origin rule let it through
to the operator's credential. A wildcard listener has no origin of its
own, so an Origin-bearing request is refused there; API clients without
Origin keep working. Also treat the absolute form localhost. as localhost
and detect expanded IPv6 unspecified binds as wildcard.

(cherry picked from commit 5ce7aac4d7e4d160b2a6447f0442e747f489d72f)
2026-10-03 16:29:17 +05:30
John Paul Soliva
4b1ff38e51 fix(proxy): accept only local Host names and no foreign browser Origin
hermes proxy attaches the operator's subscription credential to every
forwarded request but accepted any Host header and any Origin. A
loopback or specific-IP bind now serves only its own Host names, and a
request whose Origin is not the proxy's own is refused with 403.
Non-browser clients send no Origin and are unaffected; a wildcard bind
keeps accepting any Host.

(cherry picked from commit 3cf3870cfc2a795e439b1a8c0c53424f79c24505)
2026-10-03 16:29:17 +05:30
teknium1
c8301ea6c9 fix(local-runtime): Vulkan/HIP launches subtract what other programs hold on the card
launch_budget read free memory only from nvidia-smi, so a discrete AMD/Intel card launched at its
capacity window regardless of other programs' VRAM, the case that silently pages on Windows. It
now asks the engine's device probe afresh at launch (the polled view stays cached); the driver's
free count includes other processes (live: 16.2 GB held elsewhere read as 8.2 GB free of 26.0).

Docs: auto is a preference with the platform's pinned fallbacks, not an unconditional CUDA pick.
2026-10-03 02:25:26 -07:00
teknium1
2e6924bd10 fix(local-runtime): size AMD/Intel GPUs by the engine's own device type
The previous commit budgets any Vulkan/HIP device that `llama-server
--list-devices` prints as a discrete card. That output carries no
integrated-vs-discrete verdict, so an iGPU (Intel ADL-N reports 11.8 GB of
a 16 GB machine's shared memory) would be counted twice: once as VRAM and
again as system-RAM spill.

Ask the installed engine's ggml library instead, in a bounded child
process: ggml_backend_dev_type() is GPU for a discrete card and IGPU for
an integrated one (Vulkan reads it from VkPhysicalDeviceType). llama.cpp
places layers on discrete devices whenever one exists and falls back to an
integrated GPU only otherwise, so the budget follows the same rule:

- discrete device -> its own memory, system RAM as spill (uma=False)
- integrated GPU, no answer, CPU/Metal -> RAM-as-unified, unchanged

The probe runs only when no NVIDIA device answered and the configured
engine is Vulkan/HIP, and is cached per engine binary (the hardware
endpoint polls every few seconds). The CUDA --list-devices fallback for the
NVIDIA unified-pool quirk is left as it was on main.

Co-authored-by: Belaquaa <eee_pokkk@mail.ru>
2026-10-03 02:25:26 -07:00
John Paul Soliva
0ef09aed76 fix(dashboard): learning-node and skill editor writes honour ?profile=
DELETE/PUT /api/learning/node, POST /api/skills and PUT /api/skills/content
read the target profile from the JSON body only. A shared-backend Desktop
(global remote, registry connections) scopes these calls with ?profile= and
sends a body without one, so the star map's delete archived, and its edit
rewrote, the same-named skill of the backend's launch profile while the
selected profile's skill stayed as it was.

Take the body profile first and fall back to the query, as
PUT /api/skills/toggle and the tools routes already do.
2026-10-02 22:34:58 -05:00
Brooklyn Nicholson
80aab939c8 fix(docker): keep per-process temp mounts out of the reuse fingerprint
A symlink anywhere under skills/ makes _safe_skills_path serve the skills
mount from a fresh mkdtemp copy per process, so hashing its host path into
hermes-environment made the label differ across processes: the persistent
container was never reused and every session started fresh, losing the
long-lived-container contract (installed packages, filesystem state).

Canonicalize tempdir-sourced mount specs to a stable placeholder that
keeps the container path; real mounts, image and HERMES_HOME still force
a fresh container on change.

Follow-up to #131895.
2026-10-02 21:43:34 -05:00
ahamSel
b829f2af8c fix(tools): scope Docker reuse to immutable config 2026-10-02 20:27:05 -05:00
fangliquanflq
942dd4fb0f test(hooks): cover crashed fail-closed process 2026-10-02 20:18:26 -05:00
teknium1
f6f6befbcf fix(fallback): a bare llama.cpp alias resolves only to the live managed server
Completes the managed-endpoint rung for every caller that resolves a bare
llamacpp / llama.cpp / llama-cpp alias outside the main ladder:

- The rung moves from _resolve_custom_branch to the resolve_provider_client
  entry and to _get_cached_client, via agent/auxiliary_local_runtime.py.
  The `main_runtime is None` gate is gone: the cached auxiliary path always
  passes a normalized dict, so the rung never ran there, and a task pinned to
  llamacpp inherited a foreign main runtime's URL instead.
- No running server now means (None, None) — the same answer the main ladder's
  _resolve_llamacpp_runtime gives — instead of continuing into
  _try_custom_endpoint / _resolve_api_key_provider, which posted the local GGUF
  slug (and, for compression, the conversation) to whichever cloud provider held
  a key (#119227) or to the primary's own endpoint. OPENAI_BASE_URL is not
  consulted either: the alias table entry (b7e0d715be) exists for explicit
  base_url lanes, and the main ladder never reads it for a bare alias.
- The cached client keys on the live endpoint (base_url + key), read at call
  time, so a server restarted on a new port/key gets a fresh client and a
  stopped one resolves to nothing instead of a stale client.
- The endpoint is resolved with the calling profile's config (load_config() at
  call time), matching the main ladder; a configured provider entry named like
  the alias still wins.
- Vision keeps the raw provider name for its final resolve so the alias is not
  flattened to bare `custom` first.
- The aux "unavailable" message names the stopped local server instead of an
  invented LLAMACPP_API_KEY.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
2026-10-02 16:44:56 -07:00
ethernet
5fe12f373e docs: drop source channels and install identity section; main is the only source channel
Remove the "Source channels and install identity" section from Updating &
Uninstalling, and stop documenting stable/canary as selectable for source
installs. `main` is the only valid source channel right now, so the
updating, CLI reference, and stable-releases pages say that instead.
2026-10-02 18:50:11 -04:00
ethernet
d6bb42e8ba feat(activate): add activate.fish for the fish shell
`activate` is bash syntax, so fish users had no way to take on the dev
environment. `activate.fish` does what `activate` does: snapshots the keys it
will change, applies the composed env, defines a `hermes` function that
refuses outside the worktree, adds the prompt prefix, and defines
`deactivate`. It shells out to the same bash library, so composition is not
reimplemented.

`pm.environments --format fish` emits the env as `set -gx` statements.
fish's read-only names (`PWD`, `SHLVL`, `_`, ...) are skipped because
`set -gx PWD` aborts the script, and values named `*PATH` stay lists.
`hermes_compose_env` takes the dialect (`sh` or `fish`) as its second argument.
2026-10-02 18:43:30 -04:00
ethernet
b4d5092d3f refactor(activate): split interactive activation from running a command in the env
`activate` doubled as the thing scripts sourced non-interactively, so it
carried `--` argument guards and every caller had to tolerate its prompt and
`hermes` function. Composition (setup sync, bootstrap Python, pm env) now
lives in scripts/_activation.sh, and there are two consumers:

- `activate`: interactive, reversible (`deactivate`).
- new `scripts/run-in-hermes-env CMD...`: applies the env to one process and
  execs into it. Replaces `scripts/_hermes-python`; every Python script's
  shebang and `scripts/run_tests.sh` (which re-executes itself under it,
  however it was launched) use it.

`pm.environments --format sh` emits the env as shell exports, so the inline
JSON-to-export Python is gone from `activate`. An install that did not build
the test environment now reads as stale for every consumer.

Tests are split along the same seams: activation support fixtures, bash/ps1
activation, env-script quoting, and the runner's staleness.
2026-10-02 18:15:08 -04:00
Mark S.
a6e75acb0d Merge pull request #131697 from unsupportedpastels/fix/128974-uninstall-dry-run
fix(cli): honor --dry-run in uninstall --gui and keep shared workspace node_modules
2026-10-02 17:44:26 -04:00
teknium1
35eacd248a fix(update): keep git's own lazy-fetch pack fold on in a partial clone
#131679 set maintenance.auto=false in the treeless install checkout to stop the
commit-graph lazy-fetch loop (#127711). That also switched off the post-fetch
`gc --auto` that git <= 2.53 runs after every lazy fetch, so packs piled up
between updates and only folded on a manual `hermes update` / `--check`
(nothing runs `--check` automatically).

The loop needs a commit-graph write, and gc.writeCommitGraph=false +
fetch.writeCommitGraph=false already stop gc and fetch from writing one.
maintenance.commit-graph.enabled=false closes the last writer (a commit-graph
maintenance task, e.g. from a user's global maintenance.strategy) while
leaving automatic maintenance, and its gc fold, on.

Checkouts that already got maintenance.auto=false from the first cut are
converged: the next update/--check unsets exactly that value.

Measured on a real tree:0 clone with a Bloom commit-graph, 100 lazy fetches
after one updater run:
  git 2.53.0: main 101 packs, this change 1 pack, 0 commit-graph writes
  git 2.55.0: 101 / 101 (stock 2.55 maintenance does not fold), 0 writes
2026-10-02 14:17:41 -07:00
teknium1
4e3fcd5cd7 fix(update): give the lazy-fetch pack fold room to finish and say when it runs out
The fold was killed after 120 s on the assumption it takes "well under a second". That holds
for ~100 small packs, not for the checkouts it exists for (#129712: 2,475 packs / 39 GiB): those
need a full repack, and a killed fold restarted from scratch on every update without a word,
so the worst-hit installs never converged.

- Bound raised to 20 minutes (measured: 750 packs / 530 MB of hermes-agent history fold in
  5.8 s at 7.4 cores; a 39 GiB pack dir on a slow disk is a different order of magnitude).
- consolidate_lazy_fetch_packs returns None on timeout; the shared update_cmd_check reporter
  (used by both `hermes update` and `hermes update --check`) prints the hand-fold command.
- on_fold_start announces a fold that will actually run (pack count past gc.autoPackLimit), so
  a multi-minute first fold is not a silent hang.
- Test fixture disables auto-maintenance: each lazy fetch's detached gc otherwise raced the
  test and folded the packs itself.
2026-10-02 11:41:19 -07:00
ethernet
6880ffc1af fix(update): make the partial-clone pack fold and maintenance keys work together (#129712, #127711)
The two salvaged commits above disagree: #127721 persists gc.auto=0, which
turns the fold's `gc --auto` into a no-op (106 packs stay 106 on git 2.50.1,
2.53.0 and 2.55.0), and the fold itself is exposed to the loop #127721
guards against. Probed on all three gits against a blobless clone whose
commit-graph carries changed-path (Bloom) data:

- gc, `gc --auto`, `maintenance run --task=commit-graph` and
  fetch.writeCommitGraph each lazy-fetch the trees of every commit the graph
  has not seen (30 unseen commits: 30 fetches, 30 packs), and every lazy
  fetch spawns `git maintenance` again. gc.writeCommitGraph=false stops it
  (gc: 31 packs -> 1).
- Without a Bloom graph none of that happens, so the keys are a guard, not a
  fix for every partial clone.

Changes:
- keys: maintenance.auto, gc.writeCommitGraph, fetch.writeCommitGraph; gc.auto
  stays default. Idempotent (no rewrite when already set), `--local`, hidden
  console on Windows. install.sh and install.ps1 set the same three keys.
- consolidate_lazy_fetch_packs applies the keys before the fold, so installs
  that predate the installer change converge on their next update check.
- the fold runs under bounded_probe_run: subprocess.run(timeout=) kills only
  `git gc` and leaves `pack-objects` running (reproduced), and with
  GIT_NO_LAZY_FETCH.
- dropped the post-gc mark_unmarked_packs_promisor: gc keeps the .promisor
  marker on 2.50.1, 2.53.0 and 2.55.0.
- docs: updating.md troubleshooting entry.
2026-10-02 11:41:19 -07:00
ethernet
1a4508e2af Merge pull request #131691 from NousResearch/fix/release-abandon-cancels-runs
release.py abandon now cancels the abandoned rc's in-progress runs
2026-10-02 14:12:44 -04:00
itsflownium
f9fec19d96 fix(gui): keep shared workspace node_modules on uninstall --gui
`hermes uninstall --gui` deleted hermes-agent/node_modules on the premise
that the workspace root only carries Electron. The root package.json is
an npm workspace (apps/*, ui-tui, ui-tui/packages/*, web, tests-js), so
the root node_modules holds the hoisted dependencies of the TUI and the
dashboard too, while Electron itself resolves from
apps/desktop/node_modules, which is still removed.

Stop treating the workspace root as a GUI artifact, list it under "Kept
intact" in the removal plan, correct the docstring and the desktop guide,
and add a regression test that a real `--gui --yes` removal keeps agent
data and the shared dependencies while removing every GUI artifact.

Refs: #128974
Salvaged-from: #131147
2026-10-02 18:06:40 +00:00
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
unsupportedpastels
c6293dcd57 fix(cron): preserve suspend credit across idle sampling
Keep newly observed sleep credit until the following poll because the supplied idle duration can predate that sleep. Extend both contributor regression tests across the sampling boundary and qualify the platform guarantee.
2026-10-02 13:50:23 -04:00
KJH
2f267112ce fix(cron): stop counting host sleep as job inactivity
Add AwakeIdleMeter, which reads the wall clock and time.monotonic() on
each watchdog check. monotonic pauses during sleep on macOS
(mach_absolute_time) and Linux (CLOCK_MONOTONIC), so the gap between the
two clocks is time spent asleep, and that sleep is not charged as idle
time until the agent records new activity. The cron inactivity watchdog
and the turn liveness watchdog both use it, because on main both guard a
cron run with a 600 s wall-clock bound. A job that is really idle for
600 s awake is still killed.

Fixes #131523

(cherry picked from commit 07d9ddc24736e243d72327ff90a24f95e86119d8)
2026-10-02 13:50:23 -04:00
ethernet
59dc1ad5ff fix(release): abandon force-cancels the attempt's in-progress runs
`release.py abandon` deleted the draft and wrote the marker but left the
attempt's Stable Release run going. A live run keeps building, and with
autopublish on it can publish the draft that abandon just cleared.

abandon now lists the claim ref's runs of stable-release.yml and runs
`gh run cancel <id> --force` on every one that has not completed. This
happens before the draft is deleted and before the marker is written: the
marker removes the attempt from the outstanding set, so a cancel failure
after it could never be retried. A published release is still refused
before anything is cancelled. The `delete` callable is renamed `execute`
since it now runs cancels too.
2026-10-02 13:43:18 -04:00
teknium1
83097ba008 fix(desktop): keep the hermes://skill/install deep link through the catalog revert
The plugin deep link (hermes://plugin/install?catalog=) and the skill
deep link are separate features: one per kind. Reverting #119381's card
catalog should not leave skills without a link, so this restores the
skill route on its own:

- Desktop: skill-deeplink-install store, the skill route in
  deeplink-routes, its use-desktop-integrations wiring, and the
  confirm-store onConfirm/details/busy/done phases its dialog uses
  (restored from main with their tests). Its nine strings move from the
  deleted catalog.* namespace to skillDeepLink.* (en + the six locales
  that translated them).
- Website: Skills Hub "Install in Hermes" button, plus the
  source-qualified installIdentifier from extract-skills.py so the link,
  the embedded picker and the CLI command all name the exact skill
  (apps/shared/src/catalog-install.ts keeps only the skill helpers).
- Docs: skills.md "Install from the website" section, desktop.md pointer.

The card catalog UI stays reverted.
2026-10-02 10:29:28 -07:00
teknium1
6cb7a64e59 revert: Skills and Plugins card catalog browser, third landing (reverts #119381)
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.

Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
  reels/masonry, install switch) and skill deep-link install
  (hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
  locales/_keys.desktop.json); the two i18n pack tests and the keys
  test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
  extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
  5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
  9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
  the catalog half of c13232e90c / f265273347.

Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
  (the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
  7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
  the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
  a provenance pill only for agent/hub skills, since the pre-#119381
  labels have no `external` entry.
2026-10-02 10:29:28 -07:00
teknium1
0a374d1674 docs: Supermemory installs from the plugin catalog
memory-providers.md gets a "Plugin catalog" note on the Supermemory section,
`hermes plugins install supermemory` in its setup, and a "Migrating from
bundled Supermemory" section that mirrors the Hindsight one. memory.md,
overview.md, integrations/index.md, cli-commands.md and the developer guide
no longer list Supermemory as bundled.
2026-10-02 05:10:06 -07:00