Main claims Cmd/Ctrl+W on every window (window-accelerator's
before-input-event + preventDefault, kept for the #105498 swallow
guard and the Win/Linux close path) and routes it to the renderer via
hermes:close-preview-requested; the renderer's close rung maps any
focused [data-terminal] to closeActiveTerminal(), so the chord closed
the pane and killed the live shell instead of reaching xterm as
readline unix-word-rubout (#65457).
Route it the way #108550 routes Cmd/Ctrl+R: keep main's claim exactly
as-is, and add the missing terminal rung in the renderer.
- TerminalInstance (the user PTY) gains [data-interactive-terminal];
the read-only agent mirror keeps only [data-terminal], so its tab
stays deliberately closeable (the #65524 breadth complaint).
- The terminal handle registry (terminal-context-menu) keys by the
[data-terminal] scope rather than the nested xterm host — the same
fix#108550 carries — and grows a wordErase verb: the user terminal
re-delivers the ^W byte to its PTY, the agent mirror's is null.
- The close-preview IPC rung (use-desktop-integrations) and the
view.closeTab keybind dispatch (use-keybinds) both consume the
bare platform chord through that verb instead of closing; Shift /
Alt / rebound variants keep closing.
- The non-mac Window>Close menu item becomes click-only
(registerAccelerator: false): the 'close' role's default
CommandOrControl+W accelerator would claim the chord before the
before-input-event run. Clicking it still closes the window.
Co-authored-by: Kyssta <218078013+kyssta-exe@users.noreply.github.com>
Co-authored-by: percival <ignacio@agenticolabs.io>
Part of #79915
Shards the two direct-call uploadComposerAttachment describes out of
use-prompt-actions/index.test.tsx (6256 -> 6180 lines) into
upload-composer-attachment.test.ts (105 lines). The moved cluster needs
neither the Harness component nor sessionInfo: it calls
uploadComposerAttachment directly with an injected requestGateway and a
stubbed window.hermesDesktop.readFileDataUrl. The new filename follows the
sibling shards in that directory (single-flight-resume.test.ts,
rewind.test.ts, utils.test.ts).
Rebuilt against current main: the old preview reuse cluster from the
source PR no longer exists on main (behavior change in #93324 rewrites it
as the image cache contract describe with opposite semantics), so the
shard moves the current direct-call cluster byte-for-byte (parent diff is
a pure deletion).
Test parity: 138 -> 136 + 2, all green.
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Filenames with extensions, hashes, UUIDs, dense model/version IDs and
paths were spoken character by character ('peyton-sample-20260922.wav'
as 'peyton dash sample dash two zero two six...'), making otherwise
clean synthesized audio sound corrupted (#119207).
One identifier-to-speech pass, mirrored token-for-token in
tools/tts_text_normalize.py (prune_identifier_tokens_for_tts, wired
into prepare_spoken_text after the symbols pass) and
apps/desktop/src/lib/speech-text.ts (pruneIdentifierTokens, in
sanitizeTextForSpeech after code fences/links/URLs/MEDIA tokens are
consumed). Identifier-dense tokens become silence — never a hardcoded
English placeholder word, per the #86602 lesson (the reply may be in
any language). Ordinary prose, email addresses, dates ('2026/06/02'),
ratios ('3:2'), prices and short hyphenated words (COVID-19,
well-known) pass verbatim.
Also fixes a pre-existing smooth_whitespace_for_tts bug the corpus
surfaced: 'user@example.com' was read as 'user@example. com' because
the punctuation-space pass split any letter-follows-punctuation pair;
it now only splits when the punctuation is not glued to a word
character on the left.
The two implementations are pinned to the same corpus
(tests/fixtures/identifier_speech_corpus.json), asserted by both
tests/tools/test_tts_identifier_speech.py (pytest) and
apps/desktop/src/lib/speech-text.identifier-corpus.test.ts (vitest).
Builds on the desktop-half approach of PR #119212 (which leaves the
gateway/CLI normalizer in tools/tts_text_normalize.py untouched) and
extends it to both halves.
Co-authored-by: KoNit-K <konit.block@protonmail.com>
The recovery test advanced the 1s retry timer before clearing the
failure flag, so retry 1 re-failed and no successful fetch ever landed;
it passed only by reading the cached pre-turn snapshot. Main's #70871
busy-toggle now evicts that snapshot, exposing the ordering. Flip the
failure off first so the assertion reads a real retry fetch.
Sandbox verification of the first fix exposed the remaining gap: manual
/compress runs the session.compress RPC OUTSIDE any turn, so busy never
flips and useContextBreakdown's effect deps (attempt/busy/enabled/
requestGateway/sessionId) never change — the meter kept serving the
pre-compression breakdown (441.2k/1M) after the transcript shrank 1220 →
202 messages (~5.7x), until the status-bar item was unmounted and
remounted, which reset useState and forced a fresh fetch.
Add an invalidation seam: invalidateContextBreakdown(sessionId) bumps a
per-session generation and notifies subscribers; the hook subscribes for
its own session and re-runs the fetch effect on a bump. Two call sites:
slash.ts after a successful session.compress (the no-turn path), and
lifecycle.ts on session.reclaimed (belt-and-braces with the retry ladder
— invalidation makes the refetch immediate, retries cover failures).
Also verified live: the pre-compression value was wrong by 5.7x after
compression, and unmount/remount of the footer item (the user's manual
workaround) is exactly the cache-reset the fix now does automatically.
useContextBreakdown made one refetch attempt per busy toggle, swallowed
failures, and kept serving the cached pre-compression breakdown whenever
fetched.sessionId still matched (it always does). After compression plus
a session.reclaimed resume, that single refetch races the agent rebuild:
a 4001 is swallowed and the meter freezes at the pre-compression
percentage; an in-flight rebuild answers a zeroed breakdown
(context_max: 0, the agent-is-None branch), which blanks the meter and
caches the zeros. No dep changes after the failed attempt, so nothing
ever retries — recovery required manually switching sessions.
Treat an untrustworthy answer as a transient window, not data: zeroed
breakdowns are never cached; failed fetches retry on a bounded backoff
(1s/4s/12s, re-running the fetch effect via an attempt counter); on
ladder exhaustion the cached breakdown is evicted so the meter goes dark
honestly rather than showing numbers known to be stale. Session switches
reset the retry state; the pending backoff timer is cancelled by the
fetch effect's own cleanup.
Fixes#94001 (scenarios 1, 2, 4).
Tighten collectSourceListIds() to the bundled grounded-citations
skill's own _split_draft() boundary: find the last recognized Sources
header (## Sources or plain Sources:), then collect only [n] http(s)://
entries in the section it opens, skipping fenced code. A bare [n]
marker now survives the orphan strip exactly when this response lists
a matching source entry — ordinary [7] todo prose lines or fenced
examples no longer anchor anything.
The orphan-marker strip deleted every bare [n] that follows prose, so
grounded-citations output lost its claim-to-source links in Desktop. A
numbered source-list entry ([n] url at line start) now anchors the same
number in prose: anchored markers survive, orphan markers are still
stripped.
Deleting a desktop session (or hitting /stop) did not stop its
background memory/skill review fork. The review is a SEPARATE AIAgent
instance, tracked only on the parent's _background_review_agent /
_active_children (agent/background_review.py) -- never on
session["running"] or session["_run_thread"]. That made it invisible
to every teardown/interrupt path:
- _teardown_popped_session's run-thread join only waits on the
FOREGROUND turn's _run_thread.
- session.interrupt's _interrupt_session_turn only calls
request_hard_interrupt when session["running"] is True -- the
common shape is the foreground turn already finished and only the
post-turn review is still running, so this branch never fires.
In the reported incident the review degraded into a failed-tool-call
retry loop (malformed arguments from a fallback local model) and kept
calling the model for 70+ minutes after the session was deleted, only
stopping on a full app restart.
Fix: call the existing
agent.background_review.cancel_background_review_for_live_turn
helper -- previously invoked only when a NEW live turn preempts a
running review -- from both:
- _finalize_session (tui_gateway/session_lifecycle.py), the single
_finalized-guarded chokepoint every teardown path (explicit close,
idle-timeout reap, ws-orphan reap, shutdown) funnels through, and
- _interrupt_session_turn, so an explicit /stop reaches a live review
even when the foreground turn has already ended.
cancel_background_review_for_live_turn gained optional
message/tool_reason kwargs (additive, backward compatible) so the new
callers report the real cause ("session ended (idle_timeout)",
"session interrupted") instead of the misleading default "superseded
by a new live turn" wording inherited from the live-turn-preemption
caller.
Two new regression test files cover the finalize path and the
/stop-only-review-running sibling path; both were proven to fail
(RED) with the fix reverted and pass (GREEN) with it applied.
Fixes#102895
Note on related PRs: #75616 and #102911 (duplicate of #75616) fix a
different, adjacent gap -- the Desktop renderer not calling
session.interrupt before session.close. That closes the window where
the FOREGROUND turn itself outlives deletion, but does not touch this
review-fork gap: in the reported log the foreground turn had already
finished (finish_reason=stop) before the review started, so
session["running"] was already False and session.interrupt's
running-gated branch would not have helped even with those PRs
applied. This fix is complementary, not a duplicate.
`toolRunning` / `reasoning` are pushed to `$petActivity` only from the
per-session message-stream hook (`use-message-stream/gateway-event.ts`), which
is mounted for the foreground session alone. A background session running an
agent task therefore never reached `$petActivity`, so the floating pet sat in
`idle` for the whole run — only the visible session drove its pose.
Mirror the pattern the `waiting` pose already uses: `awaitingInput` works across
sessions because it is derived from the store-level `$attentionSessionIds`
subscription in use-pet-bridge, which is always live regardless of which session
is rendered. Add the twin for busy state — subscribe to `$workingSessionIds`
(the computed over every session's authoritative `busy` flag) and mirror it into
`$petActivity.busy`. `derivePetState` maps `busy` to the `run` pose, so any
working session — foreground or background — now animates the pet, while the
foreground stream's finer `toolRunning` / `reasoning` signals still refine
run-vs-review for the visible session.
Extract `syncPetBusyFromSessions()` as a pure function (mirroring
`rehydrateLiveSessionStatuses`) so the cross-session behavior is unit-tested:
a background-only working session shows `run`, a single finish among several
keeps `run`, and full rest returns to `idle`.
Fixes#84282
Re-exports isStaleTargetError from use-prompt-actions and adds the same
refresh-transcript + recompute + one retry recovery to the session tile's
restoreToMessage verb that the primary chat's restore gained, so the tile
surface named in the report no longer dead-ends on 4018.
The review on #62586 (carried through #128476) noted the existing
assertion runs after flushSync returns, proving the eventual draft swap
but not that the previous draft could not be painted first. The merged
#59305 attachment test pins the layout-phase guarantee for chips only.
Add the text counterpart: a useLayoutEffect probe observes the
composer's mirrored draft (draftRef) in the layout phase of session B's
switch render — the last moment before the browser may paint — and
asserts it already holds B's text. Deterministic: flipping the swap
effect to a passive useEffect makes this test fail with A's text, green
with useLayoutEffect.
Fixes#68894
web-haptics' `debug` option is its desktop audio fallback: with
navigator.vibrate present (Chromium exposes a no-op on desktop), the
library plays synthesized click sounds through an AudioContext whenever
debug is enabled. On macOS that briefly seizes the audio device,
interrupting other playback (Spotify, Apple Music, Bluetooth streams) on
every prompt submit and stream start.
Desktop has no vibration motor, so haptic output is inert here — but keep
the provider and the persisted titlebar mute control wired, and narrow
the fix to omitting `debug` (default false). Also drop the idle
AudioContext warmup, which constructed an audio context of its own.
Adds provider-level regression tests: no AudioContext is constructed on
trigger or mount, a live trigger is registered while unmuted (null while
muted), and haptics are suppressed while muted.
The stale-closure suite seeds only the global $messages mirror, so it
exercises the ?? $messages.get() fallback path. Active sessions publish
their transcript into $sessionStates[runtimeId].messages while the
global mirror stays empty/divergent — add focused tests pinning the
primary path for reloadFromMessage, restoreToMessage, and editMessage.
Ported from #73608 (commit 6ff8426) and adapted to the current
prompt.submit wire contract (confirm_truncate +
truncate_before_message_id). Red-first verified on origin/main: all 4
new tests fail without the fix, 141/141 pass with it.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
planReload/planRestore/planEdit in usePromptActions read $messages.get()
(the global draft mirror), but active sessions store their transcript in
$sessionStates[runtimeId].messages. The global mirror is empty when a
session has a runtime ID, so findIndex returns -1 and the functions
silently fail — refresh, restore, and edit buttons do nothing.
Read from $sessionStates[sessionId]?.messages when available, falling
back to $messages for draft (no-runtime) sessions.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Ported from #73608 (the branch itself diverged too far from main to
rebase cleanly).
On quit, main.ts closes the pet overlay window and its 'closed' handler
unconditionally sent { type: 'pop-in' } to the main renderer, which runs
popInPet() and persists $petOverlayActive=false. restorePetOverlay() on
the next boot therefore skipped restoration and the pet silently reverted
to in-window after every clean quit with the pet popped out.
Latch an appQuitting flag (distinct from isQuittingForHandoff, which is
hand-off-specific and resets) at the top of before-quit, and on the
non-macOS primary-window close path where closing the window IS quitting.
The overlay 'closed' handler now suppresses the pop-in echo while
quitting; a user-initiated overlay close (⌘W) still pops the pet back in.
Supersedes #55934 (liuhao1024) and #58112 (tianma-if), which implemented
the same guard against the since-deleted main.cjs.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: yoma <yingwaizhiying@gmail.com>
Fixes#55920
The no-lazy-fetch probe made ``in_tree_catalog_time()`` return ``None`` on
every tree:0 checkout — indistinguishable from "not a git checkout". That
``None`` feeds the frozen-copy rule in ``_prefer_in_tree_entry`` (no time,
no versions ⇒ live wins), so a live cache fetched before ``hermes update``
re-pinned the old sha on exactly the installs that had just bumped it —
the very regression ``load_catalog_live()``'s contract forbids (#125716
review).
On a git checkout whose path history is unreadable, fall back to the
newest checked-out catalog file's mtime: the checkout ``hermes update``
just wrote outranks the pre-bump doc again, while a checkout whose catalog
files predate the doc still defers to it. ``None`` now means only "no
usable in-tree time" (release installs, never-checked-out clones).
in_tree_catalog_time() dates the checkout with a pathspec'd `git log`,
which opens every commit's tree. On a tree:0 partial clone (the layout
`hermes update` produces) those trees are absent, so the probe lazy-fetches
each one against the remote: measured 40-80s per plugins.manage list,
past the desktop's 30s RPC limit (#125683, #123446). Scope
GIT_NO_LAZY_FETCH=1 to the probe so missing trees fail fast into the
documented None fallback, and resolve the pins/versions/titles maps from
ONE load_catalog_live() pass instead of three in _plugin_rows().
The seed harvest broke three contracts main relies on: _run(cli, cmd)
returned a dict (main's test asserts a plain string) and _SlashWorker.run
returned a (output, seed) tuple (main's fakes return a bare string, so
slash.exec hit 5030 / the mirror busy-warning path never fired / the
concurrent-spawn fake 403'd). Keep both string APIs and carry the seed
separately: _run parks it on cli._harvested_seed, the worker process
replies it on the existing 'seed' field, and _SlashWorker.run stores it
for a pop_seed() the gateway calls after run().
Two tests asserted the live /prompt viewer this PR intentionally removes
(compose-only /prompt); updated minimally: the r7 read-command case drops
its 'prompt' entry and the mutating-mirror test drops its /prompt row.
_compose_in_editor fell back to shell=True with the raw $EDITOR value
unquoted when the argv invocation raised, so a crafted EDITOR value ran as
shell code. Drop the fallback: an unargv-splittable or unrunnable editor,
or a nonzero editor exit (which may leave abandoned buffer text), now
cancels the compose cleanly.
Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
/prompt and /blueprint park their composed prompt on the one-shot
_pending_agent_seed for the interactive REPL loop — but the persistent slash
worker has no REPL, so Desktop/TUI /prompt silently dropped the composed
text (or worse, the gateway's live 'prompt' entry dumped the raw system
prompt instead of composing). Harvest the seed in the worker's _run, carry
it through _SlashWorker.run as (output, seed), and route slash.exec to
return a {type:'send', message} dispatch — which the Desktop and TUI clients
already handle. The gateway's live system-prompt viewer entry (and its
mirror) is removed: /prompt is compose-only in the CLI, and Desktop /prompt
is the compose affordance, not a viewer.
Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: JPeetz <j.peetz69@gmail.com>
The check-attribution job requires every new author email on the branch to have
a contributors/emails/<email> mapping (or a legacy AUTHOR_MAP entry). This is
the mapping for the PR author's commit email.
Collapse the per-driver duplicates:
- one cli_harness fixture drives both one-shot routes (chat -q / -Q) through a
single shared fake CLI, so recovers_in_place, the quota-wall release, the
denial, nothing-settled and the exit-summary smoke pin are one test each
parametrized over the driver
- the seven goal_mode_does_not_continue_* tests become one table (eight refusal
rows) over both drivers, plus the positive control per driver
- interrupt / terminal settlement / partial stay as should_recover_turn unit
rows; the end-to-end denial is one representative row per driver
- exit mapping: drop the overlap with main's test_single_query_exit_contract.py
and keep only this PR's addition (the stripped kanban_task_id() predicate)
Kept as requested: pinned-carrier fail-closed, the ambient-board negative,
lease expiry across the backoff, the recovered-response positive control.
Verification: 77/77 green; ruff check . plus the blocking CI scripts clean;
a 7-mutant battery re-introducing each damage class was killed 7/7.
Same-session recovery for kanban workers whose turn dies on a retryable
provider failure: the one-shot CLI used to return rc=0 with no terminal
kanban call, the dispatcher booked a protocol violation and cold-restarted
the card, discarding the whole session context. This retries the turn IN
PLACE (same session, bounded budget, 15/45/90s backoff) and exits honestly
when recovery is exhausted or refused.
Retry authority is positive and typed — never inferred from an outcome
shape: only a failed turn whose classifier marked it retryable; interrupts
are never retried (already persisted/cleared by agent/turn_recovery.py);
the #87096 bounded terminal settlement is never retried; partial /
completed=False are not authority at all. Before EVERY attempt the worker
proves it still owns the exact, unexpired run/claim lease: the dispatcher-
pinned carrier (HERMES_KANBAN_DB + HERMES_KANBAN_RUN_ID +
HERMES_KANBAN_CLAIM_LOCK) is required and compared exactly — no ambient
board resolution — and claim_expires must be unexpired on both the task
row and the open run row, mirroring release_stale_claims()'s stale
predicate and heartbeat_claim()'s extend-then-mirror contract. Fail-closed:
no proof, no retry. The loop never extends its own lease (claim-lifetime
policy remains #95318).
Rebased onto current main (28aceb3451): the one-shot machinery moved out of
cli.py into hermes_cli/cli_single_query.py, and the carrier re-derives
there — the shared exit mapper keeps ONE decision point (the worker
predicate inside it is the STRIPPED kanban_task_id(), at both the
credentials and the turn sites, so a whitespace-only value is not a worker
to the exit mapping either), and both one-shot drivers (`-Q` quiet and `-q`
non-quiet) run the recovery before their goal gates, which continue only on
a settled, authorized turn (post-recovery exit == 0).
Re-review P2 repaired: the non-quiet driver's recovery callback now
CAPTURES the recovered response (nonlocal) and goal continuation receives
the latest settled turn's deliverable — never the stale provider error of
the turn recovery replaced. The quiet driver already derived its response
post-recovery; the non-quiet side now matches. The non-quiet fake mirrors
production (chat() returns the settled result's response) and the positive
control asserts the judge receives "done"; the discarded-capture mutation
reddens that test with the exact stale-error string from the review's
reproduction.
Tests: 63 in tests/agent/test_kanban_turn_recovery.py (retry authority,
carrier/lease proof incl. expiry-during-backoff, goal composition negatives
on both drivers, positive controls, whitespace predicate pin), 133 green
across the 12 focused files incl. main's test_single_query_exit_contract.py.
Mutation receipts: worker predicate un-stripped -> 1 red; non-quiet goal
gate removed -> 1 red; response capture discarded -> 1 red (the review's
repro); quiet goal gate removed -> 8 red; restores byte-identical.
Preserve genuine selections around comma-normalized historical notices and require closed quoted question/choice fields before recognizing a notice suffix. Cover real historical producer outputs, embedded terminators, prefix lookalikes, and explicit status through public compression dispatch.
The terminal approval check runs right after the lifecycle guard, on the
tool thread with no deadline. Seven hermes/docker/podman rules used the same
ambiguous `-{1,2}\S+(?:[=\s]\S+)?` flag repetition as the lifecycle pattern,
so the reported rclone command (and any long flag run after hermes, docker,
docker compose or podman) still froze the gateway in
detect_dangerous_command() once the lifecycle scan returned (#129281).
Give each rule a linear global-flag fragment with one parse per flag. The
docker/podman rules keep taking a separate value only after exactly one
whitespace character, as before, so no approval decision changes: old and
new rules agree on every command of up to four tokens with single spaces
and up to three tokens with mixed spaces, double spaces and tabs.
Reported-by: starship-s
Preserve the optional-value disambiguation from #129282 while removing its flag-count cutoff. Make token and whitespace runs possessive, and retain standalone dash values. Bound regression probes in subprocesses so a GIL-holding regex cannot wedge the test runner.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
Match the complete single-query notice rather than its prefix, and recognize comma-normalized headless notice envelopes before filtering individual selections. Preserve current explicit status and independent non-notice selections.
Co-authored-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com>
Since entry.ts shipped again (c0a0f097a8), local WSLg launches relaunch on
--ozone-platform=wayland. main.ts still forced --ignore-gpu-blocklist on
every /dev/dxg host, and on Wayland ozone that re-enables GPU compositing
Chromium had correctly disabled: WSL has no DRM render node, so the GPU
process segfaults (exit 139, null deref) on every start until Chromium
aborts with "GPU process isn't usable". hermes desktop never shows a
window.
Keep the override on X11 ozone, where it enables full GPU compositing on
D3D12. On Wayland, leave Chromium's defaults: rasterization and WebGL stay
on the GPU, compositing falls back to software, and the #57614 maximize
fix from #113247 stays intact.
Extend Finn763's dead-owner recovery with owner-fenced failure delivery based on PR #120391 and retain stderr until the parent consumes an escaping worker traceback. Preserve unknown side-effect state and do not retry automatically. Cover both shared entry paths, an exception through the payload lifecycle, abrupt death, and concurrent recovery.
Co-authored-by: Denis Dresvyanskiy <dresvyanskiydenis@gmail.com>
A restart-safe cron worker that dies AFTER adopting its attempt was reported as
"Scheduler restarted after this execution's owner exited" and treated as success.
recover_interrupted_executions() only knows that an owner is gone, so that is all it
can write -- but the waiter holding the worker's Popen knows the owner was that
worker and knows its exit status. Worse, the sweep leaves the row terminal, so the
waiter returned True, run_one_job short-circuited, and mark_job_run never ran: the
job's fire claim outlived its lost execution and refused the next manual fire with
"Job is already being fired" for the whole 300s lease, with an empty last_error.
- executions.terminalize_dead_owner(): terminalize ONE attempt with an observed
cause. Refuses a live owner, this process, and a handoff inside the adoption
grace, so it can never steal a running worker's attempt; returns False so the
caller keeps the generic sweep.
- The waiter records that cause and RAISES, routing the existing post-handoff
bookkeeping: mark_job_run records the run and retires the claim, no dispatch
incident and no false crash notice. Also folds the worker's captured stderr into
the error, which the pre-ack path already did (#112729) and this path dropped.
(cherry picked from commit d3f3571f8c6f4d2f188e90151d8f8393f049a573)
Restore managed extra values after cross-shape merges and expose authored subdict conflicts to plugin hooks before global env fallbacks. Consolidate the precedence coverage into two invariant matrices and qualify documentation for managed policy and explicit env precedence.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
With authored platforms.<plat>.extra outranking the <plat>: block, a user
could replace a key the administrator pinned in the managed block
(require_mention, allow_from, ...) by writing it under their own
platforms.<plat>.extra. On main the managed block value wins.
Drop managed block-pinned keys from the authored snapshot. A key the
managed layer sets under platforms.<plat>.extra stays authored, so it now
also beats a user's top-level block.
The authored-extra snapshot was taken from the merged platforms_data, which
already holds the legacy gateway.json layer. A gateway.json
platforms.<plat>.extra key was therefore treated as authored and beat the
same key in the config.yaml <plat>: block, in extra and on the env rung,
with a warning naming a platforms.<plat>.extra the user never wrote.
config.yaml keys always win over gateway.json.
Snapshot from a scratch merge of the YAML layer only.