* fix(desktop): recover stranded session windows when resume fails
Opening a session in a new window (or any routed resume) could latch the
thread loader on "session" forever — the reported "stays stuck loading,
even after a nap" bug. Two compounding causes:
1. use-session-actions.resumeSession's catch ran the REST transcript
fallback OUTSIDE its own try. When session.resume rejected AND the
fallback also threw (the common case on a wedged/unreachable backend),
the throw skipped setMessages and left activeSessionId null with an
empty transcript — exactly the state the loader gates on
(messagesEmpty && !activeSessionId), with no terminal/error state.
2. use-route-resume's self-heal could never re-fire: resumeSession sets
selectedStoredSessionIdRef synchronously at entry (before failing), so
stuckOnRoutedSession stays false, and on an already-open idle window
neither pathnameChanged nor gatewayBecameOpen fire again. The window
never retried — naps, focus, nothing recovered it.
Fix:
- Wrap the REST fallback in its own try so a fallback failure can't strand
the loader.
- Add $resumeFailedSessionId: armed on terminal resume failure, cleared at
the next resume's entry (and left clear on success).
- use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s)
that re-resumes while the routed session matches the failure flag, with a
fire-time liveness recheck so a recovered session isn't double-resumed.
Regression tests cover: fallback-wrap arming the flag without throwing,
flag cleared on success, retry fires on backoff, no retry for a
non-routed/recovered session, and the retry cap.
* feat(desktop): show error + manual Retry when resume retries exhaust
When a stranded session window's bounded auto-retry gives up (gateway
resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts),
the loader latched forever. Add a $resumeExhaustedSessionId atom armed at
the give-up point so the chat view swaps the perpetual spinner for an
explicit error state + manual Retry button. Retry / reconnect / reselect
clears the latch and resets the auto-retry counter for a fresh cycle; a
route-change away from the stranded session also clears it.
Distinct from $resumeFailedSessionId (armed during the backoff window) so
the error UI only appears once auto-recovery has actually given up, not
mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering
latch-arms-on-exhaustion, stays-clear-while-retries-remain, and
clears-on-route-change.
* fix(desktop): address review on stranded-resume recovery layer
Follow-up to review on #47655 (PR head 253bfc0e3). Four issues on the
recovery layer:
1. (blocking) Arm $resumeFailedSessionId only when the transcript is still
empty after the REST fallback ($messages.get().length === 0), matching the
atom's documented contract and the loader's messagesEmpty gate. Previously
armed on any resume-RPC reject regardless of fallback outcome, so a window
that recovered its history via REST still auto-retried and, on exhaustion,
blanked the visible transcript behind the error overlay.
2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId
armed->cleared edge so a manual Retry / reconnect / reselect on the SAME
stranded session gets a fresh backoff cycle, not a single one-shot attempt
that immediately re-arms the error. (Keyed on the exhausted latch rather
than the resumeFailedSessionId null->value transition the review suggested:
the auto-retry loop itself toggles resumeFailedSessionId every cycle, so
keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only
resumeSession clears the exhausted latch, making its clear edge the
unambiguous manual-retry signal.)
3. Advance retryAttemptRef only when the timer actually dispatches a resume,
not at schedule time. Prevents unrelated dep changes during the 1s-8s
backoff window (transient gatewayState flip, non-stable resumeSession) from
burning attempts and hitting MAX with fewer than 4 real resume attempts.
4. Drop unrelated blank-line-only insertions in store/session.ts and
use-session-actions.ts to keep the diff tight.
Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle;
no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook
suite (65) pass; tsc --noEmit clean.
---------
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
* fix(photon): preserve text in mixed iMessage attachments
When an iMessage bubble carried both text and an attachment, spectrum-ts'
inbound mapper returned only buildAttachmentMessage(...), dropping the user's
typed text before Hermes could see it. The Photon adapter then had no 'group'
content path, so the text was lost entirely.
- adapter.py: handle a new 'group' content type that flattens text + attachment
items, preserving the typed text alongside cached media (extracted shared
_normalize_binary_payload helper).
- sidecar: emit 'group' content in normalizeContent, and ship
patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper
(at npm postinstall AND at sidecar startup, so existing installs self-heal).
Windows robustness fixes on top of the original PR:
- The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which
never matches on Windows (file:/// + drive letter) — it silently no-opped.
Switched to pathToFileURL(argv[1]).href.
- The patcher matched \n-joined strings, so a CRLF checkout (Windows git
autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching
and restores the original EOL style on write.
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
* chore: map YuhangLin contributor email for attribution (#46513)
---------
Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
A failed turn leaves a red error banner inline in the transcript. These
errors are renderer-local state (never persisted) and stay pinned to the
message until the session is reloaded, so a stale, no-longer-relevant
error (e.g. a transient provider/inference error) lingers with no way to
clear it.
Add an 'x' dismiss button inside the existing MessagePrimitive.Error
block. Clicking it clears the error from BOTH the live $messages view
and the per-runtime session cache — the view first, because
preserveLocalAssistantErrors re-grafts any still-errored message it finds
in the view onto the next session.info flush, so clearing only the cache
would let the heartbeat resurrect the banner. A bare error placeholder
(no streamed content) is dropped entirely; a turn that streamed partial
output before failing keeps its text and just sheds the error.
The control only renders when an onDismissError handler is wired, so
secondary/embedded Thread usages are unaffected. Adds the dismissError
string to all four locales (en/ja/zh/zh-hant) and two behavior tests.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
A /title typed before any message in a fresh desktop chat could be silently
lost: the session DB row is deferred to the first prompt, so session.title
found no row, only stashed pending_title, and returned pending:true. It then
relied on a post-turn apply block to write the title. When that turn never
landed under the same session_key (or the apply path didn't fire), the title
was dropped and the sidebar fell back to the first-message preview — e.g.
"/title my-custom-name" then "hello" left the session titled "hello".
Mirror the messaging gateway's _handle_title_command: an explicit /title is
clear user intent, not an abandoned draft, so create the row up front
(_ensure_session_db_row) and set the title immediately via the profile-aware
_session_db handle, returning pending:false. This also fixes the frontend
symptom for free — the desktop handler's immediate refreshSessions() now pulls
the correct persisted title instead of clobbering the optimistic value with a
still-NULL row.
If row creation can't take (DB unavailable / racing writer), fall back to the
existing pending_title queue so the post-turn apply block remains a recovery
path. The sidebar's min-messages filter keeps a titled 0-message row hidden, so
a /title'd-but-never-used draft still doesn't clutter the list.
Updates the test that asserted the old queue-on-missing-row behavior and adds a
fallback-to-queue regression test.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
* feat(search_files): path-grouped lossless densification of content matches
Content-mode search_files results repeat the {path,line,content} JSON keys
and the full path string for every match. Group consecutive same-path matches
under one path header with indented '<line>: <content>' rows — lossless (every
path/line/content byte preserved), self-describing (matches_format key), and
readable by the model with no decode step.
57.8% mean token reduction on real search_files content outputs (422-output
corpus), fires on 97% of them. Gated at >=5 matches; below that the verbose
array is left untouched. Default to_dict(densify=False) is unchanged, so no
other caller is affected.
ripgrep emits matches path-ordered, so consecutive grouping never reorders
results.
* test: accept densify kwarg in _FakeSearchResult.to_dict
The search loop-detection tests stub SearchResult with a fake whose
to_dict() must mirror the real signature now that it takes densify=.
* test(search_files): edge-case losslessness battery for densification
Adversarial single-line content (colons, indentation, unicode/emoji, empty,
trailing whitespace, quotes+commas), paths with spaces, and an explicit
one-line-per-match invariant documenting the ripgrep contract the format
relies on (0/6775 real match contents contained a newline).
* fix(logging): alias RotatingFileHandler to concurrent-log-handler
On Windows, stdlib RotatingFileHandler.doRollover() uses os.rename(), which
fails with PermissionError [WinError 32] whenever another process holds an
append-mode handle on agent.log — essentially always in Hermes (TUI, gateway,
hy_memory server, MCP servers, and on-demand CLI commands all log from separate
processes). This pinned agent.log at the 5 MiB threshold and spammed stderr
with a traceback on every emit (#44873).
Add concurrent-log-handler==0.9.29 as a core dep and alias its
ConcurrentRotatingFileHandler as RotatingFileHandler in hermes_logging.py. It
wraps the rename in a cross-process file lock (via portalocker: pywin32 on
Windows, fcntl on POSIX) so only one process rotates at a time. Aliasing keeps
every existing isinstance/class-declaration reference working unchanged.
Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com>
* fix(logging): gate concurrent-log-handler swap to Windows only
The initial salvage aliased RotatingFileHandler -> ConcurrentRotatingFileHandler
unconditionally, which regressed POSIX: CLH opens lazily and rotates via its own
lock path, breaking managed-mode (NixOS) group-writable perms and eager file
creation that _ManagedRotatingFileHandler depends on. CI caught it as 2 failures
in test_managed_mode_*_group_writable on Linux.
The WinError 32 bug (#44873) is Windows-specific — POSIX renames an open file
fine, so stdlib already works on Linux/macOS. Gate the swap behind
sys.platform == 'win32': Windows uses CLH, POSIX keeps stdlib RotatingFileHandler.
- hermes_logging.py: platform-conditional import.
- tests/test_hermes_logging.py: import RotatingFileHandler from hermes_logging
(single source of truth) so the autouse fixture's isinstance checks match the
real handler class on both platforms.
- pyproject.toml/uv.lock: mark the dep 'sys_platform == "win32"' so portalocker
/pywin32 only ship where used.
---------
Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Follow-up hardening on @ehz0ah / @harshitAgr's session-switch work (#28296):
- on_session_switch no longer runs the old-session writer-drain + pending-token
GET + commit POST inline on the caller's command thread. /new, /branch,
/resume, /undo call it synchronously, so a slow drain (up to 10s) or wedged
commit blocked the user-facing command — the same hazard #41945 fixed for
end-of-turn sync. State now rotates synchronously (cheap) and the old-session
commit is offloaded to a daemon finalizer (generalized _finalize_session_async).
- Guard the (_session_id, _turn_count) pair with _session_state_lock: sync_turn
runs on the memory-manager executor thread while the session hooks run on the
command thread, so the snapshot+reset vs increment was a cross-thread race.
- _session_needs_commit checks the committed-session guard BEFORE the
turn_count>0 shortcut, closing a double-commit window when a racing sync_turn
re-increments after commit+reset.
- Add a _shutting_down flag so deferred finalizers stop POSTing against a
torn-down client; track all prefetch threads in a set so invalidate/shutdown
join every one, not just the latest slot.
Tests: regression for the non-blocking switch (asserts the caller returns while
a slow drain is parked off-thread) and the committed-guard ordering; updated the
deferred-commit test to the unified finalizer contract.
* fix(desktop): pin Electron below the broken native extract-zip install
The Windows desktop install fails at "Building desktop app": Electron's
postinstall aborts with `ERR_DLOPEN_FAILED loading
index.win32-x64-msvc.node` / "Cannot find native binding" from
`@electron-internal/extract-zip`.
Root cause is a dependency drift, not the user's machine. Electron changed
its install mechanism mid-patch-series:
electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS)
electron 40.10.3 / 40.10.4 -> @electron/get@^5 + @electron-internal/extract-zip@^1 (native napi)
apps/desktop declares `electronVersion: 40.9.3` (the tested, JS-extract
build) but pinned the dependency as `electron: ^40.9.3`, so `npm ci`/`npm
install` silently resolved 40.10.3/40.10.4 — onto the brand-new native
extract-zip whose win32-x64 binding fails to dlopen on some Windows hosts.
The committed lockfile already carried 40.10.3, and the installer's mirror
fallback can't help (it re-runs Electron's own `install.js`, which uses the
same broken native module).
Fix:
- Pin `electron` to an exact `40.10.2` — the newest build before the native
extract-zip switch — and align `build.electronVersion` to match (Electron
Builder needs electronVersion/electronDist to match the installed binary).
- Add a root `yauzl: ^3.3.1` override so the (re-introduced) JS extract-zip
path also works on Node >= 24.16 / >= 26.1, where the old yauzl hangs.
This is the same workaround the wider Electron ecosystem adopted.
- Regenerate package-lock.json: drops @electron-internal/extract-zip and
@electron/get@5, restores @electron/get@2 + extract-zip@2 + yauzl@3.4.0.
* test(desktop): lock the Electron pin/version/lockfile consistency contract
Guards against the dependency drift that broke the Windows desktop install:
the Electron dependency must be an exact version, must equal
build.electronVersion, and the lockfile must resolve to that same version so
`npm ci` installs exactly what electron-builder packages. Asserts the
relationships, not a specific version number.
* fix(desktop): keep streaming painting in unfocused secondary chat windows
The chat transcript streams to screen through a requestAnimationFrame-gated
flush, which Chromium pauses for blurred/occluded windows. The primary window
opted out with `backgroundThrottling: false`, but the secondary "session
windows" (cmd-click pop-out, new-session, subagent-watch) hand-copied their
webPreferences and silently lost that flag — so a streamed answer in one of them
stalled until the window regained focus (reported on Windows 11). The primary
window's own comment even claimed it was "matching the secondary windows," which
was no longer true.
Hoist the chat-window webPreferences into a single shared factory
(`chatWindowWebPreferences`) in session-windows.cjs and use it for BOTH windows,
so they can never drift on this flag again.
* test(desktop): assert chat windows disable background throttling
Cover chatWindowWebPreferences: it must set backgroundThrottling=false (so the
streaming transcript paints while the window is blurred) and pass the preload
path through while keeping the hardened defaults (contextIsolation, sandbox,
nodeIntegration=false).
The model is callable via xAI OAuth but omitted from models.dev and
/v1/models listings. Merge it into the curated xAI catalog so it appears
in `hermes model` without requiring a custom model name.
Avoid applying text-only persist_user_message overrides to multimodal current-turn user messages. Early crash-resilience persistence mutates the same messages list later used for the API call, so clobbering list content drops ACP image blocks before model dispatch.\n\nAdd regression coverage for both text override behavior and multimodal preservation.\n\nCloses #44242
* feat(mcp): raise default tool-call timeout 120s -> 300s
Port from openai/codex#28234. Long-running MCP tools (web fetches,
sandboxed builds, deep-research servers) routinely exceed 120s, causing
spurious timeout failures. Codex bumped its default MCP tool timeout from
120 to 300 for the same reason.
- _DEFAULT_TOOL_TIMEOUT 120 -> 300 in tools/mcp_tool.py (per-server
'timeout' config override unchanged)
- update test_default_timeout assertion
- document the default in mcp-config-reference.md
* refactor: remove agent-callable send_message tool
The agent should not decide on its own to fire off cross-platform
messages or reactions. Outbound platform messaging is handled outside
the agent loop — cron delivery, the gateway kanban notifier
(dashboard-toggled), and the `hermes send` CLI.
Removes the model-tool registration only; the send engine in
send_message_tool.py (_send_to_platform, _send_via_adapter,
_parse_target_ref, per-platform _send_* helpers) is kept intact for
those non-agent callers. Drops the now-empty 'messaging' toolset and
its `hermes tools` toggle. Yuanbao DM guidance now points at the
native yb_send_dm tool.
A multi-MB message (logged bundle, huge tool dump) froze the renderer
before any paint: Streamdown runs `preprocess` + `marked` lex over the
whole string synchronously in a useMemo, an uninterruptible long task
that no try/catch or content-visibility can help (our JS runs before the
browser ever skips layout). Tiered fix:
- Message gate: past 200KB, bypass markdown entirely and render the raw
text in `content-visibility:auto` line-chunks — synchronous work is
bounded to a string split, the browser virtualizes layout natively,
and every line stays in the DOM (selectable, find-in-page).
- Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a
span per token) and render plain, chunked the same way.
- Collapse/expand: a reusable ExpandableBlock clamps code blocks and the
huge-text fallback to a 120px preview with a gradient + chevron,
expanding to 300px. The inner element is always a scroll container so
the content-visibility chunks stay lazily laid out in both states.
No content is ever dropped; the copy button (card header) always yields
the full block.
restore_skill() falls back to p.name.startswith(f"{skill_name}-") when no
archive directory matches the requested name exactly. That fallback is meant
to catch the timestamped duplicate archive_skill() writes on a name collision
(<skill>-YYYYMMDDHHMMSS), but the bare prefix also matches any unrelated
archived skill named <name>-something. So restoring "git" can pull an archived
"git-helpers" out of .archive/, rename it to "git", and report success: the
requested skill is not restored and the sibling is gone from the archive.
Constrain the fallback to the exact suffix archive_skill() produces, a 14 digit
timestamp. The exact-name match and the recursive nested-archive walk are
unchanged, so nested and timestamped restores still work; unrelated siblings no
longer match.
Fixes#47647
The OpenAI device-code login (POST auth.openai.com/.../deviceauth/usercode)
had no retry or 429 handling — a transient throttle from OpenAI surfaced as
a bare "Device code request returned status 429" with no guidance, reading
as a hard login failure.
- Retry the device-code request with capped exponential backoff (honoring
Retry-After), up to 4 attempts.
- On persistent 429, raise a clear AuthError tagged CODEX_RATE_LIMITED_CODE
(classified transient, not a credential problem) with a wait hint.
- Apply the same 429 classification to the token-exchange step (same bug
class).
Unrelated to PR #47399 (Responses-API cache headers); this is the OAuth
device-code path in hermes_cli/auth.py.
get_runtime_status_running_pid() validates liveness with a local
os.kill(pid, 0) probe. In /api/status the runtime record can be the
REMOTE health-probe body (cross-container), whose PID belongs to another
host and is display-only — probing it locally is wrong and trips the
test live-system guard (os.kill on a PID outside the test subtree).
Run the fallback only against the local read_runtime_status() record.
_profile_scope swaps process-global skills_tool/skill_manager module
attrs under an RLock; /api/status holds that scope across the
run_in_executor remote-health probe await, so a concurrent
/api/skills?profile=X request can cross-restore the status profile's
skill dir on its finally. Add _config_profile_scope (contextvar-only,
task-local, await-safe) and use it for status, which only resolves
get_hermes_home() at call time for config/env/gateway state and never
needs the skills-module globals.
Context files (AGENTS.md, CLAUDE.md, .hermes.md, .cursorrules, SOUL.md) were
hard-capped at a flat 20K chars before head/tail truncation. Among the agent
harnesses we track, only Codex caps project docs at all (32 KiB); Claude Code,
OpenCode, and Cline load them whole. The flat 20K predates large context
windows and silently truncates real-world AGENTS.md files.
B — dynamic cap: when context_file_max_chars is unset (now the shipped
default), the cap scales with the model's context window
(ctx_tokens * 4 * 0.06, floor 20K, ceiling 500K). Small-context models stay at
the historical 20K; a 200K model gets 48K; large models stop truncating real
docs. An explicit context_file_max_chars still wins. Context length is resolved
once per conversation (stable -> prompt cache untouched).
C — when truncation does happen, the marker now names the concrete file path
and tells the agent to read_file it for the full content.
Validation: 154 targeted tests + full agent/ + hermes_cli/ + test_config
(0 failures); E2E against a real 60K AGENTS.md confirms small windows truncate
with the path-bearing marker, large windows load whole, and the system prompt
is byte-stable across rebuilds.
unicode-bidi:plaintext (#44596) resolves text direction per line, but
list markers and the blockquote border are box chrome driven by the CSS
direction property, which plaintext never sets, so an RTL list renders
its numbers stranded at the far left edge. CSS cannot close this gap
(:dir() only reads the dir attribute, never plaintext resolution), so
ul/ol/blockquote carry dir="auto" and the browser resolves their box
direction natively while the plaintext rules keep owning the text.
Inline code carries dir="ltr", which HTML's auto algorithm skips,
matching the no-vote contract the CSS isolate already gives it.
Rolling back to the oldest curator snapshot failed and deleted that
snapshot. rollback() takes a safety snapshot first, and snapshot_skills()
ends by pruning the backups directory down to keep (5 by default). At the
steady keep limit that prune removed the oldest snapshot, which is the very
one being restored, so the extract found no skills.tar.gz and the rollback
stopped with "snapshot extract failed (state restored)".
Thread an optional protect set through snapshot_skills() into _prune_old()
so the pre rollback safety snapshot can never evict the snapshot being
restored. Add two regression tests covering restore of the oldest snapshot
at the keep limit.
Fixes#47612
The curator now defaults to prune-only: the deterministic inactivity pass
(mark stale / archive long-unused skills) still runs whenever the curator is
enabled, but the opinionated LLM umbrella-building consolidation fork is OFF
by default.
- agent/curator.py: add DEFAULT_CONSOLIDATE=False + get_consolidate(); gate
the forked aux-model review in run_curator_review behind it (new consolidate
param, None=read config). When off, the LLM pass is skipped entirely (no
aux-model cost); the run is still recorded and reported.
- config.py: add curator.consolidate (default false); v29->v30 migration seeds
the key for existing installs without clobbering a user-set value.
- hermes_cli/curator.py: 'hermes curator run --consolidate' override; status
shows consolidate state; prune-only notice on run.
- docs + tests.
When refresh_launchd_plist_if_needed() runs from inside the gateway's own
launchd process tree (agent-initiated self-update via the terminal tool), a
direct launchctl bootout tears down the service's process group — including
the CLI doing the refresh — before the follow-up bootstrap can run. The
gateway is left unloaded and KeepAlive can't revive it (#43842).
Detect in-service execution via gateway.status.get_running_pid() +
_is_pid_ancestor_of_current_process(), and delegate the bootout->bootstrap to
a detached (start_new_session=True) helper that survives the process-group
teardown. The normal out-of-tree CLI path is unchanged.
Fixes#43842.
A turn that ends in an error (e.g. an out-of-funds state) was being
re-rendered in unrelated threads. On a warm thread switch the on-screen
`$messages` still belongs to the previously viewed thread, and
`flushPendingViewState` fed it into `preserveLocalAssistantErrors`, which
grafted the prior thread's failed turn onto the newly opened one. Because
the polluted view then became the next switch's baseline, the error
cascaded into every thread the user visited.
Only carry local errors across a view flush when the on-screen baseline is
the same session being flushed; the cached state we publish already retains
that session's own errors. Also surface the turn error as a global toast
even when the failing turn ran in a background thread, since the error
blocks all subsequent interactions until the user acts.
The double-underscore prefix swap fixed bare native tools but SKIPPED tools
already named mcp_<server>_<tool> (real MCP servers, e.g. mcp_linear_get_issue):
they went on the OAuth wire single-underscore and still tripped Anthropic's
third-party billing classifier -> HTTP 400 'extra usage, not plan limits'.
Verified empirically against a live Max subscription: a single mcp_ tool flips
the whole request to the extra-usage lane; mcp__ is accepted.
- build_anthropic_kwargs: promote ANY leading single-underscore mcp_ to mcp__
(bare names -> mcp__name; mcp_<server>_<tool> -> mcp__<server>_<tool>),
never double-prefixing an already-mcp__ name. Same for tool_use blocks in
history.
- normalize_response: reverse the mcp__ wire name back to whichever original
the registry knows — the single-underscore mcp_<server>_<tool> form for MCP
server tools, or the bare name for native tools — preferring a name that
already resolves natively.
- Tests rewritten to assert the invariant: ZERO single-underscore mcp_ names
reach the OAuth wire, and the mcp__ round-trip resolves back to the
registered name for both native and MCP-server tools.
Builds on liuhao1024's mcp__ prefix commit (cherry-picked). Closes the
MCP-server gap that left any session with an MCP server configured still
billing to extra usage.
Anthropic's Claude-Code request classifier treats tool names with a
single-underscore `mcp_<x>` prefix as non-Claude-Code / third-party,
routing the request to extra-usage billing (HTTP 400). Real Claude Code
uses double underscores: `mcp__<server>__<tool>`.
Change the tool-name prefix from `mcp_` to `mcp__` in both the outgoing
path (build_anthropic_kwargs) and the incoming path
(normalize_response). Update the skip-guard to check for both `mcp_`
and `mcp__` prefixes so native MCP server tools (which use the legacy
single-underscore format) are not double-prefixed.
Fixes#46675
`hermes login` was removed in favor of `hermes auth` / `hermes model`, but
the subparser still validated `--provider` against a hardcoded choices list
(nous, openai-codex, xai-oauth). Running `hermes login --provider anthropic`
therefore crashed in argparse with `invalid choice: 'anthropic'` *before* the
deprecation handler could print the redirect to `hermes model` — so a user
trying to authenticate a perfectly valid provider just saw a hard error and
assumed the feature was broken rather than relocated.
- Drop the restrictive `choices=` so every `--provider` value reaches the
deprecation handler (which ignores the value and prints guidance).
- Omit the subparser `help=` kwarg so the dead command no longer advertises
itself in `hermes --help` (#24756). Avoids the `==SUPPRESS==` placeholder
leak that `help=argparse.SUPPRESS` emits for a top-level subparser on 3.12+.
- `hermes login [--flags]` still reaches the actionable deprecation message
for old scripts/aliases; `hermes login --help` shows the redirect.
Picks up the intent of the inactivity-closed #24902, rebased onto the
post-refactor parser location (hermes_cli/subcommands/login.py) and extended
to fix the whole bug class (any provider value), not just hiding from --help.
Tests: parametrized provider acceptance + help-suppression (no SUPPRESS leak).
The docstring described a token as path-like when it contains a "/"
separator, but the keystroke-latency fix now excludes "://" scheme tokens
(URLs) even though they contain "/". Document the exclusion so the contract
matches the behavior.
Regression coverage for the keystroke-latency fix: a URL token contains
"/", so the bare-slash path heuristic used to return it as a path word and
run os.listdir on every keystroke. Assert _extract_path_word rejects
http/https/ssh scheme tokens, that ordinary paths (incl. a bare colon) are
unaffected, and that the completer never touches the filesystem for a URL
under the cursor.
The interactive CLI input box runs its completer with
`complete_while_typing=True`, so `SlashCommandCompleter.get_completions`
is invoked on *every* keystroke. That completer does blocking I/O:
fuzzy `@`-file indexing shells out to `rg`/`fd` (up to a 2s timeout) and
file-path completion calls `os.listdir` + `stat`. Because the completer
was passed inline (never wrapped in `ThreadedCompleter`), all of this ran
synchronously on the prompt_toolkit event loop, stalling the render after
each key — very noticeable on WSL2 and other slow-filesystem setups
("typing in the prompt box being very latent").
Two fixes:
- Wrap the input completer in `ThreadedCompleter` so completion work runs
off the UI event loop and never blocks rendering between keystrokes.
- Stop treating URLs as file paths in `_extract_path_word`: a token like
`https://example.com/x` contains `/`, so it triggered `os.listdir` on
every keystroke while typing/pasting a link (listing a bogus `https:`
dir) for a completion that can never be useful. Skip any token with a
`://` scheme separator.
(cherry picked from commit b5be2ba276)
Streamdown runs our `preprocess` inside its own useMemo, and the user
bubble runs `extractEmbeddedImages`/directive parsing inside theirs — so
anything thrown while rendering one message (a regex/stack overflow on
adversarial content) escapes to the ROOT error boundary and takes down
the entire app, as seen in a reported `RangeError: Maximum call stack
size exceeded` from a single message.
Wrap both the assistant preprocess pipeline and the user-message
directive passes in try/catch that degrade to the raw text. One bad
message now renders plain instead of nuking the transcript.
`normalizeFenceBlocks`/`pushProseFence` appended block bodies with
`out.push(...lines)`, which spreads every line as a separate call
argument. A single message carrying a large fenced block (a logged
minified bundle, base64 blob, or big tool dump — common in long
sessions) overflows V8's argument-count limit and throws
`RangeError: Maximum call stack size exceeded`, breaking the transcript
render. Compression doesn't save us: it gates on tokens vs. window, not
a single message's line count, and the protected recent tail renders
verbatim regardless.
Append iteratively via a small `extend()` helper. Behavior is identical
for normal-sized blocks.
sync_turn's bounded join could drop a still-alive previous worker by
replacing the single _sync_thread slot. The dropped worker kept POSTing
under the old sid but was no longer visible to on_session_end /
on_session_switch, so the commit could fire while orphaned writes were
still in flight — those writes landed past the commit boundary and were
never extracted.
Replace the single _sync_thread slot with _inflight_writers:
Dict[sid, Set[Thread]]. Writers self-register on spawn (sync_turn,
on_memory_write) and self-deregister on exit. The commit path drains
_drain_writers(sid, 10.0) and skips the commit if any writer for that
sid is still alive after the bounded budget.
Also trim inline review-rationale comments to short invariants per
reviewer style ask: "commit only after session writes drain" and
"drop prefetch results from older switch generations."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7537ee6f5b)