Figma's mcp.figma.com register endpoint is a client_name allowlist
(Claude Code / Codex succeed; Hermes Agent 403s) and returns a client
secret while advertising auth_method=none, then requires the secret on
token exchange. Auto-set client_name + client_secret_post for Figma
hosts, pass oauth cfg through login/add paths, force interactive OAuth
for hermes mcp login from non-TTY desktop shells, and ship a catalog
entry. Proven: hermes mcp login figma → 26 tools.
A mid-stream steer/redirect cancels only the live model request and queues
the correction for a rebuild. But the retry-wait, error-handling, and
backoff-wait paths all treated the cancellation bit as a hard stop:
clear_interrupt() destroyed the pending correction and the turn died with
"Operation interrupted…" — the user's message silently lost. All three
sites now preserve the redirect and rebuild the iteration from it, exactly
like the InterruptedError handler.
tui_gateway also gets the two suppressions its sibling surfaces already
had: the "Operation interrupted: waiting for model response (…)" sentinel
is cancellation metadata and no longer ships as assistant prose in
message.complete (gateway/run.py and ACP already suppress it), and a
leftover pending_steer returned by the turn is requeued as the next prompt
instead of dropped (cli.py and gateway/run.py already do this).
session.steer now records the correction on the inflight turn like
session.redirect does, so a resume/reconnect mid-turn rebuilds the steered
user bubble instead of losing it.
A run of thinking headers is the one place the disclosure affordance isn't
otherwise discoverable — every other one sits in a row you're already
reaching for. The resting opacity becomes a token so only that surface opts
in; DisclosureRow is shared with tool rows and run headers, which keep the
invisible-until-hover default.
The turn block gap is the space between the reply and the work around it, so
a back-to-back run of thinking headers and tool rows spent the full gap
between every line and read as a stack of cards rather than one column.
Adjacent scaffolding now ticks at a third of it.
The hook is the block list the rhythm rule already enumerates, minus prose,
rather than `data-conversation-scaffold`: that marker is absent on a
multi-call tool group — most of a real run — and present on rows nested
inside one, so it both missed the lines that needed tightening and moved
ones that didn't.
An open file edit is the exception. A diff is the deliverable, read like a
PR, so it keeps the full block gap on both sides while the scaffolding
around it stays tight. A streaming turn seals blocks into separate bubbles
where the flex gap would restore the full gap, so that seal is corrected to
match — and skipped when either side carries a diff.
Saying EXACTLY a configured stop phrase (default: 'stop') and nothing
else now ends the voice conversation instead of being sent to the agent
as a prompt. Match is deliberately strict — whole utterance,
case-insensitive, surrounding punctuation stripped — so 'stop doing
that and try X' still reaches the agent.
- tools/voice_mode.py: is_voice_stop_phrase() + voice.stop_phrases
config loader (default ['stop'], [] disables, malformed config falls
back safely).
- hermes_cli/voice.py: shared continuous loop (TUI + desktop) halts on
a stop phrase exactly like the silent-cycle limit (fires
on_silent_limit so every UI turns voice off); stop_continuous
force-transcribe path swallows the phrase without counting a silent
cycle.
- cli.py: classic CLI push-to-talk/continuous path and barge-in
utterance path disable voice mode on a stop phrase.
- Config default voice.stop_phrases: ['stop']; docs updated.
Tests: tests/tools/test_voice_stop_phrase.py (27 tests,
sabotage-verified: loop test fails when detection is disabled);
voice suites green (110 + 44 passed).
Whisper auto-detection frequently misidentifies short/accented clips,
which users experience as voice notes transcribed in the wrong language
(Teknium + CTO both hit this). The unified resolver from #73067 made a
global hint possible; this makes it the DEFAULT so stock installs stop
guessing. Non-English users set stt.language once; '' restores
auto-detect for multilingual use.
Deep-merge gives existing configs the new default automatically (no
_config_version bump needed); any explicit per-provider or global
language setting still wins.
Tab-descending into a folder from the @ popover re-types the token as a
bare `@apps/desktop/` so the next complete.path lists its children. That
is right while typing, but a bare token is not a reference —
REFERENCE_PATTERN only matches @kind:value — so a draft sent with one
rendered as plain text and attached nothing at all.
Promote it to @file:/@folder: on the way out, the same shape url-refs.ts
uses for bare links: on the committing space, on paste, and on submit for
a path that never got its space. A '/' is required so a handle like
@teknium1 is never mistaken for a path.
displayContentForMessage re-derived every ref from the attached context
block and re-emitted it as a detached list above the text. Now that the
token survives expansion, that list duplicated the inline chip. Hoist
only the refs the prose is missing, so turns persisted by an older
backend still render their chips.
Expanding an @file:/@folder: reference deleted the token from the message
it was typed in, leaving a hole in the sentence and no anchor for clients
to chip. The attached context block still names each ref, so nothing is
lost by leaving the token where the user put it.
Empty Enter while busy was a hard no-op, so a queued turn could only be
sent early via the panel's send arrow or Cmd+Shift+K. With prompts
queued, a second Enter now promotes the head and interrupts, mirroring
the idle empty-Enter drain. Nothing queued keeps the old no-op.
The panel's send-now row action switches from a bare up-arrow to the
return glyph, so the row states the keybind the double-send uses.
Root-cause fix for the 'TTS voice bubble broken' issue family (#57048,
#54589, #57213, #58845, #14841, #45557, #57049). Two class-level defects:
1. Several backends silently write MP3/WAV bytes into a .ogg output path
(Edge only emits MP3, Piper writes WAV, xAI writes MP3, some
OpenAI-compatible servers ignore response_format=opus). Platforms that
need real Ogg/Opus render 0-second/broken voice bubbles. Instead of
per-provider patches, text_to_speech_tool now sniffs magic bytes once
after synthesis (_sniff_audio_container) and repairs the container
centrally (_repair_ogg_container): ffmpeg transcode in place, or rename
to the honest extension when ffmpeg is unavailable. Covers every
current and future provider, including command providers and plugins.
2. want_opus only recognized Telegram, so Matrix/Feishu/WhatsApp/Signal
auto-TTS voice replies were synthesized as MP3 and delivered as broken
attachments. New OPUS_VOICE_PLATFORMS set covers all voice-bubble
platforms.
_convert_to_opus refactored onto a shared _ffmpeg_transcode_to_opus that
supports safe in-place transcodes (-f ogg forced muxer, temp file +
os.replace).
Tests: tests/tools/test_tts_container_repair.py (13 tests incl. a live
ffmpeg round-trip); full TTS suite green (153 + 187 passed).
Class-level fix for the 'STT transcribes the wrong language' issue family
(#55551, #50181 and siblings). Previously language handling was per-provider
chaos: local honoured stt.local.language, Groq/OpenAI/Mistral/DeepInfra sent
no language hint at all, xAI silently forced 'en', ElevenLabs used its own
language_code key, and there was no global setting.
- New _resolve_stt_language() helper: stt.<provider>.language >
stt.language (new global key) > HERMES_LOCAL_STT_LANGUAGE > auto-detect.
- Threaded through ALL providers: local, local_command, groq, openai,
mistral, xai, elevenlabs, deepinfra (shared OpenAI handler), command
providers, and plugin dispatch.
- xAI no longer forces English when nothing is configured (auto-detect).
- Mistral Voxtral now receives a language hint when configured.
- stt.groq.model is now honoured from config (previously env-only).
- DEFAULT_CONFIG gains stt.language, stt.groq, stt.xai, stt.mistral.language.
- Tests: tests/tools/test_stt_language_resolution.py (11 tests, sabotage-
verified) + full transcription suite green (236 passed).
Builds on cherry-picked contributor work from #19786 (@zombopanda),
#23161 (@materemias), #50684 (@BlackishGreen33).
`stt.groq: null` in config.yaml yields `groq_cfg = None`, so the
subsequent `.get("language")` raised AttributeError. Use `or {}`
(matching main's widened xai/local provider guards) and add a
`{"groq": None}` regression test confirming auto-detect stays intact.
Addresses hermes-sweeper review on #23161.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zc7Tgei4kav4n6WSsBq5F
- Normalize stt.groq.language: cast to str, strip, treat
empty/whitespace as unset (parity with xAI's str().strip()).
- Clarify "blank = auto-detect" inline comments in 4 docs/configs to
reflect the env-var fallback (HERMES_LOCAL_STT_LANGUAGE).
- Document that HERMES_LOCAL_STT_LANGUAGE also drives the local
faster-whisper provider, not just the CLI fallback.
- Add unit tests covering: omitted language when unset, config-supplied
language, env fallback, config-over-env precedence, whitespace
normalized to unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read stt.groq.language from config.yaml (with HERMES_LOCAL_STT_LANGUAGE
env fallback) and forward it to the Groq Whisper API to skip
auto-detection on known-language audio. Omit when unset so Groq
auto-detects, preserving today's behavior. Bonus: swap xAI's hardcoded
env literal for the LOCAL_STT_LANGUAGE_ENV constant for consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add optional stt.openai.language config for OpenAI transcription. Forward non-empty hints to the API while preserving auto-detection when unset. Document the config-only setting, add its default, and cover configured and unset request arguments.
The concept 'never send a turn that strict wire validation rejects as
empty' was forked across four sites, each with its own predicate and its
own blind spots:
1. build_assistant_message write-time ' ' pad — broke codex commentary
turns (content:'' is a designed state), and a DB-side pad can't
survive _rows_to_conversation's whitespace strip anyway. REMOVED.
2. conversation_loop send-time ' ' pad — main-loop only (summary path
uncovered), ordering-fragile (had to run after whitespace
normalization), assistant-only. REMOVED.
3. stream-stub '[response interrupted]' substitution — defeated the
loop's empty-stub guard (the stub no longer looked empty, entered
history, and the placeholder leaked into the stitched final
response via truncated_response_parts). REMOVED.
4. repair_empty_non_final_messages in sanitize_api_messages — the
unconditional pre-send chokepoint shared by the main loop AND the
summary path, covers user and assistant turns, non-final only,
copy-on-write. This is now the SINGLE OWNER.
The owner's payload predicate (_msg_has_payload) is extended to treat
codex_message_items / codex_reasoning_items as payload, so
designed-empty codex commentary turns are never rewritten on any
api_mode — the failure shape that broke site 1 in CI is encoded in the
owner, not special-cased at a call site.
Tests updated to pin the new contracts: builder stores textless turns
as-is; the empty stream stub stays recognizably empty for the loop
guard; poisoned resumed histories are repaired to the placeholder at
the send boundary; codex item carriers are never rewritten.
Sabotage-verified: unwiring the owner fails 3 regression tests.
Third layer of the empty-stub fix: full self-recovery. A poisoned transcript
(empty assistant stub or empty user turn already persisted before the write-
time guard, or fed in from a host history) previously 400'd every subsequent
request until it scrolled out — needing a manual DB edit + gateway restart.
sanitize_api_messages() (the unconditional pre-send chokepoint) now repairs
empty non-final messages on the per-call copy by substituting a minimal
'[response interrupted]' placeholder, so the session recovers itself IN MEMORY
on the very next send. The final message is left untouched (empty final
assistant is legal); stored history is never mutated; reasoning-only and
tool_call turns are preserved (negative controls).
Tests: production-shape repro (tool -> empty assistant -> user), empty-user
case, non-destructive guarantee, and negative controls. RED verified by
disabling the wire-in.
Add distinct, greppable log lines at both fix sites so the condition is
observable in the field instead of only inferable from the absence of the old
'Cannot compress further' spiral:
- chat_completion_helpers: warn when an empty partial-stream stub is replaced
with the placeholder (0 chars recovered, no tool call).
- error_classifier: warn when a malformed-body 400 is classified as
format_error rather than context overflow, with num_messages/approx_tokens.
Extend the litellm-shape classifier test to assert the warning is emitted.
A stream that dies after delivering deltas but with 0 recovered chars (and
no captured tool call) produced a content-less assistant stub. That empty
non-final message is invalid for the Anthropic schema, so every later request
400s with 'all messages must have non-empty content' (INVALID_REQUEST_BODY).
On a large session the 400 was misclassified as context overflow, dropping the
loop into a compression spiral that ends in 'Cannot compress further' — a
misleading context-size error on a session nowhere near its limit.
Root cause: substitute a minimal '[response interrupted]' placeholder so the
stub is never empty.
Misclassification: add _INVALID_MESSAGE_BODY_PATTERNS (checked before the
context-overflow heuristic) to classify these as non-retryable format_error,
and teach the body extractors the litellm/Bedrock errorMessage/errorCode/
errorArgs shape so descriptive proxy errors are not mistaken for generic ones.
Adds RED-verified regression tests for the stub path and three classifier tests
(including a guard that real overflows still compress).
#72336 removed the count from the flat list and #72912 from the entered
project, but three sites still tallied rows: search results, each
messaging group (Telegram, Discord, …), and cron jobs.
With them gone SidebarCount never renders a count — its one caller is
the projects-loading spinner — so rename it SidebarSectionMeta.
The backdrop shipped a leva control panel — a Shift+Y dev tweak surface
for opacity, blend mode, filters and a radius scalar — that has never
been touched since the app landed. Its hooks ran in every build, dev and
packaged alike: only the panel's visibility was gated on
`import.meta.env.DEV`, not the two `useControls` calls or the zustand
store behind them. Inline the values it was already rendering with and
delete the dependency; leva was the only thing importing it in the tree.
One of those controls was doing real damage. `--radius-scalar` shipped as
0.6 in styles.css, but the slider's 0.2 default was written onto the root
element on mount, so every window that mounts the backdrop rendered at
0.2 and every window that doesn't kept 0.6. The token now declares 0.2 —
the value the chat has actually rendered at all along.
The remaining static values (0.025 opacity, difference blend, 160dvh, top
left, invert) become classes; saturate(1)/brightness(1) were identity and
are dropped.
Use short static labels (Session actions / Actions) instead of
Actions for <name>. Trim toast fallback, settings echoes, YOLO click
lectures, and fat gateway/appearance/notifications intros.
Drop tips on dialog/overlay/find-bar/review/master-detail close buttons.
Stop tip-wrapping connection/gateway/timer/context/cron/webhook chrome
that already names itself on screen; keep tip only when there's a real
gateway reason. Drop the titlebar swap paraphrase.
Typing a second slash command in the composer went dead whenever the
message started with one. `/work /cle` offered nothing, while `do /work
then /cle` completed fine — which read as an intermittent glitch rather
than a rule.
Two regexes detect a slash, and the `^`-anchored command shape was tried
first. Its argument tail (`(?:\s+\S*)*`) swallows the rest of the line,
so a later `/skill` parsed as an argument to the first command; a command
that takes no options then suppresses the popover outright, and every
slash after the first was unreachable.
Only the first slash can be an invocation, so detect the inline shape
first. It requires a whitespace-preceded slash sitting at the caret, so
it can't take over ordinary argument completion (`/personality alic` has
no second slash) — it fires only where completion was already dead.
Clicking an attached image routed it through normalizeOrLocalPreviewTarget and
into the right rail, where it rendered as a small contained <img> inside a
pane built for reading and editing files. The bytes were already in hand as a
data URL, so the rail's whole read/edit/reload apparatus was doing nothing for
a picture the user just wanted to look at.
Route images with a resolved thumbnail to ImageLightbox — the same overlay the
thread uses — so the attachment previews at full size with the download action,
and drop the dataUrl/previewKind graft that only existed to make the rail
render bytes it shouldn't have been handed. Non-image attachments, and images
whose thumbnail never resolved, still fall through to the rail unchanged.
The <img> carried max-w-[min(100%,var(--image-preview-max-width))] while its
container was w-fit max-w-full. A percentage max-width resolves to none while
the container measures its fit-content width, so the container took the full
column while the image stayed capped — and the absolutely positioned download
button, which anchors to the container, drifted into the margin on any image
wider than it is tall.
Move the width cap onto the container and leave the image at max-w-full. The
container now shrink-wraps the rendered image, so right-2/top-2 lands on the
image's own corner at every aspect ratio.
The resting chip fill and the bold weight both fought the brand mark for
attention. Links now sit in the theme's primary color at body weight, and
the tint fades in on hover.
Weight is overridden on .link-chip itself: `@tailwindcss/typography` sets
`prose a { font-weight: 500 }`, which outranks a utility class on the anchor,
so the per-call-site classes could never win.
Remove the ActionsMenu tooltip prop and Tip wrappers on session, project,
workspace, panel, and credential ⋯ menus. aria-label stays for a11y; drop
the unused credentialActions string.