pmOnFail: ignore keeps the lock a single document, and with it a direct
`pnpm install` accepts any pnpm; node-deps.mjs (builds, hermes update) is
what refuses another version.
READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
memory-provider-plugin.md and cli-config.yaml.example say the gate binds to
every compaction authority. This branch adds the proactive tool-result prune
to the authorities it withholds, so both lists name it now (wording from
7e79a8b32b on #149265). They also state what the gate doesn't cover: a
plugin context engine's compress() is checkpointed, because the host calls
on_pre_compress() first, but an engine that removes or rewrites host-
transcript messages outside compress() is neither checkpointed nor refused
yet. The refusal for that engine stays with the plugin-contract work.
The limit sentence is @GottZ's, from his comment on #149265.
Co-authored-by: Jan-Stefan Janetzky <git@gottz.de>
Arming compression.checkpoint_required flips the four fork sites to
skip_memory=False so the child can reach the checkpoint provider. In
_init_memory that flag does two things: it loads the external provider
(what the gate needs) and it takes the built-in store branch, which reads
memory.memory_enabled / memory.user_profile_enabled (both default true)
and loads MEMORY.md and USER.md into agent._memory_store;
system_prompt._memory_parts then renders both. The MemoryManager fork scope
does not reach this, because the store is not a provider. So with the gate
armed a delegate child routed to another provider, a batch_runner
trajectory and the Feishu comment responder all carried the user's notes
and profile in their prompt; gate off (and on main) none of them did.
Found in review (BearHuddleston, #94983).
- agent_init._init_memory: right after the flags are read, a fork labelled
memory_agent_context="subagent" that did not request the memory toolset
gets both flags cleared, so no store is built and nothing renders. The
toolset request still wins (#65429: enabled_toolsets=["memory"] must get
a store), exactly as it does for a skip_memory=True fork with the gate
off. mem_config stays defined for the provider branch below. The memory
nudge is already gated on agent._memory_store (_tick_memory_nudge), so
gate on and gate off now agree on every observable of the built-in store.
- memory-provider-plugin.md, "Forks under an armed gate": the prompt stays
as with skip_memory=True; the toolset exception is named.
Tests (tests/agent/test_fork_memory_under_checkpoint_gate.py): the probe
builds a fork and a primary agent through a real AIAgent init with the
recording provider and MEMORY.md/USER.md in the sandboxed home, then renders
build_system_prompt for both. Red against the previous commit ("parent-only"
in the fork's prompt, a MemoryStore on the fork); the primary is the
control. A second test pins the toolset exception. The helper grows a
builtin_memory switch that sets both store flags.
(cherry picked from commit b4cf44d6544c1aa1d9b8ece6e4f3061d08e3652b)
(cherry picked from commit 3acd933faba7c17dec2e0047fc6792e90e614ac8)
MemoryProvider.initialize documents parent_session_id among the kwargs a
provider may receive, and the fork docs rely on it to tell the fork shapes
apart, but _memory_provider_init_kwargs never sent it: parent_session_id
lived on agent._parent_session_id and never reached initialize(). Every
provider.initialize() takes **kwargs, so pass it through when set -- a no-op
for providers that ignore it, and the documented behaviour for those that
don't. Pinned with the recording provider through a real AIAgent init (red
without the change), with the unset case as the control.
(cherry picked from commit a69ce3b5a078dc30fe8a1f197fe4d2e8c4b0d1f6)
(cherry picked from commit bc96063187d562845a2beb7bceea133e91c2db13)
Arming checkpoint_required flips the four fork sites to skip_memory=False so
the child can reach the checkpoint contract. That also woke the ordinary
per-turn fan-outs (on_turn_start, sync_all/sync_turn, prefetch_all,
queue_prefetch_all) and the session-end and delegation writes, which leak the
fork's harness prompt and output into the user's real memory. The old
skip_memory=True guard existed to stop exactly that. The escape hatch only
worked for providers that honour agent_context; most bundled providers
(mem0, hindsight, byterover, holographic, openviking, retaindb) ignore it.
Enforce the skip on the host: MemoryManager captures agent_context at
initialize_all() and short-circuits prefetch_all, queue_prefetch_all,
sync_all, on_turn_start, on_session_end and on_delegation for
agent_context "subagent", independent of provider behaviour. initialize(),
on_session_switch() and on_pre_compress() (the checkpoint path) stay
ungated -- the contract needs them; the on_pre_compress docstring and the
provider guide say so, since a v2 provider that applied its non-primary
write skip there would either report success without a save or kill the
fork at its first compaction.
Scoped to "subagent" only: cron and flush agents ran with a provider before
this change and keep the provider-side contract (MemoryProvider.initialize),
so their recall and writes are not changed here.
(cherry picked from commit a2ead2a17eb646bee8c78da41dde1ad971e8335a)
(cherry picked from commit 654f7c7a9ba06ab2379405b180e058978b0eafa8)
Four call sites fork a second agent with skip_memory=True, which leaves the
child with _memory_manager = None: a fork is not the user, and its scratch
transcript is not the user's memory. With compression.checkpoint_required
armed that saving turns into a kill. The gate is armed globally per agent
instance from config, not from whether the agent has a provider, so the child
arms the same gate its parent did and then meets its first compaction with
nothing to checkpoint against. It raises CompressionCheckpointUnavailable --
"no active provider implements checkpoint API v2" -- and the delegate turn
dies with status=error and a null summary while the parent runs on. The gate
is not bypassed there; it is armed against a provider that was never loaded.
- The four sites (delegate_tool, curator, batch_runner, feishu_comment) pass
skip_memory=not checkpoint_required_from_config(). Gate off is the default
and stays byte-identical: skip_memory=True, no provider init, no hook. Same
shape and the same reason as the two gateway disposable-agent sites that
already read the key this way (run_turn_hygiene.py,
slash_commands_session.py). delegate_tool reads it inside the delegated
profile scope, where the child reads it.
- The detached review and /btw forks (build_cache_parity_fork) are not
touched: they persist nothing, and _pre_compress_memory_context already
exempts exactly that shape (decabd79f1), so the gate never fails them.
- checkpoint_required_from_config() in conversation_compression_checkpoint,
next to the gate's init refusals. One resolver rather than four parses, and
the call sites need it before an agent exists:
agent.compression_checkpoint_required is assigned inside the child's own
init, and the parent's says nothing about the child. It reads
load_config_readonly() -- the call init_agent makes to arm the gate on that
child, so parent and child answer from one source and a fork is given a
provider precisely when the child would refuse to compact without one. An
unreadable config reads as "not armed", matching init_agent's own except
around the same load.
- New memory_agent_context kwarg on AIAgent/init_agent, handed to the
provider's initialize() as agent_context; unset, the platform rule decides
as before (cron/subagent, else "primary"). The platform rule alone cannot
scope these forks: the curator runs as platform "curator", the batch runner
takes its platform from the caller's config, and the Feishu responder sets
none, so each would file its transcript as the user's own memory in every
provider that scopes writes by that key. All four pass "subagent" -- the
value memory_provider.py documents and supermemory gates on. A fresh
per-site string ("curator", "batch", ...) would drop out of every
provider's non-primary membership test and re-enable writes the provider
had deliberately disabled, which is a fail-open on the one path this commit
opens. platform still separates the shapes.
Costs, taken deliberately rather than hidden: one provider initialize() per
fork (a round trip for a network-backed store, on a path that made none), and
fork transcripts reaching the archive as checkpoints. Both are named at the
call site and in the contract doc, where a provider author reads them.
Ten tests (tests/agent/test_fork_memory_under_checkpoint_gate.py). The
four site probes drive the real spawn functions with the smallest parent stub
they accept and record the child constructor kwargs -- red at all four against
the previous commit with the literal skip_memory=True. The gate-off probes are
the control in the other direction: without them "inherit the provider" could
have shipped as "always inherit". The recorder aborts with a BaseException
because the sites wrap the spawn in except Exception and would have swallowed
an ordinary failure into a green test. The provider-initialize test drives a
real AIAgent init, the only way to observe the keyword at initialize().
Live probe (on the August base), isolated HERMES_HOME with a stub API-v2
provider and the gate armed: before, the child aborts with failure_class
"exception:CompressionCheckpointUnavailable" and delegate_task returns
status=error, summary=null. After, the child initializes the provider
(agent_context "subagent", platform "subagent"), its own session fires
on_pre_compress, no CompressionCheckpointUnavailable appears, and the parent
returns the subagent's answer.
(cherry picked from commit 31af59c54ebff4c6c1861961111700b8e42dba76)
(cherry picked from commit 9bf3feaf8c98dff6e90eadf0f533204adc150a8c)
The gate now accepts the deployment session token in X-Hermes-Session-Token on
the gateway's Webapp, but peek_session (used only by /api/status to decide what
a signed-out Webapp caller sees) still read just the bearer and the cookies. So
a Desktop "Session token" connection was signed in on gated routes and got the
signed-out /api/status body, without install_id or gateway_shared_with.
peek_session now tries the header in the same place the gate does: no bearer
and neither session cookie present. It goes through the same helper, so only
the session-token provider verifies the value and the Webapp bearer rule
applies; peek stays side-effect free (no refresh, cookie writes, audit or 401).
Bearer, cookie and no-credential requests read as before.
Docs: the Desktop guide lists the header among the ways the deployment token
works on the Webapp.
Patch from @BearHuddleston's review.
A cross-site POST authenticated only by X-Hermes-Session-Token is served
without the cookie-write checks and without setting any cookie. The new
test pins both: it fails if the has_cookies clause is dropped from
_cookie_write_refusal (403) or if a header session stamps the provider
cookie (Set-Cookie on the response).
The web dashboard docs now describe the REST leg once: Desktop's
"Session token" connection sends the dedicated header, an Authorization
bearer works too and wins when both are sent.
Suggested-by: @BearHuddleston
Hermes Desktop's "Session token" connection sends the deployment session
token in the X-Hermes-Session-Token header on REST. The gated middleware
read only a bearer token from the Authorization header, so every REST
call from such a connection got 401 no_cookie even with the right token.
When no bearer is sent and neither the access nor the refresh session
cookie is present, the gate now verifies that header with the registered
session-token provider only. A request carrying a session cookie takes
the cookie path exactly as before (the header is not consulted). Any
other header value, or no registered provider
(dashboard.session_token_auth.enabled: false), also leaves the request
to the cookie path. A bearer still wins, and on the gateway's Webapp the
header follows the same token_works_alone rule as a bearer. The gate's
two cookie-write 403 checks move, unchanged, into _cookie_write_refusal
so the gate stays under the complexity cap.
Refs #147387
The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.
Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
The plugin guide example said get_session_env returns "" outside a session. When the
session ContextVar was never bound it reads os.environ first and only then returns the
default, so the comment now says that.
The plugin guide told authors to read session identity with
`gateway.session_context.get_session_env(...)`, written as a dotted module
call with no import line. `hermes plugins validate` treats only
`from gateway.session_context import get_session_env` as public, so an author
who wrote `import gateway.session_context` to match that spelling got a
private-import warning.
The developer guide and the user guide now name that import, and the
slash-command example uses it to read the platform and the chat id. A test
validates the guide's `get_session_env` example as a plugin and expects no
private-import warning.
gateway/builtin_hooks/ held only a docstring, and
HookRegistry._register_builtin_hooks() was an empty method called at the
start of discover_and_load(). Nothing registered through either one, so
hook loading is unchanged: user hooks under the profile's hooks/ dir
load exactly as before.
Remove both, the test helper that patched the empty method out, and
the developer docs (and zh-Hans copies) that listed the package as an
extension point.
Related: Code Health PLG-08.
On the gateway's Webapp behind the sign-in gate, a provider token worked
on its own (bearer header, socket ?token=) only when this listener gave
it to a native app's sign-in. The deployment session token
(HERMES_DASHBOARD_SESSION_TOKEN, literal or file:<path>) that the bundled
session-token provider vouches for was therefore refused with 401 /
close 4401, although the docs promise it authorizes the gated listener.
The bearer, peek_session and socket ?token= checks now call
access_token_works_alone: token_works_alone (looked up at call time) or
the token the registered session-token provider read at startup,
compared in constant time by the provider's matches_token, which its
verify_session also uses. With the provider not registered
(dashboard.session_token_auth enabled: false) the token is refused as on
main. token_works_alone and the refresh route are unchanged from main:
the deployment token has no refresh.
The ledger is working data for the serve cutover, not user documentation, and
is deleted with the serve stack. Move it next to its drift test as
tests/hermes_cli/serve_handler_ledger.json, which Docker, Desktop payloads and
Nix already exclude; the generator and the test only change the path.
Creating a profile is host-level work, not session work, so POST /api/profiles
and profiles.create are recorded as keep-host-wide instead of undecided.
Add scripts/gen_serve_handler_ledger.py and the generated
website/docs/developer-guide/serve-handler-ledger.json: one row per
HTTP/WebSocket route of the app `hermes serve` runs and per JSON-RPC
method an /api/ws client can call, with its current path and an owner
decision.
RPC rows are classified with the tables /api/ws dispatch reads: the
authority's served verbs, then ws_legacy_fallback's refused/fallback
rules. A served verb is decided `authority`; every other row is
`undecided` (RPC fallback/refused rows reference D3) until a reviewed
decision is recorded in the script's DECISIONS table. Dashboard-plugin
routes depend on installed plugins and are one row, the hot-mount route.
tests/hermes_cli/test_serve_handler_ledger.py fails when a handler is
added or removed without regenerating the ledger; --check does the same
from the command line.
Refs #145171 (EP-1)
Describe capability checks, send recovery, Stop and approval behavior.
Document the gateway-owned and Desktop-driven group chat paths.
(cherry picked from commit 572a99dfdbd04d667918a8e8b2ba13f289ad2952)
A local session's compression, vision and title routes came from the copy of `auxiliary:` saved when the chat was created, while background review, /btw, /review, the curator, vision's model and every `providers:` entry the routes name were read from config.yaml. A retired side-task model therefore kept failing in every older chat, and a saved key could reach an entry's new URL. Side tasks now read the profile's config.yaml in every in-process chat, as messaging chats already do. The chat's main model and route keep the saved config, --ignore-user-config / safe-mode sessions still see no profile at all, and managed workers keep their session snapshot as a whole.
This replaces the earlier rule that a changed side-task key sends the task back to the main model; the recovery test now asserts the new key reaches the next call.
The automatic-selection case is adapted from Gille's reset-compression regression tests in #147679.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
- plugin_host_wire: an error crossing the host now carries the nearest builtin base
class, so a ValueError subclass (json.JSONDecodeError) raised by a hosted provider's
write_config_fields is still a ValueError in core and the dashboard returns 400, not 500.
- notify_memory_provider catches SystemExit as well as Exception: a provider hook that
exits no longer skips rename's alias, active-profile and identity updates.
- Docs and docstring: a key missing from the target profile's .env reads as unset; the
hooks never fall back to the caller's environment.
- Memory panel GET maps a provider read failure like PUT does (ValueError -> 400).
- One helper for submitted secrets; the Honcho placeholder rule moves into its storage
branch so the field loop no longer branches on storage kind.
- Tests: wire round-trip of error classes, SystemExit hook, missing-secret fail-closed,
and the failing-hook test now asserts both hooks ran.
A catalog memory provider (Honcho today) has no way to own its per-profile setup or its
dashboard config file, so core carries that code for it: profile create/rename edit Honcho's
host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private
modules. These seams let a provider do that work itself; core's Honcho code is untouched here
and is removed in a follow-up once the plugin implements them.
- MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new,
hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for
the provider the profile's effective config selects, with that profile's home and its own
.env secrets bound. A failing hook or unreadable config is logged and never fails the create
or rename. No-ops by default.
- storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) /
write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps
value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on
a refused write, and saves secrets to .env only after the provider accepted them.
_record_profile_rename moves to profile_identity.py (profiles.py line budget).
The rewritten resolution paragraph dropped the sentence that listed
mistral among the providers, so the docs-drift ratchet saw
stt.mistral.language as newly undocumented. The placeholder path in a
code span covers every provider key.
Make provider-specific, global, and environment language hints explicit fallbacks. Describe auto-detection in terms of the resolved hint, since the global setting defaults to English.
Tested: English Docusaurus build, rendered paragraph, and git diff --check
Tested: STT selection, 64 passed and one failure reproduced on the unchanged baseline
Not-tested: Full repository suite and live Groq transcription
Scope-risk: narrow
Document command-provider namespaces and inherited plugin language hints. Link the command and plugin sections to the shared rules, and clarify when Groq consults the environment.
Scope-risk: narrow
Tested: English Docusaurus build, rendered table and links, unchanged YAML values, and isolated STT resolution checks.
Not-tested: Other documentation locales.
Explain why a blank provider language can still resolve to English. Align the guides and environment reference with first-nonempty resolution while preserving configuration values and documenting command-backend limits.
Constraint: Documentation must describe current runtime behavior, not pending default-language or CLI changes.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: Four configuration cases through the real loader and resolver; parsed YAML unchanged.
Tested: Clean English Docusaurus build and rendered table/cross-link checks; git diff --check.
Not-tested: Other documentation locales; no runtime code changed.
Classic CLI gains a Codex-style backtrack picker. Esc Esc on an idle, empty composer with no
overlay (or /rewind) lists earlier prompts newest first with a preview and turn number.
- Enter: prefill the prompt for editing. Nothing is cut until the edit is submitted; the
submission rides to the process loop as a BacktrackResend, which re-validates the picked
turn (same session, same text) and then runs the same durable rewind /undo uses. Esc Esc,
Ctrl+C, an empty submit, or submitting a slash/! command cancels and cuts nothing.
- f: fork. Runs the existing /branch path, then rewinds only the CHILD to just before the
picked prompt and prefills it; the parent's rows stay active.
- /rewind N and /rewind fork N do the same without the picker.
Esc Esc with a draft still discards it; mid-turn Esc Esc is unchanged.
undo_last's rewind body moves into CLISessionMixin._rewind_to_user_ordinal (shared by /undo
and the picker; same compaction-carrier handling, agent patching and memory rewound hook).
The picker lives in a new hermes_cli/cli_backtrack.py mixin; cli_tui_mixin.py is untouched.
CommandDef rewind is cli_only (desktop: terminal); desktop registry and parity table regenerated.
The /rewind picker (Esc Esc on an idle, empty composer) gains Codex-style
backtrack navigation:
- f = Fork from here: a `session.mutate branch` through the last stored row
before the picked prompt (the same primitive Desktop's message-level branch
sends), switch to the child, prefill the prompt unsent. The original session
is untouched. The first prompt has nothing before it, so it opens a plain new
session with the prompt prefilled.
- The list shows a preview (first lines + prompt number) of the selection.
- While the list is open the transcript scrolls to the selected prompt and
marks it, so Up/Down jumps between earlier user messages.
A Webapp in Docker behind a Cloudflare Tunnel hits three things the docs
did not cover:
- webapp.host defaults to 127.0.0.1, the container's own loopback, so a
tunnel or proxy in another container gets connection refused (Cloudflare
shows 502) while `hermes webapp --status` inside reports it serving. The
Desktop and Configuration pages said to keep the loopback host behind a
proxy, which only holds on the same machine.
- The listener trusts X-Forwarded-* only from loopback and
dashboard.trusted_proxies, so without webapp.public_url the sign-in
callback is rebuilt as http://<host>/auth/callback. dashboard.trusted_proxies
also applies to the Webapp, which no page said; trusting the proxy is what
makes the sign-in cookies Secure.
- The Webapp signs in with the dashboard's HERMES_DASHBOARD_OAUTH_CLIENT_ID
unless Web Access registered a client of its own, and a Portal client has
one callback, so a dashboard and a Webapp on two hostnames cannot both use
one client. Only native Desktop's Web Access registers the Webapp's own.
Docker gets a "Behind a tunnel or reverse proxy" recipe (with a cloudflared
Compose sketch) and a "Next to the dashboard" section; the Desktop and
Configuration pages point the cross-container case at it, and the
public_url bullet now says the one-hostname rule holds on a loopback host.
Checked against nousresearch/hermes-agent:latest (v0.22.7) with a
cloudflared quick tunnel in its own container.
- Docs/docstrings: an unattended update never accepts a caution verdict on a git update;
a catalog re-pin accepts one (a reviewer read that pin at admission). Name the --ref hold.
- /plugins update, Desktop and the dashboard reload each updated plugin (MCP + skills live now,
Python tools in new sessions); the scheduled check's 'loads in new sessions' stays.
- Desktop: $pluginUpdateAllRunning is a per-profile set, so profile A's run leaves B's button enabled.
Review findings "Background batches lose siblings on a foreground interrupt" and
"A sibling skipped by the gate is never detached or closed":
- _run_gated now takes honor_parent_interrupt and passes parent=None for a
detached (background) unit, so stopping the parent's foreground turn no
longer cancels siblings still waiting on the prompt-cache warm-up.
- A child the gate skips never reaches _run_single_child's finally, so it is
detached from parent._active_children and closed before its entry is built.
The same teardown runs for futures the parent-interrupt path cancels while
they were still queued behind max_concurrent_children (never started).
- Document that the leader is always task 0 (docs + code comment).
Sibling subagents in one tasks batch send byte-identical tools[] and system
prompt scaffold; only the goal (their first user turn) differs, plus the system
prompt tail when tasks carry different context. Started together, every
child's first request misses the provider prompt cache and pays the full prefix.
delegation.prefix_stagger_seconds (default 0 = off) now holds the siblings on
the first child's route until that child's first response starts streaming
(its request has written the cache), it finishes, or the cap elapses (max 60 s).
The wait ends as soon as the cache is readable, so opting in costs about one
time-to-first-token instead of a fixed (N-1)*S ramp. Routes Hermes does not
prompt-cache and siblings on a different model start without waiting, and a
sibling stopped during the wait is reported interrupted without starting.
Rebuilt on the split delegate_tool_* layout: the gate is its own sibling
(tools/delegate_tool_prefix_gate.py); dispatch only wraps run_child.
An outside adapter following the guide kept the answer-the-oldest bug, and one using the new
helper drew a private-import warning at admission. Review follow-up.
The per-provider `language: ""` comments in config_defaults.py,
cli-config.yaml.example and the voice-mode docs said blank means
auto-detect. Since bc997a36a8 made stt.language default to "en", a
blank provider value inherits that, so users following the comment got
English transcripts of non-English audio (#120100). Correct the text
to the real resolution order; no behaviour change.
Reported-by: Baophan00 (PR #120130)
The reference said "/save | Save the current conversation" and listed /save
as CLI-only. On main /save takes <json|md|html> [filename] [redact] in the
CLI, TUI, Desktop and messaging gateway (hermes_cli/commands.py registers it
without cli_only).
- CLI table: formats and aliases, redact, where the file lands (always on
the user's machine; Desktop uses a save dialog), bare /save behaviour.
- Messaging table: new /save row (file sent to the chat as a document).
- Notes: drop /save from the CLI-only list.
- Same changes in the zh-Hans copy.
Review finding: the "anywhere else is untrusted" rule disowned two
install routes our own docs use: the Docker Hub nousresearch/hermes-agent
image (published by docker.yml) and install.ps1 fetched from
raw.githubusercontent.com/NousResearch/hermes-agent (windows-native.md).
List both, name the NousResearch GitHub org, and scope the "not
nousresearch.com" bullet to exclude the accounts listed above.
The page called five addresses the "only" official ones, but Desktop
packages, Desktop updates, Hermes-Setup and the Termux APT repository are
served from hermes-assets.nousresearch.com, so a reader following the page
would distrust a genuine download. It also labelled install.sh for Termux,
which the README and Termux guide say not to use there.
- State the rule: nousresearch.com and its subdomains; list the ones Hermes
uses (docs, assets, portal, inference API, website) with what each serves.
- Add the Discord invite to the account list (README/docs-site form).
- Point Termux and Nix at their own official routes; drop Termux from the
install.sh comment.
- Replace "any other domain containing hermes" with the subdomain rule plus a
lookalike-suffix example, so a future Nous subdomain is not called a scam.
- Drop sidebar_position: sidebars.ts orders the category explicitly.
An active impersonation campaign (hermes-agent.icu, HERMES-TOKEN GitHub
mass-mention spam — issues #87068, #87133, #87155) is targeting users and
contributors. Add a getting-started page listing the only official domains,
what is NOT official (lookalike domains, tokens, mass-mention shortlinks,
credit-offer emails), and what to do with suspicious messages. Registered
in sidebars.ts.
Feishu already rendered exec approvals and update prompts as interactive
cards, but the clarify tool fell back to the base numbered-text prompt and
finite-choice slash commands (/reasoning, /fast, usage-limit switch) fell
back to text status cards. This implements the generic adapter contracts
from BasePlatformAdapter instead of a Feishu-only path:
- send_clarify(): multiple-choice questions become a card with one numbered
button per option plus "Other (type answer)"; a tap answers through
resolve_gateway_clarify and the callback response settles the card inline
for every client; "Other" calls mark_awaiting_text. Open-ended questions
use the base text prompt. Answers come from the pending clarify entry, so
a tap after a timeout or /new reports the prompt expired.
- retire_clarify_card(): the gateway's hook for a clarify that ended without
a tap (timeout, reset, superseding prose, typed answer) PATCHes the card
button-less; the card is a shared card (update_multi) so the update
reaches every recipient.
- send_choice_picker(): one-tap picker card; the tap settles the card and
runs on_choice_selected on the adapter loop, then sends its confirmation.
- Every click passes the approval path's _validate_card_action (operator
authorization incl. the gateway pairing check, callback-vs-card chat
match); state pops are first-writer-wins, so a double or stale tap is
refused.
Behaviour lives in plugins/platforms/feishu/adapter_choice_cards.py (mixin);
adapter.py stays at main's length (card-action routing became a table).
New platform.feishu.clarify/picker strings in all 17 locales, reusing each
locale's existing Telegram/Discord wording.