Keep the outer history_offset when _run_agent drains queued follow-ups recursively so transcript persistence includes every queued turn in the chain instead of only the last one.
* tui: make URLs clickable + hover-highlight in any terminal
Problem
-------
URLs printed by `hermes --tui` were not clickable in basic macOS Terminal.app.
Cmd+click did nothing, the cursor didn't change shape — like nothing was
detected — even though arrow buttons and other Box onClick handlers worked
fine.
Root cause
----------
Two layers of dead plumbing:
1. `<Link>` only emitted the underlying `<ink-link>` (which carries the
hyperlink metadata into the screen buffer) when `supportsHyperlinks()`
said yes. On Apple_Terminal that's false, so the per-cell hyperlink
field stayed empty, so `Ink.getHyperlinkAt()` had nothing to return on
click. The visible underline was just decorative.
2. `Ink.openHyperlink()` calls `this.onHyperlinkClick?.(url)`, but
`onHyperlinkClick` was never assigned anywhere in the codebase. The
click pipeline (`App.tsx → onOpenHyperlink → Ink.openHyperlink`) ran
but bailed silently on the optional chain.
Bonus discovery: even when wired up, there was no hover affordance —
terminal apps can't change the system mouse cursor, so users had no
visual signal that a cell was clickable. Arrow buttons in the chrome
worked because they had explicit `<Box onClick>` styling; inline link
URLs didn't.
Fix
---
- `Link.tsx`: always emit `<ink-link>` regardless of terminal capability.
The renderer's `wrapWithOsc8Link` already gates the actual OSC 8 escape
on `supportsHyperlinks()` further down — so terminals that don't
understand OSC 8 still don't see the escape, but the screen-buffer
metadata (which the click dispatcher reads) is now populated everywhere.
- `ink.tsx + root.ts`: add `onHyperlinkClick?: (url: string) => void` to
`Options` / `RenderOptions`, wire it to the existing `Ink.onHyperlinkClick`
field in the constructor.
- `src/lib/openExternalUrl.ts`: small platform-aware opener using
`child_process.spawn` with arg-array (no shell) — http(s) only, rejects
`file:`, `javascript:`, `data:`, etc., so a hostile model can't trigger
arbitrary local handlers via `<Link url="file:///...">`. Detached + stdio
ignore so closing the TUI doesn't kill the browser and Chrome stderr
doesn't leak into the alt screen.
- `entry.tsx`: pass `onHyperlinkClick: openExternalUrl` to `ink.render`.
- `hyperlinkHover.ts` + Ink hover wiring: track the URL under the pointer
in `Ink.hoveredHyperlink`, update it from `dispatchHover`, and inverse-
highlight every cell of the matching link in the render-pass overlay
(same pattern as `applySearchHighlight`). This is the cursor-hover
affordance for clickable links — terminals don't expose cursor shape,
so we light up the link itself.
- `types/hermes-ink.d.ts`: add `onHyperlinkClick` to the `RenderOptions`
shim so consumers (`entry.tsx`) type-check against the new option.
Tests
-----
- `src/lib/openExternalUrl.test.ts` (15 cases): http(s) accepted; file/js/
data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with
empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs
pass through unmolested as a single argv element; synchronous spawn
failure returns false.
Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a
URL opens in default browser, hovering inverts the link cells, and
moving away clears the highlight. Full TUI suite: 713 passing, 0
type errors.
Reverts
-------
The earlier attempt that version-gated Apple_Terminal in
`supports-hyperlinks.ts` was based on a wrong assumption — Terminal.app
silently strips OSC 8 sequences but does not render them as clickable
hyperlinks. Reverted to the original allowlist.
* tui: address Copilot review — explorer.exe on win32 + comment fixes
- openExternalUrl: switch win32 from `cmd.exe /c start` to `explorer.exe`.
cmd.exe's `start` builtin reparses the URL through cmd's tokenizer, so
`&`, `|`, `^`, `<`, `>` either split the command or get reinterpreted —
breaking both the protocol-allowlist safety story AND plain http(s) URLs
with `&` in query strings. `explorer.exe <url>` invokes the registered
protocol handler directly with no shell.
- openExternalUrl.test.ts: rename the win32 test to reflect the new
contract and add two regression tests — one with `&|^<>` metachars,
one with the common analytics-URL `&` query-param pattern — both pinned
to single-argv-element delivery via explorer.exe.
- Link.tsx: fix misleading comment. OSC 8 escapes are emitted
unconditionally by the renderer (`wrapWithOsc8Link` in
render-node-to-output.ts, `oscLink` in log-update.ts). Non-supporting
terminals silently strip the sequence, which is why hover/click
affordance has to come from the in-process overlay rather than the
terminal's own link rendering.
Verified: 715/715 tests pass, type-check + build clean.
* tui: address Copilot review #2 — async spawn errors + hover scope + docs
1. openExternalUrl: attach a no-op `'error'` listener on the spawned
child BEFORE unref(). spawn() returns a ChildProcess synchronously
even when the binary is missing (ENOENT on xdg-open / explorer.exe),
unreachable, or otherwise unusable; the failure surfaces later as
an 'error' event. An unhandled 'error' on an EventEmitter crashes
Node, which would tear down the whole TUI. The listener is a
deliberate no-op — we already returned `true` synchronously and the
user just doesn't see the browser pop.
2. openExternalUrl.test.ts: add a regression test using a real
EventEmitter to simulate the async-error path. Pins both the
listener-attached contract and the "doesn't throw on emit" behavior.
Was 17/17, now 18/18.
3. ink.tsx dispatchHover: bypass `getHyperlinkAt()` and read
`cellAt(...).hyperlink` directly. `getHyperlinkAt` falls back to
`findPlainTextUrlAt` for cells without an OSC 8 hyperlink, but the
render-pass overlay (`applyHyperlinkHoverHighlight`) only matches on
`cell.hyperlink === hoveredUrl` — so plain-text URLs would burn
re-renders without ever producing the highlight. Hover is now a
strictly 1:1 fit for what the overlay can paint. Plain-text URLs
still get the click action via the existing dispatch path.
4. root.ts + ink.tsx doc comments: replace the misleading "typically
`open` / `xdg-open` / `start` shell" wording with the actual safe
recipe — argv-array spawn into `open` / `xdg-open` / `explorer.exe`,
with an explicit warning that `cmd.exe /c start` reparses the URL
through cmd's tokenizer and is unsafe + breaks `&`-query URLs.
Verified: 716/716 tests pass, type-check + build clean.
* tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist
1. ink.tsx onRender: stop folding steady-state hover into hlActive.
hlActive forces a full-screen damage diff so previous-frame inverted
cells get re-emitted when the highlight set changes. The transition
IS the trigger — enter / leave / change-to-other-link. While the
pointer just sits on a link the painted cells don't change and the
per-cell diff handles the no-op. Folding the steady state in would
burn a full-screen diff on every frame. Added a
lastRenderedHoveredHyperlink tracker and gate the hlActive bump on
`hovered !== lastRendered`.
2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker)
when toggling alt-screen state. Hover dispatch is alt-screen-gated,
so once we leave there's no path to clear it. Without this, remounting
<AlternateScreen> would paint a phantom hover from the previous
session until the next mouse-move arrived.
3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for
xdg-open and return null for everything else (aix, sunos, cygwin,
haiku, etc.). Previously the default-fallback always returned
xdg-open, which made the caller's `if (!command) return false` dead
and yielded a misleading `true` on platforms that probably don't
have xdg-open. New tests cover the null path AND the
openExternalUrl-returns-false-without-spawning behavior.
Verified: 718/718 tests pass, type-check + build clean.
* tui: address Copilot review #4 — doc comment accuracy
1. openExternalUrl return-value doc: now lists all three false paths
(URL rejected / no opener for platform / synchronous spawn throw)
plus a note that async 'error' events still return true because the
spawn was attempted.
2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives
either an OSC 8 hyperlink OR a plain-text URL detected by
findPlainTextUrlAt — App.tsx routes both into the same callback.
3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading
'caller forces full-frame damage' promise. Caller decides; for hover
the current caller only forces full damage on transitions.
No behavior change. 718/718 tests pass.
* tui: address Copilot review #5 — lint fixes
1. ink.tsx: reorder `./hyperlinkHover.js` import before `./screen.js` to
satisfy perfectionist/sort-imports.
2. Link.tsx: drop unused `fallback` parameter destructuring + the
trailing `void (null as ...)` dead-statement (would trip
no-unused-expressions). Kept `fallback?: ReactNode` on the Props
interface as a documented compat shim so existing call sites still
compile, with a comment explaining why it's no longer wired up.
3. openExternalUrl.test.ts: replace `typeof import('node:child_process').spawn`
inline annotations (forbidden by @typescript-eslint/consistent-type-imports)
with a `SpawnLike` type alias backed by a real `import type { spawn as SpawnFn }`.
No behavior change. 718/718 tests pass, type-check clean, lint clean on
all modified files.
Recover from SIGWINCH without clearing the physical screen or scrollback
buffer. The startup banner and tool summary are printed before
prompt_toolkit owns the live chrome, so they live in normal terminal
scrollback. Calling erase_screen() + \x1b[3J] on every resize removed
that UI permanently — _replay_output_history cannot reconstruct it
because the banner was never added to _OUTPUT_HISTORY.
Instead, just reset prompt_toolkit's renderer cache and invalidate so
the next incremental redraw starts from a clean slate, then let the
original on_resize handler recalculate layout for the new terminal
size. This matches the behaviour of bash/zsh/fish on SIGWINCH.
FixesNousResearch/hermes-agent#22999
skill_view ran the direct-path strategy across every skill dir before
the recursive strategy, so a top-level skill in an external dir could
silently shadow a same-named nested local skill. /skills correctly
listed the local version (deduped local-first by _find_all_skills) but
skill_view loaded the external one — confusing, and a real bug class
for users with skills.external_dirs registered alongside categorized
local skills.
Pick a louder fix than @polkn's PR #6136 proposed: collect every match
across all dirs (direct path, recursive by parent dir name, legacy
flat <name>.md), and if there's more than one, refuse with an error
that surfaces every matching path plus a hint to load by the
categorized form. Local-first precedence would have replaced silent
external-shadowing with silent same-name collisions between two
externals, or made an externally-shadowed-by-local skill unreachable
by bare name with no signal. Refusing forces the user to disambiguate
once and never wonder which skill ran.
Recovery: pass the full categorized path
("foundations/runtime/explore-codebase" instead of
"explore-codebase"), or rename one of the colliding skills.
Co-authored-by: pol <pol.kuijken@gmail.com>
Removes the 'Launch hermes chat now? (Y/n)' prompt at the end of
hermes setup. The summary already prints 'Ready to go! → hermes'
so the auto-launch was redundant, and on macOS 26+ it could crash
in prompt_toolkit when setup was invoked from the curl install
script with stdin redirected from /dev/tty (#5884, #6128).
After setup, users run 'hermes' themselves like every other CLI
tool. Same pattern applies to the Windows installer.
Closes#6128 (narrower env-var-guarded fix superseded by removing
the prompt outright).
Adds an explicit API compatibility mode prompt to the `hermes model -> custom`
flow so Codex-compatible third-party endpoints (and any other non-default
backend whose URL doesn't match the existing heuristics in
`_detect_api_mode_for_url`) can be selected explicitly instead of silently
falling back to chat_completions.
Choices: Auto-detect / chat_completions / codex_responses / anthropic_messages.
Persists `api_mode` to:
- `model.api_mode` (active session config)
- the matching `custom_providers[*]` entry (so re-activating the named
provider next time replays the same transport)
Salvaged from PR #6125 onto current main: kept the new prompt and the
`_save_custom_provider(api_mode=...)` plumbing; the named-custom flow
already extracts and applies `api_mode` from the saved entry on current
main so those changes are preserved as-is. Test fixtures updated for the
new prompt and the existing display-name prompt.
Co-authored-by: littlewwwhite <1095245867@qq.com>
- memory_setup.py: use shlex.split() for plugin dep checks instead of shell=True
- transcription_tools.py: avoid shell=True for auto-detected whisper commands
(user-provided templates via env var still use shell=True for compatibility)
- cli.py: add comment clarifying intentional shell=True for user quick_commands
- Add test verifying auto-detected template is shlex-safe
Addresses CONTRIBUTING.md Priority #3 (Security hardening — shell injection).
Keep Cron and Profiles available through lower-prominence chrome entry points so the workspace sidebar stays focused on core chat navigation.
Co-authored-by: Cursor <cursoragent@cursor.com>
These two functions in hermes_cli/profiles.py have no callers — the live
`hermes completion {bash,zsh}` command uses hermes_cli/completion.py's
generate_bash() / generate_zsh() instead. Multiple PRs (incl. #6141) tried
to fix the trailing-`_hermes "$@"` zsh bug here, only to discover the
patch never reached users. Delete the dead code so future contributors
patch the right file.
The actual user-facing fix lives in the preceding cherry-picked commits
to hermes_cli/completion.py.
Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands.
Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session.
Previously :latest tracked the tip of main, which meant pulling :latest
got you whatever was last merged — fine for development, surprising for
users who expect :latest to mean 'the most recent stable release'.
Reshape the publish flow so the floating tags carry their conventional
meaning:
- :sha-<sha> every main commit (unchanged, immutable)
- :main tip of main (NEW; what :latest used to do)
- :<release_tag> every published release, e.g. :v1.2.3 (unchanged)
- :latest most recent release (CHANGED; release-only now)
Implementation:
- Rename the move-latest job to move-main; it still gates on push to
main, still ancestor-checks the existing :main label before
retagging, still uses cancel-in-progress: false so queued moves run
serially.
- Add a new move-latest job gated on release: published. Reads the
OCI revision label off the existing :latest and only advances if
the release commit is a strict descendant. This keeps backport
releases on older branches (e.g. patching v1.1.5 after v1.2.3 has
already shipped) from dragging :latest backwards.
- merge job exposes pushed_release_tag and release_tag outputs so
move-latest knows when to fire and what to retag from.
Only Discord and Telegram had lazy-install hooks in their
check_*_requirements() functions. The remaining four platforms that were
moved to lazy_deps (Slack, Matrix, DingTalk, Feishu) would just return
False immediately if their packages weren't pre-installed — no attempt
to install them at runtime.
This means even with the .venv permissions fix (#24841), these four
platforms would still fail to load in Docker (or any fresh install)
unless the user manually ran pip install.
Add the same lazy_deps.ensure() pattern to all four, matching the
existing Discord/Telegram implementation.
Drops the duplicate _FILE_MUTATING_TOOLS frozenset in run_agent.py and
imports the canonical FILE_MUTATING_TOOL_NAMES from
agent/tool_result_classification.py (aliased as _FILE_MUTATING_TOOLS to
avoid renaming the existing call sites). Prevents future drift if
another file-mutating tool is added — only one set needs updating.
No behavior change: same frozenset({'write_file', 'patch'}), and the
117 PR-scoped tests still pass.
`lsp` is registered as a top-level subparser in `main()` (lines 9539-9545)
via `agent.lsp.cli.register_subparser`, so it shows up in `hermes --help`
output alongside the other built-ins. The `_BUILTIN_SUBCOMMANDS` set used
by `_plugin_cli_discovery_needed` to short-circuit the ~500-650ms plugin
import pass did not list it, so every `hermes lsp ...` invocation paid
the full discovery cost despite being a fully-built-in command.
This is also caught by the parity guard added in #22120:
`tests/hermes_cli/test_startup_plugin_gating.py::test_builtin_set_covers_every_registered_subcommand`
has been failing on clean origin/main with:
AssertionError: _BUILTIN_SUBCOMMANDS is missing these live
subcommands: ['lsp']. Add them to hermes_cli/main.py::_BUILTIN_SUBCOMMANDS
so plugin discovery can be skipped when the user targets them.
Fix: add `"lsp"` to the frozenset (alphabetical position between `logs`
and `mcp`). The accompanying `test_builtin_set_has_no_phantom_entries`
guard still passes because `lsp` is genuinely live — registered via the
guarded `try/except Exception` in main() since #24168.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Press Enter while busy with a draft to queue it; with no draft to interrupt
and send the next queued turn. Auto-drains one queued turn each time the
session settles, same as Cursor. Queue persists across reloads so an
interrupted-and-queued turn isn't lost on refresh.
Each queued row supports edit-in-composer (with explicit Save/Cancel),
send-now (↑), and delete. Drain skips only the entry currently being
edited so the rest of the queue keeps flowing.
Queue dequeue is transactional — an entry only leaves the queue after
`prompt.submit` is accepted, so a rejected submit doesn't drop the turn.
Also shrinks the `[interrupted]` marker to a muted one-liner and drops
its assistant footer so it stops looking like a real reply.
- Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring.
- Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d).
- Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing).
- Remove stray merge conflict markers from pyproject.toml optional deps.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Dockerfile permissions section made /opt/hermes/.venv readable but not
writable by the hermes runtime user. Since the 2026-05-12 policy change
moved messaging packages (discord.py, telegram, slack, etc.) out of [all]
and into lazy_deps.py, the Docker image no longer ships with them
pre-installed. At first gateway boot, lazy_deps.ensure() tries to
`uv pip install` them into the venv but fails with EACCES because
site-packages is root-owned.
The result: every messaging platform adapter silently fails to load inside
Docker containers, producing only a cryptic "discord.py not installed"
warning despite the gateway being correctly configured.
Two-part fix:
1. Dockerfile: add /opt/hermes/.venv to the existing chown -R hermes:hermes
line so the default (UID 10000) case works out of the box.
2. docker/entrypoint.sh: extend the needs_chown block to also re-chown the
.venv when HERMES_UID is remapped. Without this, the build-time chown
becomes stale when someone uses the documented HERMES_UID override in
docker-compose.yml.
Fixes#21536
Related: #17674, #21543, #21755
- Rename 'Alibaba Cloud (DashScope)' display label to 'Qwen Cloud'
in CANONICAL_PROVIDERS (model picker, /model, hermes model TUI) and
PROVIDER_REGISTRY (setup wizard prompts, status output).
- Move Qwen Cloud (alibaba) up to position 6 — directly below
OpenAI Codex and above Xiaomi MiMo.
- Move Qwen OAuth (Portal) (qwen-oauth) to the bottom of the
canonical provider list.
Provider slug 'alibaba' is unchanged — only the display label
moved. DashScope env var (DASHSCOPE_API_KEY) and base URL are
unchanged. The separate 'alibaba-coding-plan' plugin provider is
not affected.
* feat(nous): unified client=hermes-client-v<version> tag on every Portal request
Every Hermes request to Nous Portal now carries the same
client=hermes-client-v<__version__> tag (e.g. client=hermes-client-v0.13.0
on this release), sourced live from hermes_cli.__version__. The release
script's regex bump auto-aligns it on every release.
Centralized in agent/portal_tags.py and wired into all four call sites:
- NousProfile.build_extra_body (main agent loop, every chat completion)
- auxiliary_client.NOUS_EXTRA_BODY + _build_call_kwargs (aux client)
- run_agent.py compression-summary fallback path
- tools/web_tools.py web_extract fallback
Replaces the client=aux marker added in #24194 with the unified version
tag. Tests assert against the helper output (invariant) rather than the
literal string, so they don't need updating on every release.
* feat(nous): cover /goal judge and kanban specify aux paths
Two aux-using surfaces bypassed call_llm by invoking
client.chat.completions.create() directly without extra_body, so they
were missing the unified Portal client tag:
- hermes_cli/goals.py — /goal standing-goal judge
- hermes_cli/kanban_specify.py — kanban triage specifier
Both now pass extra_body=get_auxiliary_extra_body() or None so they
inherit the version tag when the aux client points at Nous Portal, and
emit nothing otherwise (no tag leak to OpenRouter/Anthropic auxes).
The long-lived prefix-cache layout split the system prompt into stable/
context/volatile blocks and re-derived them on every API call. The
volatile tier (timestamp + memory snapshot + USER profile) ticks per
turn, so the system message bytes mutated mid-conversation and broke
upstream prompt caches (OpenRouter, Nous Portal, Anthropic).
Diagnosed via live wire-format diffing: an 8-turn conversation showed
OLD layout flipping system block[1] sha mid-session at the minute
boundary, dropping cached_tokens to 0 on that turn (cumulative
66.6% vs 83.3% for the single-block layout). Hermes invariant:
history (system + all but the last 1-2 messages) must be static.
Fix: drop the long-lived layout entirely. Single layout everywhere —
system_and_3 with one cached system string built once on first turn,
replayed verbatim on every subsequent turn. Loses cross-session 1h
prefix caching for Claude (the feature that motivated the split), but
within-session caching now actually works on every provider.
Removed:
- run_agent.py: _use_long_lived_prefix_cache flag, _long_lived_cache_ttl,
_supports_long_lived_anthropic_cache method, the long-lived branch in
run_conversation, mark_tools_for_long_lived_cache call site
- agent/prompt_caching.py: apply_anthropic_cache_control_long_lived,
mark_tools_for_long_lived_cache, _mark_system_stable_block helper
- hermes_cli/config.py: prompt_caching.long_lived_prefix and
prompt_caching.long_lived_ttl config keys
- tests/agent/test_prompt_caching_live.py (entire file)
- tests/agent/test_prompt_caching.py: TestMarkToolsForLongLivedCache,
TestApplyAnthropicCacheControlLongLived
- tests/run_agent/test_anthropic_prompt_cache_policy.py:
TestSupportsLongLivedAnthropicCache
Targeted tests: 62/62 pass.
Two related fixes for Python detection on Windows:
1. py.exe (Python launcher) is missing from per-user installs that
didn't check the launcher option, so 'py -3.X --version' alone
misses real Python installs. User-reported case: clean Win11 +
official Python.org 3.14 install -> 'where py' returned nothing,
our installer offered to install Python again. Both NSIS prereq
page and main.cjs now probe in this order:
1. py.exe launcher (when present)
2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath
3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v>
Crucially, we never fall back to running 'python.exe' from PATH
on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\
WindowsApps\python.exe is a redirector that opens the Microsoft
Store window if no Store Python is installed. Triggering that
during boot would be terrible UX. Registry/filesystem probes
never execute the binary.
2. Drop 3.14 from the supported version set. Several Hermes deps
(notably pywinpty, which carries Rust crates like
windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels
missing, 'pip install -e .' falls back to building from sdist,
which needs a Rust toolchain — users see 'could not compile
windows_x86_64_msvc build script' on first run. install.ps1
sidesteps this by pinning to 3.11 via uv; the desktop installer
doesn't yet have the same uv-managed-Python pathway, so for now
we accept 3.11/3.12/3.13 and tell winget to install 3.11 if
none of those are present. Revisit when the wheel ecosystem
catches up to 3.14 (~early 2026).
Five distinct bugs surfaced from a math-heavy stress test:
1. Adjacent code fences glued together. scrubBacktickNoise's
second-pass regex /``\s*``/g matched the LAST 2 backticks of
one fence + whitespace + FIRST 2 backticks of the next, collapsing
two blocks into one. Fixed with lookbehind/lookahead so we only
match exactly 2 backticks not part of a longer run.
2. Whitespace eaten between fences and following content.
stripPreviewTargets internally calls .trim() which strips leading/
trailing whitespace from each split-segment. For segments between
two fences this collapsed \n\n to '', gluing fence close to next
block. Fixed by capturing leading/trailing whitespace at the call
site and restoring it after the transform.
3. Currency dollar signs eaten as math. With singleDollarTextMath:true
remark-math greedy-matched any pair of $, so '$5 ... $10' became
one inline math span. Added escapeCurrencyDollars to escape $<digit>
patterns to \$<digit> in prose segments (not in code). Trade-off:
math expressions starting with a digit (rare — '$5x = 10$') get
escaped too. Mirrors the convention in ChatGPT/Claude's UIs.
4. \(...\) and \[...\] LaTeX brackets unsupported. Models often
emit these instead of $...$ / $$...$$. Added
rewriteLatexBracketDelimiters preprocessor pass.
5. ```latex / ```tex blocks were being routed to KaTeX via a
rewrite to ```math. Aligns with GitHub markdown convention:
```math = render as math; ```latex / ```tex = LaTeX/TeX
source code (syntax highlighted, not rendered). Conflating them
broke teaching/showing-source use cases. MATH_FENCE_LANGUAGES
pruned to {'math'} only.
Also flipped parseIncompleteMarkdown to true (was !isStreaming) so
the math parser can't see $ inside streaming-but-not-yet-closed code
fences. Shiki was already deferred via defer={isStreaming} so this
doesn't introduce new tokenization cost.
Test: 18/18 existing tests still pass; one test updated to expect
escaped \$ in currency-prose-with-URL case.
When switching models via /model, AIAgent._config_context_length was
never cleared, so the new model inherited the previous model's context
window instead of auto-detecting the correct one via
get_model_context_length().
Clear _config_context_length to None before the runtime field swap so
the full resolution chain (custom_providers per-model, endpoint probe,
models.dev, etc.) is re-evaluated for the newly selected model.
Closes#21509
The test_restart_command_while_busy_requests_drain_without_interrupt test
was asserting against a hardcoded emoji string that was valid before the
i18n migration. After gateway/run.py switched to t("gateway.draining",
count=N), the test sees the translated output (or the raw key when the
locale catalog isn't resolved in xdist workers).
Fix by asserting against t("gateway.draining", count=1) — this produces
the correct expected value regardless of whether the locale file is
available in the test environment.
Default timeout raised from 60s to 300s (5 minutes) to accommodate
slower systems like Unraid NAS. Configurable via WHATSAPP_NPM_INSTALL_TIMEOUT
environment variable.
The live adapter path in _send_via_adapter called adapter.send() without
passing thread_id, while the standalone fallback path correctly forwarded
it. For plugin platforms (google_chat, teams, irc, line) running with the
gateway in-process, this caused every threaded reply to land as a new
top-level message instead of continuing the thread.
Matches the pattern already used by _send_matrix_via_adapter and
_send_feishu: build metadata={"thread_id": thread_id} and pass it through.
The WeCom adapter's _listen_loop() automatically reconnects when the
WebSocket drops, but it never called _mark_connected() after a successful
reconnection. This left the runtime status file (gateway_state.json) stuck
in "disconnected" even though the adapter was fully operational again.
Add self._mark_connected() right after _open_connection() succeeds so
that the dashboard and health probes report the correct state.
Tested by forcing a WebSocket close via the heartbeat loop and verifying
that the status file updated from "disconnected" back to "connected".
The LINE adapter calls self.create_source(...) which raises
AttributeError on every inbound message — no such method exists.
The base PlatformAdapter exposes this factory as build_source(),
consistent with the IRC and Teams adapters.
Fixes#23728
GLM-family models (z-ai/glm-4.5-air, z-ai/glm-4.5-flash, etc.) exhibit
the same "describe-instead-of-call" failure mode that gpt/codex/gemini/
gemma/grok already trigger enforcement for. Without the injection,
free-tier GLM workers spawned by the kanban dispatcher routinely exit
cleanly (rc=0) without invoking kanban_complete or kanban_block,
producing the "protocol violation" error and triggering the dispatcher's
gave_up path.
Observed in real workloads: seven consecutive kanban tasks across three
GLM-tier profiles (shipbackend, frontend-engineer, backend-engineer) all
failed with the identical message:
worker exited cleanly (rc=0) without calling kanban_complete or
kanban_block — protocol violation
Re-running the same tasks on Claude Haiku immediately resolved them.
Adding "glm" to TOOL_USE_ENFORCEMENT_MODELS closes the gap so future
GLM-routed work receives the explicit "every response must contain a
tool call or final result" steering that already protects the other
enforcement-gated model families.
One-line change; no behavior change for non-GLM models.
PR #23458 introduced _send_message_with_thread_fallback() and applied it
to all control-style sends (send_update_prompt, send_approval_request,
send_model_picker_prompt), but the slash-confirm result message in
handle_callback_query still called self._bot.send_message directly.
In supergroups with stale message_thread_id on the callback's parent
message, this raises "Message thread not found" and silently swallows
the result text. Replace with the helper so the same retry-without-
thread-id logic applies.
Autostash creates refs/stash as a pointer to the latest stash commit, but
git stash apply/drop expect the symbolic ref format like stash@{0}, not
the raw commit SHA. Using the commit SHA causes: error: 'X is not a stash reference'
- Note that typescript-language-server pulls in the typescript SDK
automatically (peer-dep relationship was previously implicit and
caused initialize failures when the SDK was absent).
- Add a Troubleshooting entry for the new Backend warnings section
in hermes lsp status, with the shellcheck install commands across
apt / brew / scoop.
Reflects what shipped in PR #24630.
_session_info() used os.getcwd() which reflects the gateway process
working directory, not the user's actual working directory. This caused
the TUI status line to display incorrect paths (e.g. D:\HermesWork
instead of D:\Hermes\HermesWork) after agent turns that changed the
process cwd.
Align with session.create which already correctly reads TERMINAL_CWD
env var set by the CLI launcher.
In WSL2, sounddevice.query_devices() returns [] even when the
PulseAudio bridge is functional. The existing code already handled
the case where the query itself raises an exception, but it missed
the empty-list case.
This change treats an empty device list as non-fatal in WSL when
PULSE_SERVER is configured, matching the existing exception-handler
behavior.
Fixes: WSL users seeing 'No audio input/output devices detected'
even though paplay/arecord work fine.
Closes#23064
When Hermes connects to Signal via signal-cli in daemon mode (linked
device setup), group messages sent from the user's phone were silently
dropped. The syncMessage handler only processed events where
destinationNumber equals the bot's own number (Note to Self).
Group messages from linked devices carry a groupInfo.groupId instead of a
destinationNumber. Extend the condition to also pass through sync messages
that have a groupId, so group messages are promoted to dataMessage and
reach the agent.
PR #24151 routed Portal Qwen (qwen3.6-plus) through the prefix_and_2
long-lived cache layout, attaching {"type":"ephemeral","ttl":"1h"}
markers to the tools[-1] entry and the stable system-prefix block.
That layout works for Portal Claude because Anthropic / OpenRouter on
Anthropic routes honour 1h TTL — but Portal Qwen ultimately proxies to
Alibaba DashScope, which documents a single "ephemeral" TTL of 5
minutes on its Context Cache. The ttl="1h" qualifier is silently
dropped upstream, so the two highest-value breakpoints (tools array +
system prefix) never land. Only the rolling-window 5m markers on the
last 2 messages cache, which matches the observed ~25% read rate.
Fix: keep Portal Qwen on cache_control via _anthropic_prompt_cache_policy
returning (True, False), but drop it from _supports_long_lived_anthropic_cache
so it rides the standard system_and_3 5m layout (system + last 3 messages,
all at 5m). Same 4 breakpoints, all in a TTL the upstream actually honours.
Refs: https://www.alibabacloud.com/help/en/model-studio/context-cachehttps://openrouter.ai/docs/features/prompt-caching (Alibaba Qwen
section: "TTL: 5 minutes")
- _supports_long_lived_anthropic_cache: Portal scope narrowed back to Claude
- tests: flip the two qwen long-lived expectations to False, retitle
non_claude_non_qwen_rejected -> non_claude_rejected