The guide still told users to "leave authserv_id unset" when their
server's host varies, which now drops every message that needs an
authenticated From:. State that the pin is required, how to find it
(the token before the first ';' of the topmost Authentication-Results
header your server writes), what to do when it seems to vary (RFC 8601
authserv-id names the administrative domain, so configure a stable one
and pin it), and that the only alternative is the explicit
require_authenticated_sender: false / EMAIL_TRUST_FROM_HEADER=true
opt-out. Mirror the reference tables, plugin.yaml prompt and zh-Hans
translation.
Co-authored-by: JiuYue0820 <287222957+FirmaSpring@users.noreply.github.com>
Trust only the topmost Authentication-Results field from the explicitly configured receiving MTA. Fail closed for allowlisted senders when no exact pin is configured, preventing injected or lower matching headers from authorizing a sender.
(cherry picked from commit e013fda3facc005e8f4d5f81e20c2d21aee698df)
#132201 stopped profile export shipping .ssh/.aws/.gnupg/.kube/.envrc, but
the other credential dirs the file tools already write-deny still shipped:
a skill dir copied from a home carries .docker/config.json (registry auth),
.azure/accessTokens.json, .config/gh/hosts.yml (GitHub token) and
.config/gcloud/ (ADC / credentials.db), and the default-profile export plus
named exports archived them verbatim (binary DBs skip the redact pass).
The home-relative credential dirs now live once in
agent.file_safety.HOME_CREDENTIAL_DIRS; build_write_denied_prefixes and the
profile export (_OS_CREDENTIAL_STORES) both derive from it, so the write
denylist and the export filter cannot drift. Nested stores (.config/gh) are
matched on trailing path components, so only .config/gh and .config/gcloud
are dropped and the rest of .config still ships. .config/gcloud is included
for parity with file_safety. Distribution installs treat the same paths as
installer-owned via PROFILE_CREDENTIAL_PATHS.
Co-authored-by: Keiran Haax <196126709+keiranhaax@users.noreply.github.com>
The wizard's invite integer and the Discord docs' Recommended row carried
Use External Emojis (1<<18), a permission the adapter never exercises (it
only reacts with unicode emoji) and that no permission list in the docs
names. The voice-mode page and the zh-Hans Discord page already used the
correct integers, so the pages disagreed with each other.
- onboarding.INVITE_PERMISSION_BITS: drop Use External Emojis
(wizard invite 309241171008 -> 309240908864, matching voice-mode.md)
- discord.md: Recommended 309238025280 -> 309237763136; the Minimal row
names Embed Links (117760 already includes it)
- .env.example and the zh-Hans table follow
Co-authored-by: Emil <etoegersen@gmail.com>
While the plugin was missing and the catalog or GitHub unreachable, every `hermes chat` process
paid a live-catalog fetch (+5.9 s on a blackholed network) and printed the raw multi-line git
stderr of the failed clone.
A failed automatic install (catalog miss, failed install) touches
<home>/cache/left-core-<plugin>.failed; agent and gateway start skip the row, before any network,
while that stamp is under an hour old. `hermes update` ignores the stamp and always retries. The
failure notice is one line: git's own fatal:/error: line when the error carries raw git output,
else its first line.
The catalog module already serves a fresh cache (6 h) and falls back to its stale/in-tree copy on
failure; pre-resolving offline would not save the round trip, because the install resolves the
entry through the live catalog again, so the gate sits before both.
The left-core migration's only gate was "plugin dir missing + Home Assistant in use", so after
`hermes plugins remove homeassistant` every agent start, gateway start and `hermes update`
reinstalled and re-enabled it (~7.5 s each).
The automatic install now happens at most once per home and row: `_left_core_installed` in
config.yaml is written (in the same write as the toolset-scope marker when the plugin was already
there, right after a successful install otherwise) and only once the plugin dir exists in that
home. A marked row is never installed again; a catalog miss, failed install or lazy-installs-off
start writes nothing, so those homes keep retrying. Profile clones copy plugins/ with config.yaml,
so a clone's marker always matches what its source had.
Review findings on the onboarding wizard:
- A token pasted with curly quotes reached the HTTP header and crashed with
UnicodeEncodeError; it now goes through the same non-ASCII stripping
save_env_value applies, before the Discord check.
- Reconfiguring replaced DISCORD_ALLOWED_USERS with the owner alone; the
wizard now starts from the existing entries and only adds.
- Hermes always requests Message Content, so a disabled intent makes Discord
refuse the connection (close 4014), not deliver blank messages. Wizard,
.env template and docs now say so.
The setup test records every write, so a rejected token appearing in any
write fails it, and uses ASCII fixture tokens now that the wizard sanitizes.
Discord was the hardest gateway to set up: the .env template had no Discord
section at all, the wizard saved whatever was pasted, and users had to find
the Message Content Intent toggle, build an OAuth2 invite URL by hand and turn
on Developer Mode just to learn their own user ID.
The wizard now reads GET /applications/@me with the pasted token: a rejected
token is re-prompted instead of saved, an intent that is off gets a direct
link to the toggle and a re-check, the invite link is printed with the
permissions the adapter uses, and the allowlist defaults to the application's
owner (or accepted team members). Offline, the token is still saved with the
manual intent reminder. The wizard moves from the 7k-line adapter into
plugins/platforms/discord/onboarding.py.
website/scripts/generate-skill-docs.py output for the four skills whose
SKILL.md changed in this stack; docs-site-checks fails on generator drift.
Refs #132504
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.
Gaps in the unanswered-/health handling, found in a second review:
- _probe_bridge_health swallowed a timeout while reading the JSON body
and reported an unparseable 200, so a bridge that sent headers but
stalled on the body still reached _kill_port_process. Timeouts now
propagate from either phase.
- The guard listed listeners with the POSIX-only lsof/ss scan, which
finds nothing on Windows, so Windows fell through to the netstat-based
kill. Both now use _port_listener_pids(); if listing fails, nothing
is killed.
- _standalone_send treated a non-200 /health as an older bridge and
sent anyway. It now sends nothing.
- Docs named a nonexistent `hermes whatsapp send`; the command is
`hermes send --to whatsapp:<chat_id>`.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
Two gaps left by the session check, both found in review:
- connect(): a port holder that does not answer /health in time fell
through to _kill_port_process, so another profile's busy bridge was
still killed. No answer is no ownership evidence: after this profile's
own bridge is reaped by its pidfile, a remaining listener is left
running and connect() fails with the retryable
whatsapp_bridge_unresponsive.
- _standalone_send (hermes whatsapp send, cron delivery) posted to the
port without asking whose bridge it was, so a profile without its own
bridge sent from another profile's WhatsApp account. It now reads
/health before every send and refuses one whose session is another
profile's. A bridge without the field (older bridge.js) is still used.
The session dir is resolved by one helper for the adapter and standalone
sends, so both compare the same absolute string.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
Follow-up to the cherry-picked session handshake, rebased onto the
multiplex bridge ownership that landed in #132518:
- connect(): the foreign-session fatal sits ahead of main's
secondary-aware pidfile/port reaping, so neither path signals a bridge
that serves another profile's session.
- Drop the separate "no session -> restart" branch. A bridge without the
field runs an older bridge.js, so its scriptHash already marks it
stale and it restarts through the existing path.
- Inline sessionIdentity() as path.resolve() in /health and drop its
node test, which only exercised path.resolve and no CI lane runs.
- Collapse the four adapter cells into one parametrized invariant (own
session adopted; another session never adopted or killed, connected or
disconnected). The read-receipt test now reports its own session, so
it still restarts for the read-receipt reason rather than a missing
field.
- Docs: fold the separate-gateway guidance into the existing "Multiple
profiles" section instead of a second multi-profile section.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
Two profiles on one host default to the same bridge_port (3000) but have
profile-scoped session paths. _reuse_running_bridge adopted any connected
bridge whose scriptHash and read-receipt config matched, so profile B
adopted profile A's bridge and served A's session; on any mismatch,
connect() fell through to the pidfile/port kill path and killed A's
running bridge.
Contract, keyed on a new /health `session` field (normalized absolute
session dir via bridge_helpers.js sessionIdentity):
1. session present and EQUAL to the adapter's own normalized session
path -> today's logic unchanged (adopt on hash + read-receipt match,
else stale/restart).
2. session present and DIFFERENT -> FOREIGN bridge: not adopted, not
killed. connect() sets a fatal error naming the port, the foreign
session path, and the fix (platforms.whatsapp.extra.bridge_port, one
distinct port per profile).
3. session ABSENT (bridge predates the handshake) -> treated as stale
and restarted, exactly like an absent scriptHash today.
Normalization uses Node path.resolve / Python os.path.abspath - absolute
and `..`-normalized WITHOUT symlink resolution. fs.realpathSync /
Path.resolve() would resolve symlinks asymmetrically (macOS
/tmp -> /private/tmp) and produce false foreign verdicts.
(cherry picked from commit 7e8418678e91fa4bc69d4b4fa6faedd66f2a80e5)
`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.
b11146 ships ubuntu-cuda-13.4-arm64 (+ cudart), so llamacpp-cuda gains
linux-arm64, auto resolves NVIDIA arm64 Linux to CUDA, and the desktop's
localModels flag ships on Linux arm64 too.
Every upstream Linux llama.cpp build (CPU included) links the system
libgomp, which minimal hosts (WSL, containers) lack, and a normal install
never touches the system package manager. Each Linux engine now pins
Ubuntu 24.04's libgomp1 .deb (glibc 2.38, the floor upstream already sets)
and stages libgomp.so.1 beside llama-server, where the $ORIGIN RUNPATH
finds it; the artifact mirror covers the pool URL after 24.04's EOL.
DebPackage's ar+tar unpack becomes pm.package.unpack_deb and reads zstd
data.tar (Ubuntu's default) with the stdlib codec PM's 3.14 runtime has.
Pre-PM engines on Linux carry only llama.cpp's own archives, so adoption
maps their manifests to those and records them as outdated (usable now,
update offered) instead of failing to match.
Verified on an RTX Spark N1X (WSL, Ubuntu 26.04, no system libgomp):
ensure_engine() for cuda, cpu and vulkan through PM's real download/stage/
verify path; ldd resolves libgomp from the entry; llama-server serves
completions (CUDA0 at 445 tok/s). linux-x64 cpu/vulkan staged through the
same path with no NEEDED library missing beyond glibc/libstdc++/OpenSSL 3.
hermes -z ends its turn but leaves the Relay root conversation open:
run_conversation keeps the root resumable and the one-shot exit is
os._exit, which skips the atexit finalizer every other CLI path relies
on. Exported Relay telemetry showed session start + turn start/end with
no session end, and on_session_finalize never fired for one-shot runs.
_run_agent now keeps the session id at turn entry (compression can
rotate agent.session_id without opening a second root) and _close_agent
calls finalize_session for it after the background-completion linger and
before memory/agent teardown, on failure as well as success.
Co-authored-by: Soheil Fakour <soheil.fakour@gmail.com>
A secondary treated a bound port as its own whenever its pidfile named a
live bridge, without checking that bridge listened on that port. After a
crash left the old bridge alive and bridge_port was pointed at a port
another profile's bridge held, the secondary probed and adopted the
foreign bridge (script hash and read-receipt config do not distinguish
phones). bridge.pid now records the spawn port on line 3; ownership
requires pid, start time and port to match. Pidfiles without a port fail
closed for secondaries; the default path reads lines 1-2 as before.
A profile that recorded a port as a secondary and later launched its own
gateway started its bridge on 3000 while `whatsapp send` read the record.
The launch adapter now resolves its port like standalone send (explicit,
then record, then 3000), so profiles without a record are unchanged.
gateway/AGENTS.md no longer lists the WhatsApp bridge as default-only
shared ingress.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
A secondary allocated its bridge port by bind probe alone. A sibling whose
bridge is down (gateway stopped, profile disabled) leaves its recorded port
unbound, so a newly paired profile took it and both records claimed one port;
the second to start hit the non-retryable whatsapp_bridge_conflict on every
restart. Allocation now skips ports another profile's record claims.
A non-numeric record surfaced int()'s "invalid literal" as the fatal text;
it now names the record file and says to delete it.
The secondary connect tests stub find_node_executable: connect() now ensures
Node before anything else, so without it they exited before the secondary path.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
Co-authored-by: Enough1122 <Enough1122@users.noreply.github.com>
febd2af391 skipped WhatsApp on every secondary profile under multiplex on the
premise that "a secondary has no credential of its own to bring" because the
bridge is "one authenticated session tied to a single phone number". The
session directory is home-relative (`platforms/whatsapp/session` under the
profile's own home), so each profile already owns its own paired session.
What actually collided was the bridge port, 3000 for everyone, and the
adapter's startup path that kills whatever holds that port before spawning.
A fleet running one phone per profile lost WhatsApp on every profile but the
launching one, with a single INFO line to say so.
The credential gate for WhatsApp is now the profile's own paired session
(`creds.json` in its session directory), evaluated in that profile's scope.
An unpaired secondary is reported as `whatsapp_unpaired` with the pairing
command, once, no retry loop. A paired secondary gets an adapter of its own.
Port ownership without any probe of another profile's bridge
(`plugins/platforms/whatsapp/bridge_ownership.py`): an explicit
`platforms.whatsapp.extra.bridge_port` wins; otherwise a secondary takes the
first free port in 3001..3099 and records it in
`platforms/whatsapp/bridge_port` so it is stable across restarts. A
secondary never calls `_kill_port_process`, never adopts a running bridge
and never signals a pidfile process it did not spawn; a bound port or a
live prior process is a fatal for that profile only, naming the port and
the config key. The default profile's startup path is unchanged.
Out-of-process sends (`hermes -p X whatsapp send`) read the same record so
they reach the profile's own bridge.
Relay stays process-level shared ingress; its skip and remedy are untouched.
(cherry picked from commit f96c53df65cff45af2488bd13f22225ce1aec743)
install --force --ref is the documented way to move a pinned plugin, but it replaced the whole plugin directory: untracked and git-ignored files (plugin state, local config) were deleted and tracked edits were lost with no copy. A reinstall from the source the plugin was installed from now carries user files like update does and copies tracked edits to plugins-backup/. A different source still starts clean.
The skills page said local skills win over external_dirs in one place and
listed project > local > external_dirs (no create_dir) in another, while
skill_view refused both. Document the single order every surface now uses
(project > local > skills.create_dir > external_dirs), how same-directory
duplicates are listed and loaded, and that plugin:skill names are unaffected.
Maintainer decision on the FASTTIER review: drop the "Daybreak/cyber aliases
offer no speed tiers" rule this stack introduced. It flipped
model_supports_fast_mode to False for gpt-daybreak-* and gpt-5.6-cyber, which
also hid /fast and the Desktop Fast option for models that accepted Priority
before. Restore the pre-stack predicate verbatim, make the kept invariant
assert Daybreak is fast-but-not-ultrafast (the proxy route stays the no-speed
case) and remove the Daybreak sentences from the speed docs.
Ultrafast collapsed into ordinary Fast across the stack: the catalog only
reported `fast`, presets and drafts stored a boolean, session.create took
only `fast`, and Settings had a single Fast switch, so an Ultrafast default
read as Fast and a new chat could lose it.
- Inventory reports `ultrafast` per model from the same route-aware
resolver that builds the request; Daybreak aliases offer no accelerated
speed (their priority request is ignored).
- session.create accepts an exact `service_tier` that wins over the legacy
`fast` flag; unknown tiers are rejected.
- Desktop keeps the exact tier through presets, draft creation, session
options and labels; an unsupported remembered tier falls back to
Standard, and failed edits reconcile to the confirmed state.
- Settings → Model → Main model offers one Speed choice (Standard / Fast /
Ultrafast) on models that support Ultrafast; others keep the Fast switch.
Split out of #129251 (which keeps Daybreak).
(cherry picked from commit 4e332fb850c989ef9f34bd70aaca174042beecc6)
Every failed auxiliary model_route row read error_class=unknown because the
aux logical scope only carried outcome/model/provider and the projection
hard-coded unknown for any non-success. Every exception escaping call_llm,
including Hermes' own aborts (Ctrl+C / /stop mid-compression, interrupted
aux streams, task cancellation), was recorded as failed.
- classify the error that ended an aux call (and the last one a successful
call recovered from) with agent.error_classifier; HTTP-200 bodies that
carry a provider error object are classified from that object
- Hermes aborts (non-Exception BaseExceptions, InterruptedError) close the
call as cancelled/none, matching the contract
- MoA aggregator streams report the same way
- aux calls that finish under the turn's own live scopes (title generation
runs beside the turn) were never counted: the scope could not be popped
and the turn-close drain closed it as an orphan without a route. The
drain now closes it with the stored real result.
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.
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
session.save wrote the in-memory history, so turns in-place compaction archived were missing from the
snapshot and sessions.max_export_messages never applied, unlike CLI and messaging /save json since the
#130339 fix. The two existing call sites' cap check + export + undone-row filter move into
hermes_cli.session_export.load_save_snapshot; the CLI, the messaging gateway (one off-loop hop) and the
tui_gateway handler all call it. The TUI handler reads the session's own profile db under that profile's
config, refuses an oversized session with rpc error 4131 (the same message as the CLI), and keeps the
in-memory export as the fallback when no stored row exists. Turn-isolated sessions run the same handler on
the compute host; the host now forwards the handler's error code so the refusal arrives as 4131, not 5011.
Refs #130339
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
plugins.isolation is profile config, so a profile (or its agent) can set it
back to in_process. The managed scope already beats profile config and makes
`hermes config set` refuse, and the isolation reader goes through that
overlay; document it as the operator lock and pin the contract with a test
(red when isolation_mode() reads the raw profile file).
The authserv_id pin now matches the topmost Authentication-Results host
exactly; main also accepted a subdomain of the pin. Operators who pinned a
parent domain for a provider that stamps from changing MX hostnames would
see every message dropped with no guidance (gate 2c Medium finding).
The adapter now trusts only the topmost Authentication-Results field and
requires a set authserv_id to match it exactly (no subdomain alignment),
so operators whose relay stamps a different top authserv-id see the
untrusted-authserv warning. Say so where the warning is documented.
Control verbs with trailing words now run the verb instead of replacing
the goal, so goal text that starts with one of those words needs the
`--` escape. Without a docs line the change is only discoverable from the
in-chat hint.
The E4 2c review (convergent with quality) found /save json now loads the
transfer projection -- every stored row, archived included -- with no
sessions.max_export_messages guard, while `hermes sessions export` and the
console export refuse oversized sessions before materializing that same
projection. A heavily compacted session could load its whole archive into
memory from one chat command. Both /save surfaces now call the existing
assert_export_safe() before a JSON export and reply with the shared
SessionExportTooLargeError text; md/html transcripts are unaffected.
The E4 2c review found the new docs say a `/save json` snapshot holds the
same rows as the transfer backup, but the TUI/desktop /save
(tui_gateway session.save) still dumps in-memory history. Narrow the
English and zh-Hans sentences to the CLI and messaging /save surfaces;
the TUI path is left as a follow-up.
The CLI and gateway /save json wrote the live rows only, the projection the
removed comment justified by import_sessions replaying archived turns as live
context. Import now restores the active/compacted flags, and the dashboard
import accepts a /save json file, so a snapshot of a compacted session lost
every turn the summary replaced. One export_projection() rule now picks the
display history for transcripts and every flagged row for JSON, shared by
`hermes sessions export` and both /save surfaces.
(cherry picked from commit 12f98c891cd70083d35dda9bcabe17459e4c7bb0)
Gate finding (2c, Low): the "Always excluded" list in the export-contents
section did not mention the .ssh/.aws/.gnupg/.kube/.envrc exclusion this stack
adds, nor that nested copies under a skill are dropped too. Add them to the
English list and to the zh-Hans credential-store list (that translation has no
export-contents section; its credential list notes exports strip them too).
"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)