Build the scope inside the try so a failing build still resets the home
override and is reported as a PlaneCredentialError. Cover the launch
profile's frozen env-only override and a secondary never borrowing the
launch profile's env override.
Under gateway multiplexing the Remote Config poller (a bare thread) resolved
the Nous token with no secret scope. The Portal override read failed closed,
default routing applied, and a non-production agent's refresh token was sent
to the production Portal, which rejected it and quarantined the login.
_nous_token now binds the owning home's secret scope (owner_secret_scope:
the launch profile's own mapping for the launch home, the profile's files
otherwise) for the resolution and resets it afterwards.
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)
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.
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).
Review minor (unsupportedpastels): Matrix ignored a stale card's reaction
silently; it now retires the card and posts platform.matrix.approval.expired,
the same path the local deadline already takes.
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.
Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
Review nits: snapshot _choice_picker_state before pruning (the SDK callback thread pops concurrently); send and settle the picker with config.update_multi so a group settle reaches everyone; a one-line title (usage-limit offer) lists the full choice labels as body instead of repeating the header.
Review nit (hint_multi): the hint offers "your own answer" but typed prose was rejected and cancelled the clarify. The card now calls mark_awaiting_text like the base text prompt.
Review finding (MAJOR, webhook transport): _handle_webhook_request turned the whole payload into SimpleNamespaces, so action.value failed the dict route check and the settled card was dropped by the fixed {code:0} reply. card.action.trigger now keeps the button value a dict and returns the callback card as the HTTP body; token/signature checks still run first.
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.
Widens #137037 from Mattermost to the whole class (#137027):
- Runtime notices never reach a bot peer: every busy-path reply (ack, redirect,
drain), lifecycle/status and credits notices, tool progress, interim and thinking
bubbles, the long-running heartbeat, the auto-reset notice, and the runtime failure
copy ("not processed", retry hints, generic failure apology) on a failed or empty
turn. The model's own reply is still delivered.
- Chain rules in gateway.bot_loop_guard (config.yaml): a human message starts a chain,
each bot message after it is one hop, max_hops (default 4) caps them, a bot repeating
the same text in one chain is dropped, and with bot_replies_expect_reply false (the
default) a bot answering the reply this bot just gave it starts no turn. A bot
message also counts as not expecting a reply, so a silence marker ends it quietly.
- Bot authorship everywhere it is knowable: a generic per-adapter bot_users opt-in
(config.yaml platform extra) for platforms whose events carry no bot flag,
Mattermost props.from_bot, QQ guild author.bot.
- Bot Mode message_agent: agent/bot_chain.py owns a per-session hop count carried in
the delivery's author envelope (runtime-written, parse_turn_author keeps it), refuses
sends past the same max_hops and identical repeated asks, sends a reply to the bot
that messaged this turn notify-only (expects_reply false, no wake), and refuses a
message back to a bot whose reply said it needs no answer. tools/bot_mode_dm.py gets
only the gate call and the wake flag (LANE dm owns that file; same wake names as #144789).
(cherry picked from commit b21f2e9fdc45335c55d200c2812f895d81ec28fd, #137037;
conflicts resolved onto main: ALLOW_BOTS moved to the Mattermost gateway_env AUTHZ
entry, status suppression moved to gateway/run_turn_progress.py)
Slack thread context authorized each prior sender as chat_type="thread". The
gateway authz chain only treats group/forum/channel as group chats, so a
channel thread's senders were judged on the DM allowlist: group_allow_from
was never read and every human was tagged [unverified] even under
group_allow_from: ["*"], while a DM-only allow_from user read as verified in
a channel. The live message is authorized as "group" (channel) or "dm"
(IM/MPIM); thread-context senders now get that same chat type plus the
thread id, so the tag marks exactly the senders the live message's allowlist
would refuse. Authz is unchanged, so the Discord "thread" callers keep their
behaviour.
Origin: 0198713c33 (CWE-863 thread-context tagging, #55979).
Moves the google extra pin and the Google Chat staleness probe
(_REQUIRED_PACKAGES) from 0.6.4 to 0.6.5 so a half-synced interpreter
is repaired onto the patched release. pyasn1-modules 0.4.2 allows
<0.7.0. 0.6.5 was published 2026-10-27, past the 3-day release-age
check.
Advisories: GHSA-2qm8-x737-gmw5, GHSA-9m75-vc3g-4x39,
GHSA-fm9c-5rcf-2q5p, GHSA-mm57-4v3p-rchx
Moves the tests-js direct pin (which the root lock hoists for
@assistant-ui/react and eslint-plugin-react-hooks) and the photon
sidecar's transitive copy from 4.6.5 to 4.6.7, the newest 4.6 patch.
4.6.7 was published 2026-10-12, past the 14-day npm release-age gate.
Advisory: GHSA-mrhx-c7xc-m3w5
Moves the ui-tui direct pin and the root undici@^6/@^7 overrides, plus
the photon sidecar's direct pin and override, to the patched releases
in each line. Both were published 2026-10-15, past the 14-day npm
release-age gate.
Advisories: GHSA-2m5v-pch6-74hx, GHSA-4qv8-2p9j-2685,
GHSA-4w7w-5xrc-9h26, GHSA-55wc-3pg8-jfw6, GHSA-9fm3-97px-rjf6,
GHSA-f72v-6w45-q472, GHSA-qrxm-fqhp-jwvm, GHSA-xjj6-f33q-929c
connect() called ctypes.util.find_library("opus") on the gateway loop
whenever opus was not loaded. On Linux find_library runs ldconfig, gcc
and ld subprocesses, and on a host without libopus (the Hermes Cloud
image) it never succeeds, so every Discord connect and reconnect
repeated that blocking work on the loop. On a memory-starved 2 GB
instance both loop-liveness watchdog dumps that restarted the gateway
had the loop thread inside this probe.
Move the opus helpers into adapter_opus.py and probe at most once per
process under a lock; connect() runs it with asyncio.to_thread. A libopus
installed later needs a restart, and the "Opus codec not found" warning
now logs once instead of on every reconnect.
The per-connect probe came in with 0ff1b4ade2 (#1299).
A plain unaddressed follow-up in a channel thread the bot participates in
gets reply_expected=None, so a bare [SILENT] is rejected and the gateway
posts the unexpected-silence notice. That is noisy when humans in the
thread talk to each other without @mentioning.
thread_followup_silence (config.extra / SLACK_THREAD_FOLLOWUP_SILENCE,
default false) makes such non-DM thread replies reply_expected=False so the
silence marker stands. DMs, @mentions and flat channels are unchanged,
and the default keeps the #110952 behaviour.
Move the Slack config getter factories into adapter_config_readers.py to
keep adapter.py within its code-health line cap.
OpenRouter's Image API catalog (GET /api/v1/images/models) no longer lists
x-ai/grok-imagine-image-quality; it lists x-ai/grok-imagine-image-2.0. Swap
the curated _IMAGE_API_MODELS row, with every knob taken from the live
catalog entry: the same 14 aspect ratios, 1K/2K, n=1, 3 references, plus a
quality enum (low|medium) the old row never declared.
The direct xAI backend keeps grok-imagine-image-quality: xAI's authed
/v1/image-generation-models still serves it.
The video-note support added a second, line-for-line copy of the video
branch in _observed_media_source and _handle_media_message, differing only in
the attribute read and the log label. Take `msg.video or msg.video_note` once
in each place instead, keeping the "video note" label for circles, and rejoin
the video_note entry of the _media_message_type table onto the next line.
adapter.py is over the 2,000-line cap, where units may only shrink: this
brings the file back to its origin/main line count (7380), so the PR adds
no lines to it. Behaviour is unchanged; the video and video-note cases of
test_native_video_is_cached and test_reply_to_a_video_note_caches_it still
pass, and each still fails with its branch removed.
group_reply_expected treats an uncaptioned voice note or audio file as spoken
(its words are not in the message text), so it returns None and the silence
notice stays. Video notes now reach the agent too, and a circle without a
caption is the same case: the question is in the audio. Without this it came
out reply_expected=False, so in a group with require_mention off a spoken
question in a circle could vanish behind a bare silence marker.
Tests: the new video-note row of
test_reply_expected_follows_who_the_group_message_addresses fails without the
change (False != None) and passes with it. Docs updated in the same sentence
that lists voice notes and audio files.
The video-note branch passes the label "video note" to
_surface_media_cache_failure, which translates the chat reply through
_MEDIA_KIND_KEYS. The label had no entry there, so a German or Japanese user
whose circle failed to download got a translated sentence with the English
words "video note" in it. Map it to the existing kind_video key ("video file"
in every catalog). The agent-visible note keeps the English "video note".
Scope: no new catalog key; a circle is reported as a video file in the reply,
which every locale already translates.
Test: test_video_note_failure_reply_is_localized (added with the test fold
later in this stack) fails with this entry removed.
Follow-up to the cherry-picked setup-text fix (salvage of #147215).
- Wizard step 4 and the plugin's security hint now share the same lines.
Before this they had drifted: only the wizard said where on the page
the ID is. Step 4 now says what the step is for instead of opening
with "the profile dialog does not show your user ID".
- Add a route for non-admins: a logged-in browser tab can open
/api/v4/users/me with its session cookie and get the user's own ID.
Mattermost reads the session cookie on API requests and only checks
CSRF on non-GET requests, so a plain GET works.
- The allowlist help text points back at step 4 again rather than
repeating one of the lookups.
- Docs (en + zh-Hans): indent the curl block under its list item so the
list no longer splits in two, add the browser route, note that
`mmctl user list` needs `--all` past 200 users, and use the canonical
docs URL (the .html form redirects).
- Move the plugin setup test into tests/gateway/test_mattermost_plugin_setup.py
next to the existing plugin setup tests and its _patch_setup_io helper,
so it no longer writes a real .env. Drop the assertions that pinned the
new wording; keep the ones that pin the old, wrong advice and the two
documented lookups.
Both setup tests fail on origin/main and pass here.
TelegramError is absent from the shared Telegram test stubs, so the import
broke test_slash_confirm_forum_callback_followup_keeps_existing_thread_behavior
with an ImportError. NetworkError covers the same transient cases (timeouts
and network failures subclass it in python-telegram-bot); BadRequest is still
caught first and keeps the decision-only fallback.
Review nit: edit_keeping_prompt suppressed every exception and fell back to the decision-only text, so a transient network error after an edit that went through replaced the kept prompt. Only a BadRequest falls back now.
Review finding: _send_interactive_card read card elements and stripped the approval deadline line on every card, including update-prompt cards. _send_exec_approval_prompt now builds the stored body from prompt.text and passes it as extra state.
Review finding: every button's data carried the 2000-char command preview, so a long CJK command made a ~30 KB card, over Teams' ~28 KB limit, and the send fell back to the plain-text prompt. The preview now stays in the adapter keyed by a per-prompt id (bounded to 256 entries); the buttons carry only that id. Cards sent before a restart still read cmd/desc from their button data.
- Timeout edit: the card now keeps why the command was flagged next to the
command (the reason was dropped; the command was already kept).
- Teams: button data carries the card's own command preview instead of a
200-char stub, so a resolved or expired card shows the whole command.
- Telegram, Slack and Feishu resolved cards drop the "If you don't answer
within N minutes it will NOT run" line, which no longer applies once
someone decided (Teams never carried it; timed-out cards already replace
the body).
Clicking a button on an approval card replaced the whole message with only
the decision ("✅ Approved once by Ana"), so a shared chat lost the record
of WHAT was approved. Discord and Slack already kept the body.
- Telegram: exec-approval and slash-confirm resolutions keep the prompt's
own rendering (Message.text_html rebuilds it from the entities, code
block included) and append the decision; a prompt with no readable body,
or one the appended line pushes past the length cap, still gets the
decision alone so the buttons never stay live. New sibling module
telegram_resolved_prompt.py; the adapter facade does not grow.
- Feishu: the card body is kept in the per-card state at send time and
rendered above the decision on the replacement card.
- Teams: a click after the approval ended replaced the card with only
"expired"; it now keeps the command and reason like the resolved card.
The lookup moved before the opt-in wait and its except was narrowed; an
unexpected `lines` shape then raised AttributeError and aborted setup before
access was configured. Catch AttributeError/TypeError again, as main did.
The live opt-in row reused list_users' 30 s timeout, so a black-holed API stalled
`hermes photon status` (previously offline-only) for 30 s. Thread a timeout through
list_users/find_user_by_phone and use 5 s for the status row only.
routing_live defaulted to True, so skipping the phone prompt still printed
"Photon setup complete. Start the gateway" although nothing can route. Start from
False (only an opted-in number flips it) and give the no-phone path its own ending
naming the command that finishes setup.
A user row without assignedPhoneNumber started a 5-minute wait that told the user only
"text the line Photon assigns": the dedicated-line lookup ran after the wait. Look the
line up first; with still no line, skip the wait and say how to finish (text the line
once it shows up, then `hermes photon status`).
The opt-in step let setup end on "✓ Photon setup complete" even when the
wait timed out, was interrupted with Ctrl-C, or was skipped on a non-TTY
run, so the user was told to start the gateway for a number Photon still
refuses ("Target not allowed for this project").
- setup: when the number is not opted in, the closing line says the setup
was saved but routing is NOT live, names the line to text, and points at
`hermes photon status`; exit code stays 0 (config was written).
- wait_for_opted_in_user: add a wall-clock deadline next to the attempt
cap, so slow list calls (30 s HTTP timeout each) cannot stretch the
advertised 5-minute wait to half an hour.
Photon's delivery plane only routes numbers whose user row carries
meta.opt_in, and that flag is set server-side when the human sends ONE
message from their phone to the row's assignedPhoneNumber. A registered
row without it looks configured everywhere but never sends or receives:
outbound fails with 'Target not allowed for this project'. Client-supplied
meta is ignored on create, so the API cannot produce the flag.
- auth.py: user_opted_in(), find_routable_user() (an opted-in duplicate
wins over list order; find_user_by_phone uses it, so setup re-runs and
status refreshes reuse the routable row), wait_for_opted_in_user() poll
(rides out transient list failures).
- cli.py setup step [4/5]: prints the assigned line to text and waits for
the opt-in to land (Ctrl-C skips; non-TTY runs skip the wait).
- status: live 'routing' row (opted in / not opted in, with the line to
text), emitted from auth.py like the credential summary.
In channels where people answer by reacting instead of replying, the
thread-context block the agent sees on a cold start dropped reactions, so a
message with twelve check-marks and no replies read as "nobody responded".
conversations.replies already returns each message's reactions array under
the history scopes the adapter requires: no extra API call, no new scope.
Each context line now ends with a compact marker such as
"[reactions: :white_check_mark:x12 👀]" (up to 8 distinct emoji, then
"+N more"). Emoji names are reduced to Slack's emoji-name charset so a
crafted name cannot forge context structure, and the marker stays out of
parent_text, which drives mention wake checks.
The helper lives in the adapter_reactions_context sibling so the adapter
facade only gains the call site. Read-side only: it does not route
reaction_added events (#10583).
Telegram fills from_user with a service account (777000 for a linked
channel's auto-forward, GroupAnonymousBot for an anonymous admin) and sets
sender_chat on posts signed by a chat. Those counted as replies to another
member, so in a discussion group with require_mention off a question
commenting on a channel post (including the bot's own channel's post) came
out reply_expected=False and could vanish behind a bare silence marker. A
reply to a chat-signed post is now treated like a reply to an unknown author
(None, the notice stays). Only a reply to another known member is chatter.
_replies_to_other_member had a single caller whose earlier branches already
settle its reply-to-bot and unknown-author checks, so the reply decision is
now one branch in group_reply_expected; _quoted_reply gains its return type.
Behaviour for every existing test row is unchanged.
Tests: the channel auto-forward and anonymous-admin rows fail on the previous
commit and pass here (68 passed with test_telegram_thread_fallback.py).
A group reply whose quoted message has no from_user was not counted as a
reply to another member, but then fell through to the chatter rule, so a
statement like "agreed" came out reply_expected=False and a bare silence
marker sent nothing. The Telegram docs (and the WhatsApp rule in #133672)
promise the notice whenever the quoted author is unknown. Such replies now
return None before the chatter rule. The forum-topic-root check moves into a
small _quoted_reply helper both paths share; reply-to-bot and topic-root
behaviour is unchanged.
Found by the automated review on this PR. Test: the new statement row fails on
the previous commit and passes here; the existing "agreed?" row passed on both
because the question rule hid the gap.
group_reply_expected judges the message text or caption, and a voice note or
audio file without a caption has none: no "?", so it came out
reply_expected=False. In a group with require_mention off, a spoken question
to the bot could then vanish behind a bare silence marker. Voice and audio
without a caption now return None (the notice stays); a captioned one is
judged by its caption, and photos, stickers, documents and locations are
unchanged.
Tests: the voice-note and audio-file rows fail on the previous commit and pass
here; the captioned-voice and uncaptioned-photo rows are controls.
Review follow-ups from a standards audit:
- A reply whose quoted author is unknown is no longer treated as a reply to
another member, so a question there keeps the notice, as in #133672.
- A command with leading whitespace counts as addressed to the bot, matching
how the gateway parses commands.
- _merge_into_pending keeps the original seed semantics (get/None) instead of
setdefault, while staying at the adapter's line cap.
- The photo-batch test now runs the merged turn through the runner's delivery
path: unaddressed silence is not sent, the addressed batch gets the notice.
- Docs note the unknown-identity, unknown-author and forum-root cases.
The Telegram adapter never set MessageEvent.reply_expected, so silence_allowed()
always saw None and every bare [SILENT] / NO_REPLY on a group message became the
"returned only a silence marker" notice, posted to the whole group (#120051).
group_reply_expected (telegram_context.py) sets the per-message signal from #123271,
with the same rule the WhatsApp adapter gets in #133672:
- True for DMs and for a group message addressed to this bot: @mention, reply to
the bot, wake word (mention_patterns) or /command, in text or a caption. The
#110952 fallback stays.
- False for a group message addressed to someone else (a reply to another member,
or one that opens by @mentioning someone else) and for plain chatter. A forum
topic's root anchor is not a reply to a person.
- None for channel posts, an unknown bot identity, and an unaddressed message with
a "?", which may be a question for the bot.
Photo bursts and albums merge through absorb_reply_expected, so a batch that
includes a photo addressed to the bot stays True.
The agent can write cache/ and runtime/, so a file there may be a link or a
forged record. Hermes now deletes a Bitwarden or 1Password cache in cache/, or
a Photon sidecar record in runtime/, on first use without reading it, and
reads and writes only the copy at the top of the profile home. All four stores
are regenerated on use.
- Remove utils.adopt_legacy_file and the paths built on it.
- home_data_layout: split SECRET_CACHE_STORES into SECRET_CACHE_FILES and
LEGACY_SECRET_CACHE_PATHS.
- Backups leave out the four top-level names; the old slashed entries never
matched a root entry.
- The dashboard Files API takes its hidden names from the shared list.
RedactingFormatter resolved a routed profile's redaction policy per
record from config, under the logging handler's lock. It now reads only
in-memory state: the live secret scope, and the policy recorded when that
profile's .env or config is loaded, or when log routing is set up for it
(resolve_profile_policy). Unknown means redact, the secure default.
This removes load_config_readonly_nowait() and the no-boot read scope.
RedactingFormatter reads a routed profile's redaction policy from config
while holding the logging handler's lock. A thread holding _CONFIG_LOCK
(for example one booting a remote profile) that logs then waits for the
handler lock: a lock-order cycle. On the booting thread itself the read
re-entered the boot and ConfigBackendUnavailable escaped the formatter.
load_config_readonly_nowait() returns None instead of waiting: when
_CONFIG_LOCK is held by another thread, or when the read would start a
remote boot fetch (config_backend.without_boot()). The formatter then
redacts (the secure default) and does not cache that answer.
The remote backend's lock-order tests move to their own file.