Commit graph

18193 commits

Author SHA1 Message Date
brooklyn!
b9ba7c78e4
Merge pull request #72345 from NousResearch/hermes/hermes-5ee2515d
feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
2026-07-26 22:08:34 -05:00
Brooklyn Nicholson
a94ec27f97 polish(desktop): tokenize artifact card font sizes, drop redundant hover class
- artifact card title/meta/open-hint now use --conversation-text-font-size /
  --conversation-tool-font-size like CodeCard and the rest of the transcript,
  instead of hardcoded rem literals
- drop no-op hover:border-border (border is border-border at rest)
- comment the deliberate raw bg-white + colorScheme:light on the sandboxed
  iframe so it doesn't read as an untokenized literal
2026-07-26 22:00:29 -05:00
hermes-seaeye[bot]
8c36cd4670
fmt(js): npm run fix on merge (#72411)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 02:52:53 +00:00
brooklyn!
af1cc1c245
Merge pull request #72388 from NousResearch/bb/desktop-render-salvage
perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep
2026-07-26 21:44:51 -05:00
webtecnica
56f312f28d fix(weixin): tighten TCPConnector keepalive to drain CLOSE_WAIT sockets
Behind proxies like Cloudflare Warp that leave peer-initiated FIN in
CLOSE_WAIT, aiohttp's default 30s keepalive_timeout lets idle sockets
accumulate. Use keepalive_timeout=2 + enable_cleanup_closed=True on the
weixin SSL connector so idle connections drain promptly (#18451 class,
related #69089).

Partial salvage of #70939: only the weixin connector hardening survives;
the PR's event-loop-freeze watchdog half is superseded by the loop-liveness
watchdog already merged on main (selector floor + thread-based probe, config-
gated via gateway.loop_watchdog).
2026-07-26 19:43:44 -07:00
teknium1
bd7938fa0c fix(gateway): keep _reconnect_watcher_task tracking the live task after a supervised respawn
Follow-up to the salvaged #71867 supervision fix. _spawn_supervised's own
backoff respawn created a new task without updating the external handle
self._reconnect_watcher_task, so after the reconnect watcher crashed and
self-restarted, _ensure_reconnect_watcher_running() saw the stale handle as
done() and spawned a SECOND concurrent watcher (double reconnect attempts).

Add an optional on_spawn callback to _spawn_supervised, fired with the live
task on every spawn INCLUDING internal respawns, and pass it at both reconnect-
watcher spawn sites so the tracked handle always advances. The two supervision
mechanisms (supervisor auto-restart + ensure-respawn) now compose instead of
racing. Regression test sabotage-verified.
2026-07-26 19:43:17 -07:00
ygd58
4b039e9543 fix(gateway): spawn platform reconnect watcher with task-level supervision
Fixes #71758.

A platform adapter that dies on a transient upstream failure (marked
retryable=True, e.g. photon's sidecar exiting when its upstream
gRPC/CDN returns errors) is correctly queued into _failed_platforms
for background reconnection. But the reconnect watcher task itself
was spawned via a bare asyncio.create_task -- if an exception ever
escaped its OUTER while-loop (not just the per-platform inner
try/except), the watcher died silently: no log, no restart.

_ensure_reconnect_watcher_running() already existed to respawn a dead
watcher, but it's only called from _handle_adapter_fatal_error_impl()
when a NEW platform's fatal error arrives. If the watcher dies while a
platform is already sitting in the queue and no OTHER platform ever
fails afterward, nothing ever notices the watcher is dead -- exactly
matching the reported symptom: photon queued for reconnect, the
gateway itself healthy (other platforms kept working, so nothing
re-triggered the ensure-alive check), and the platform stayed dead for
17.5h until a manual restart, well after the transient upstream outage
had recovered.

Fix: spawn the reconnect watcher via the existing _spawn_supervised()
task-level supervisor (already used for kanban_dispatcher_watcher,
handoff_watcher, etc.) instead of a bare asyncio.create_task, at both
the initial startup spawn and the manual-respawn path in
_ensure_reconnect_watcher_running(). _spawn_supervised already
provides exactly what's missing here: catches and logs any exception
escaping the task, and auto-restarts with capped exponential backoff
(healthy-run counter resets so a daemon that crashes occasionally over
days is never permanently abandoned) -- self-healing independent of
any new fatal-error event.

Also hardened a related race: the watcher's per-platform loop looked
up self._failed_platforms[platform] via direct indexing after
snapshotting the keys with list(...). A platform removed concurrently
between the snapshot and the lookup (e.g. a manual /platform resume,
or a reconnect that succeeded via a different path) would raise an
uncaught KeyError -- exactly the class of bug this fix's supervision
now catches, but avoiding the crash-and-restart cycle entirely is
better than relying on it. Changed to .get() with a skip-if-missing
guard.

6 new tests pass (initial spawn uses _spawn_supervised, manual respawn
uses _spawn_supervised, the core regression -- watcher self-heals
after an uncaught exception with no new fatal-error event -- and the
race-guard scenario); 52/52 in the full
tests/gateway/test_platform_reconnect.py file (including the 4
pre-existing _ensure_reconnect_watcher_running tests, confirming no
regression to that respawn-when-dead-or-missing behavior).
2026-07-26 19:43:17 -07:00
dsad
3c4220cd9c fix(agent): keep system cache breakpoints across provider failover
`apply_anthropic_cache_control` runs once per call block, before the retry
loop, and splits the system prompt into `[static prefix, volatile tail]` text
blocks carrying the cache_control breakpoints.

A failover fires *inside* that retry loop, and `_sync_failover_system_message`
assigns a bare string over `api_messages[0]["content"]` to refresh the
`Model:`/`Provider:` identity lines. That drops the block list and both
breakpoints. `convert_messages_to_anthropic` only emits system cache blocks for
list content (`isinstance(content, list)`), so the retried request ships
`system` as one plain string: zero breakpoints, nothing written to cache, and
the whole system prompt re-billed at full write price. The next call misses
too, so a failover costs two full-price prompts instead of one write + one
read.

Measured with the real functions, same history, native Anthropic layout:

  normal turn       system=BLOCK LIST(2)  system breakpoints=2
  after failover    system=plain string   system breakpoints=0

`_sync_failover_system_message`'s own docstring notes this fires on "every
gateway turn, since fallback re-activates per message while the primary is
down" -- so a gateway running on a degraded primary pays it on every message.

Fix: rewrite the decorated blocks in place instead of flattening them.
`rewrite_prompt_model_identity` only touches the LAST `Model:`/`Provider:`
lines, and those live in the volatile tail, so the static prefix stays
byte-identical and its cache entry keeps matching -- the failover retry keeps
both breakpoints AND the warm prefix. Shapes we cannot safely patch fall back
to the existing plain-string assignment.

This is the same class the retry loop already guards against eight lines below
the gap, for reasoning fields:

    # api_messages is built once, before this retry loop, while the primary
    # provider is active. [...] so the fallback request isn't sent with stale,
    # primary-shaped reasoning fields.
    agent._reapply_reasoning_echo_for_provider(api_messages)

There was no equivalent for cache decoration.
2026-07-26 19:42:43 -07:00
TheEpTic
c2ee5039ee fix(gateway): preserve media dedup after streamed replies 2026-07-26 19:31:08 -07:00
AIconcept Guru
47f5795046 fix(install): avoid realpath-dependent uv launcher on macOS 2026-07-26 19:30:32 -07:00
teknium1
71c9910ff5 test(telegram): regression for #71593 fallback-pool discard-on-failure
The salvaged fix (#71593) rebuilds Telegram fallback pools lazily and
discards+aclose()s a pool on retryable connect failure (_reset_fallback),
bounding each at Limits(max_connections=8) as a setdefault default. The PR
shipped no test.

Add tests/gateway/test_telegram_fallback_pool_release_71593.py:
  * failed fallback pool is aclose()d and dropped from _fallbacks (the
    discard-on-failure path — reverting the _reset_fallback call fails it)
  * a recovered pool is retained, only the failed one discarded
  * _reset_fallback is a no-op when the pool was never built
  * caller-supplied limits win over the _POOL_LIMITS setdefault default
  * the max_connections=8 default applies when the caller omits limits

Update the eager-build assumptions in test_telegram_network.py to the new
lazy contract (fallbacks materialize via _get_fallback, not in __init__).
2026-07-26 19:30:27 -07:00
alexneyman
ca2491f201 fix(telegram): release fallback transport pools on connect failure
The per-IP httpx transports were built once in __init__ and never torn
down. A connect that reached ESTABLISHED and was then closed by the peer
left its socket in CLOSE_WAIT inside the pool, and the failure path only
logged and continued — so the poisoned pool was retained and leaked one
descriptor per retry.

With DNS for api.telegram.org failing, every poll fell through to the
seed IP and leaked another fd every ~2.5s. The bot gateway reached 177
CLOSE_WAIT sockets against launchd's 256 soft limit and wedged: accept()
on the gateway port, config reads and DNS resolution all failed with
EMFILE, which in turn made the primary path fail and fed the loop.

Build fallback transports lazily and discard them on a retryable connect
failure, and bound every pool at 8 connections (httpx defaults to 100,
so two seed IPs plus primary could alone exceed the fd ceiling).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-07-26 19:30:27 -07:00
Frowtek
a228b81501 fix(sessions): preserve recently active sessions during pruning 2026-07-26 19:30:21 -07:00
Kyzcreig
769dba1758 fix(gateway): bound the startup-restore inbound gate on a slow boot-resume turn 2026-07-26 19:30:14 -07:00
dsad
0fb46b8185 fix(agent): keep the compaction summary when the turn-end override rewrites the user row
`_apply_persist_user_message_override` replaces the anchored user message's
content with the clean transcript text. Its DB-write twin refuses to do that
for a compaction-merged row, and says why:

    # Preflight compaction can re-anchor the override index at a message
    # whose content was MERGED with the compaction summary
    # (merge-summary-into-tail). Overwriting that with the clean gateway text
    # would silently drop the summary from the durable transcript.
    ... and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY)

The live mutation has no such guard, and `finalize_turn` runs it FIRST -- it
calls `_apply_persist_user_message_override(messages)` and only then
`_persist_session(...)`. So the row is already clobbered by the time the
DB-side guard looks at it, and the clobbered list is also what is returned as
the continuation history the next turn is built from.

The anchor lands on the merged row by design, not by accident:
`reanchor_current_turn_user_idx` prefers the last user row whose content
matches this turn's text and "fall[s] back to the last user message when no
exact match survives (merge-summary-into-tail rewrites the content ...)" --
after a merge the exact match cannot survive, so the fallback selects exactly
the merged row.

Effect: on the turn a compaction fires, the `[MERGED PRIOR CONTEXT]` summary --
the entire pre-compaction conversation -- is deleted from the history the
session continues from. It is silent, there is no error, and the archived
pre-compaction turns are already rotated away. The durable child row still has
the summary, so the live session and a later `/resume` now replay different
bytes for the same row.

Add the same guard to the live path. The paired timestamp override is
unrelated and still applies.

Both halves of this invariant are now covered:
`TestFlushCompressedSummaryOverrideGuard` already asserted it for the DB write;
the two new tests assert it for the in-memory path, plus a negative control
that an ordinary turn is still cleaned in place.
2026-07-26 19:30:08 -07:00
HexLab98
6290cd0d59 test(environments): cover multiline session env snapshot injection
Regression for #71296: dump+source must not execute continuation lines from
HERMES_SESSION_CHAT_NAME/USER_NAME, and the export snippet must unset by
name instead of grepping declare lines.
2026-07-26 19:30:02 -07:00
HexLab98
9677495004 fix(environments): exclude multiline session env from terminal snapshots
Line-based grep on export -p only drops the first declare -x line, so a
newline in HERMES_SESSION_CHAT_NAME/USER_NAME leaves shell payload in the
shared snapshot and runs it on the next source. Unset bridged vars in a
subshell before export -p instead (issue #71296).
2026-07-26 19:30:02 -07:00
menhguin
59482ea800 fix(skills): never change a skill's source registry on update
`hermes skills update` could silently replace a same-named skill from a
DIFFERENT registry — deleting the user's files and rewriting the lockfile's
recorded provenance. Two defects on main:

- tools/skills_hub.py: check_for_skill_updates fell back to *all* sources
  (`... or sources`) when no adapter matched the recorded source, so any
  registry with a same-named skill could satisfy the fetch and be reported
  as an update — silently reassigning provenance. Now reports the entry as
  `unavailable` instead of cross-registry fallback.
- hermes_cli/skills_hub.py: do_update called do_install with a bare, slash-
  less identifier and no source constraint, letting _resolve_short_name fuzzy-
  match a same-named skill in another registry. do_install now takes an
  optional source_id pin that ABORTS (rather than falls back) when no adapter
  matches, and do_update forwards the lockfile's recorded source as that pin.

Includes regression tests reproducing the cross-registry hijack.

Salvaged from PR #72216 onto current main; re-attributed to the human
contributor.

Co-authored-by: menhguin <menhguin@users.noreply.github.com>
2026-07-26 19:29:56 -07:00
dsad
c3e99fce49 fix(anthropic): keep the assistant cache breakpoint on the ordered-replay path
`apply_anthropic_cache_control` marks an assistant turn with non-empty text by
writing `cache_control` INTO `content` -- `_apply_cache_marker`'s list branch
puts it on the last content block, not at the top level.

`_convert_assistant_message`'s ordered-replay branch rebuilds the message from
`anthropic_content_blocks` and returns early. Its only cache sources are
`_relocated_replay_cache_control` (markers rescued from blocks the replay
sanitizer dropped) and the top-level `m["cache_control"]`; it never reads
`m["content"]`. So for an assistant turn that interleaves signed thinking with
a tool_use AND has preamble text, the breakpoint is dropped.

It is burned, not relocated: `_can_carry_marker` returns True for this message
(non-empty content), so the breakpoint budget already counted it. Hermes'
accounting believes the marker landed.

Measured with the real functions, native layout, same history, only
`anthropic_content_blocks` differing:

    normal path   4 breakpoints   assistant text block carries cache_control
    replay path   3 breakpoints   assistant carries NONE

Under the static-prefix layout only two conversation-tier breakpoints exist, so
this halves them. Nothing is logged and the request succeeds with identical
model output, which is why it survives: it recurs on every request for the life
of any Claude thinking+tools session, since the flag rides the message in
`agent.messages`.

#56195 fixed the complementary shape -- blank assistant content, where the
marker lands top-level -- and its regression test pins `"content": ""`. The
non-empty case is the one a Claude 4.5/4.6 tool turn normally produces (a
sentence of preamble before the call) and was never covered.

Harvest an in-`content` marker next to the existing top-level lookup and hand
it to the same `_apply_assistant_cache_control_to_last_cacheable_block` helper.
2026-07-26 19:29:51 -07:00
teknium1
236b1b56cd
feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
Substantial generated content (full HTML documents, large SVGs, long code)
now promotes out of the transcript into versioned, openable artifacts:

- lib/artifact-detect.ts: pure detection over fenced blocks — html docs,
  large standalone svg, and 48+ line / 3k+ char code fences become
  artifacts; prose/terminal/mermaid fences and small snippets never do.
  Titles derive from <title>/<h1>, filename comments, or declarations.
- store/artifacts.ts: per-session registry with content-hash dedupe and
  version history (same kind+title = one artifact the model iterates on),
  persisted to localStorage with per-session/per-artifact/byte caps.
- ArtifactCard (transcript): compact openable card replaces the wall of
  code; streaming shows shimmer + line count; versions accumulate
  automatically on completion but the rail only opens on click
  (offer, don't hijack). Reasoning scratchpads never register.
- ArtifactPane (right rail): artifact: tabs beside preview/file tabs with
  version stepper (v2 of 3 / Latest), PREVIEW/SOURCE toggle, copy,
  download (kind-aware extension), open-in-browser for HTML.
- Rendering: HTML runs in a sandbox=allow-scripts iframe (opaque origin,
  no network to the app, no top-nav); SVG is DOMPurify-sanitized with the
  same profile as the inline embed; source view reuses the windowed Shiki
  renderer so 5k-line artifacts scroll smoothly.
- Rail integration: artifact tabs participate in tab order, close-others/
  close-to-right, ⌘W, pane visibility, and reveal; gateway switches close
  open artifact tabs; a persisted artifact: active-tab id reconciles to
  preview on boot (artifact tabs are ephemeral).
- i18n: en/zh/zh-hant/ja/ar strings for card + pane.
- session-ref-open.test.tsx: mock now spreads the real module — the
  artifact card imports session-view, which needs $sessionStates.

Tests: artifact-detect (15), artifacts store (12), markdown-pipeline
integration (3); full desktop suite 3128 passed, electron project 751
passed, tsc no new errors.
2026-07-26 19:29:25 -07:00
teknium1
139282f241 chore: map contributors LeonSGP43 + spiky02plateau 2026-07-26 19:29:18 -07:00
teknium1
ebbcad26ce test(memory): cover mode-aware provider deps + force reinstall path 2026-07-26 19:29:18 -07:00
LeonSGP43
5645169c8f fix(update): refresh active memory provider deps after venv rebuild
Surgical reapply of #53505 by @LeonSGP43 onto current main (stale-base
cherry-pick conflicted in main.py):

- _refresh_active_memory_provider_dependencies() in hermes_cli/main.py,
  wired into BOTH the git-pull update path (after lazy refresh) and the
  ZIP update path — the provider's plugin.yaml bridge packages are not
  in extras or LAZY_DEPS, so the core reinstall could strip/downgrade
  them and the update flow never healed them (#53272 mem0ai).
- _install_dependencies(force=True) in memory_setup.py: hand every
  declared spec to the resolver on update so missing AND version-drifted
  packages are restored (no-op when satisfied).
- Skip guards: no provider / builtin store / memory.enabled=false.

Widened beyond the original PR for the #70636 half:
- _provider_pip_dependencies(): mode-aware expansion — Hindsight in
  local/local_embedded mode needs hindsight-all (daemon + embedder), not
  just the declared hindsight-client; setup installs it but plugin.yaml
  can't express it, so update-time healing previously missed
  hindsight-embed and the daemon stayed broken.
- Spec-aware import probing: version ranges in pip_dependencies
  (mem0ai>=2.0.10,<3) no longer break the pip-name -> import-name
  mapping.

Fixes #53272. Fixes the hindsight-embed half of #70636.
2026-07-26 19:29:18 -07:00
spiky02plateau
ab2c9289ca fix(hindsight): availability probe must cover the embedding stack for local modes
_check_local_runtime() only imported 'hindsight' and
'hindsight_embed.daemon_embed_manager', a strictly weaker import surface
than the embedded daemon actually needs: the daemon imports
sentence_transformers at startup (embeddings + reranker). When the
embedding stack is broken (e.g. a dependency conflict on a shared package
like huggingface-hub), the daemon can never start, yet is_available() and
'hermes memory status' still report Hindsight as available — every
retain/recall then fails silently.

Import sentence_transformers in the same probe so local/local_embedded
availability goes red with the real ImportError as the reason, letting
the agent degrade loudly instead of silently dropping memory. Local path
only; cloud mode is untouched and no network or model download is
triggered by the import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MN8RMDLwxCfFxwtADoEJJf
2026-07-26 19:29:18 -07:00
teknium1
623762f2f0 fix(deps): move CVE pins to current fixed versions so update stops downgrading patched envs
Adjusts the salvaged pin refresh (#60839 by @embwl0x) to the actually
mergeable versions and regenerates the lock:

- cryptography 46.0.7 -> 48.0.1 (GHSA-537c-gmf6-5ccf fixed in 48.0.1;
  49.x is NOT possible: msal caps <49 and alibabacloud-tea-openapi caps
  <49 — documented at the pin site). Also resolves the
  hindsight-api-slim>=48.0.1 conflict reported in Discord.
- starlette 1.0.1 -> 1.3.1 across core/web/mcp/computer-use/dev extras
  and LAZY_DEPS (fastapi accepts >=0.40, mcp >=0.27)
- python-multipart 0.0.27 -> 0.0.32 ([web] + tool.dashboard)
- uv.lock regenerated (tea-openapi 0.4.4->0.4.5 for the <49 crypto cap)

Keeps @embwl0x's anti-downgrade floor guard in test_packaging_metadata
with the corrected cryptography floor (48,0,1). Fixes #60685: a user env
already upgraded to these versions is no longer downgraded by
hermes update, because the pins now ARE those versions.
2026-07-26 19:29:07 -07:00
embwl0x
a48251c3dc fix(update): refresh cve dependency pins 2026-07-26 19:29:07 -07:00
teknium1
1959e2a6b6 test(deps): class invariant — every shared LAZY_DEPS exact pin must match uv.lock
Generalizes the huggingface-hub lockstep test (#72320) to the whole
LAZY_DEPS surface: any package exact-pinned in LAZY_DEPS that the core
lock also resolves must pin the SAME version, so hermes update's lazy
refresh can never churn or downgrade a shared package out from under
its other consumers (#60783 class, #31817 class).

Together with the anchor-based activation gate (previous commit,
salvaged from #27878 by @paralegalia), this closes both halves of
#44404: features no longer false-activate from shared transitives, and
even a feature that legitimately activates cannot move a shared package
away from the locked version.
2026-07-26 19:28:55 -07:00
Vite Balloons
2a55f33483 fix(update): avoid refreshing inactive lazy backends 2026-07-26 19:28:55 -07:00
Brooklyn Nicholson
3d6c32b061 perf(desktop): derive the tab menu's row narrowly instead of subscribing wholesale
SessionTabMenu subscribed to $sessions + $projectTree for values it
never rendered (the row was re-read imperatively), so every tab of every
tile re-rendered its menu wrapper on any session-list or project-tree
churn — for a context menu that is almost never open. Same class as the
TreeGroup fix (#72245): derive the three scalars the menu actually shows
(pinId, title, profile) behind a keyed bail-out, so the wrapper only
re-renders when one of them changes.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
132654cdec perf(desktop): finish narrowing the statusbar's store subscriptions
#72163 narrowed $focusedSessionState but left two whole-store reads in
the same hook paying the same price:

- $subagentsBySession: only two COUNTS are rendered, but the whole-map
  subscription re-ran the hook (rebuilding all ~9 statusbar items) on
  every subagent progress tick in any session. Select the two scalars.
- $sessions: only one row's started_at is read, but any session-list
  write (title update, poll refresh, archive) re-ran the hook. Select
  the one scalar.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
fc3af6095f perf(desktop): stop cross-session churn re-rendering every composer status stack
$statusItemsBySession rebuilt its whole output map — fresh arrays,
fresh item objects — on every recompute, and its inputs churn constantly
(subagent ticks, 5s background polls, todo updates, in ANY session). A
whole-map useStore in ComposerStatusStack then re-rendered every mounted
stack — one per open tile — on all of it, and the fresh item objects
defeated row memoization downstream.

Two halves, per the documented slice contract (use-session-slice.ts):

- producer: stabilize $statusItemsBySession per key — an unchanged
  session keeps its previous array and item objects, and a fully
  unchanged map keeps its previous reference so computed skips the
  notify entirely ('preserve reference identity on no-ops').
- consumer: the stack subscribes to its OWN session's slice via
  useSessionSlice instead of the whole map.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
6107624727 perf(desktop): stop the model-picker overlay re-rendering per streaming token
ModelPickerOverlay subscribed to $focusedSessionState whole — a
projection of $sessionStates, republished on every message delta — to
read two fields that essentially never change (model, provider). The
overlay is mounted app-wide and unconditionally renders the un-memoized
ModelPickerDialog (closed), so the focused session's stream re-ran the
dialog's full hook body ~30x/s.

Same defect class and same fix as the statusbar (#72163): select each
scalar through useStoreSelector so unchanged values bail out.
2026-07-26 21:05:55 -05:00
Brooklyn Nicholson
c74f48b62e fix(desktop): null-guard the rotation signal fired from ensureSessionState
The salvaged no-op-publish guard moved the compression-rotation signal
into ensureSessionState, where storedSessionId is string|null. A cleared
stored id is a detach, not a rotation — firing the event with a null
next id would send the route-follow effect chasing nothing (and tsc
rejects it). Guard on a real next id.
2026-07-26 21:05:55 -05:00
Andy
9aaabdcbf4 fix(desktop): cover nested terminal layout changes 2026-07-26 21:05:55 -05:00
Andy
651a313d2b fix(desktop): suspend decorative work when inactive 2026-07-26 21:05:55 -05:00
Ho Lim
5c8ac975e7 fix(desktop): invalidate terminal overlay position on layout mutations
Signed-off-by: Ho Lim <subhoya@gmail.com>
2026-07-26 21:05:55 -05:00
Ho Lim
7a5d534f5b fix(desktop): gate idle renderer loops
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-07-26 21:05:55 -05:00
蒋方明
f8a554bced fix(desktop): keep message component types stable across Thread re-renders
The component map Thread passes to the virtualizer listed the
onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent
re-render handed down a fresh callback identity, the memo rebuilt the map
and produced new component *types*, so React unmounted and remounted every
visible message. Async-rendered parts (shiki code blocks) collapsed and
re-expanded on each remount, making the whole thread visibly jump.

That is exactly what shipped in v0.15.1: the desktop controller passed an
inline arrow for onBranchInNewChat, and the 15s status-snapshot poll
re-rendered the controller, so threads with code blocks jumped every 15
seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP).
arrow away from regressing.

Route the callbacks through a ref so the component types survive any
parent re-render; only the callbacks' definedness stays a dep, because it
gates UI (the user-message Stop button). Add a regression test that fails
on the old code by asserting message DOM nodes keep their identity when
callback props change identity.

Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift
instrumentation confirms zero shifts over multiple poll cycles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:05:54 -05:00
webtecnica
571b75792c fix(desktop): stop renderer OOM from session.info heartbeat churn on $sessionStates
The renderer OOMs every ~60s because periodic ~1/s session.info
heartbeats churn the entire $sessionStates store on every tick even
when nothing changed. Each heartbeat:

1. Called updateSessionState with the running-test updater, which
   always returned a new spread object — even when busy state hadn't
   changed — because the updater param was already a fresh spread.

2. publishSessionState then spread the full $sessionStates record
   and set it, firing every computed atom ($workingSessionIds,
   $attentionSessionIds) and their subscribers on every heartbeat.

Over 60s × ~1/s heartbeat this continuous store churn creates
millions of short-lived objects, amplifies React re-renders, and
starves the GC, sending the renderer working set from ~350 MB to
1.2-5 GB before the OOM crash.

Fix (two changes):

1. updateSessionState: pass the raw previous state (not a spread) to
   the updater so it can return the same reference on no-op. Skip the
   store write, publishSessionState, and syncSessionStateToView when
   the updater returned the same reference. The rotation signal from
   storedSessionId changes is now emitted directly from
   ensureSessionState since publishSessionState (and thus
   handleTransition) is skipped on no-op.

2. publishSessionState: guard with `prev === state` reference check
   (belt-and-suspenders for any other caller).

Fixes #69016
2026-07-26 20:47:57 -05:00
teknium1
0fa5e41c86 feat(diff): cross-surface /diff with staged/all/session modes
Widen the cherry-picked /diff base (#4839 by @SHL0MS) into one
cross-surface implementation, folding in the review feedback and the
best ideas from the two sibling PRs (#22703, #53527):

- tools/working_diff.py: shared git collection layer — unstaged
  (default), staged, and all (vs HEAD) modes; untracked files folded in
  via `git diff --no-index` so new files appear as additions (Codex
  /diff parity); shlex-split arguments preserve quoted paths.
- CLI: handler moved to hermes_cli/cli_commands_mixin.py per the
  current god-file decomposition (dispatch stays in cli.py), renders
  through the rich console with a 400-line terminal-flood guard.
- Gateway: _handle_diff_command in gateway/slash_commands.py + dispatch
  in gateway/run.py; fenced ```diff output truncated to 60 lines /
  3000 chars before the platform senders apply their own per-platform
  message clamps (tool-progress-style layered truncation). Localized
  strings in all 17 locale catalogs.
- /diff session (from #53527): cumulative checkpoint-baseline diff of
  everything Hermes changed, via new CheckpointManager.session_diff();
  docstring records the retained-baseline approximation caveat from
  review. Works on both surfaces; degrades with an actionable message
  when checkpoints are off.
- Slack: /diff routed via /hermes diff (50-slash cap; keeps
  telegram-parity test green and /version native).
- Registry: cross-surface CommandDef with staged|all|session
  subcommands; docs: slash-commands reference (CLI + gateway tables +
  both-surfaces list) and hermes-agent skill reference.
- Tests: tests/tools/test_working_diff.py (real git repos),
  tests/hermes_cli/test_diff_command.py (real git + stubbed checkpoint
  manager), tests/gateway/test_diff_command.py (end-to-end handler,
  real checkpoint store), TestSessionDiff in
  tests/tools/test_checkpoint_manager.py.

Salvaged from the /diff PR cluster #4839 + #22703 + #53527.

Co-authored-by: Ninso112 <ninso112@proton.me>
Co-authored-by: Harshkamdar67 <harshkamdar67@gmail.com>
2026-07-26 18:28:20 -07:00
SHL0MS
88d45edca2 feat: add /diff command to show git changes in working directory
Shows staged and unstaged changes in the current working directory.
/diff shows stat summary + full diff, /diff --stat shows summary only.

Uses git diff directly — no checkpoint system required. Works in any
git repository.

Closes #4250
2026-07-26 18:28:20 -07:00
brooklyn!
2078af601a
Merge pull request #72346 from NousResearch/bb/desktop-perf-finish
perf(desktop): drag at 60fps with five streaming tabs
2026-07-26 20:15:35 -05:00
teknium1
d6fa2709de feat(cli): /focus — reduced-output view with hidden-line recovery and status indicator
Display-only port of Claude Code /focus; composes with existing /verbose tool-progress modes.
2026-07-26 18:10:34 -07:00
brooklyn!
e1ace0ac98
Merge pull request #72336 from NousResearch/bb/statusbar-prefs
Quieter status bar and sidebar counts
2026-07-26 20:10:16 -05:00
brooklyn!
6cea77303b
Merge pull request #72339 from NousResearch/bb/redirect-user-row
Preserve the original prompt when a mid-turn redirect corrects a turn
2026-07-26 20:09:46 -05:00
teknium1
a9cc0ac9ff fix(i18n): add the /context catalog block to ar.yaml (missed in the 17-locale sweep) 2026-07-26 18:06:21 -07:00
teknium1
07370a9dba feat(cli,gateway): unify /context into a visual context-usage breakdown
Extends the cherry-picked /context command (PR #52184) and prompt-size
attribution helpers (PR #66656) into one visual context view across
surfaces, and absorbs the per-component budget-visibility goal of the
/tokens proposal (PR #48470):

- agent/context_breakdown.py: pure renderers over the existing payload —
  a 5x20 glyph block grid (1 cell ~= 1% of the model window), an
  'Estimated usage by category' table with free space, and expanded
  per-skill / per-toolset listings via compute_context_details(), which
  reuses the prompt-size attribution mechanism (skills index-line bytes +
  registry tool->toolset map) converted to the same chars/4 heuristic.
- cli.py: /context [all] renders grid + category table (+ expanded
  listings) from the live agent and in-memory conversation history.
- gateway/slash_commands.py: /context appends the plain-text category
  table (no grid — monospace not guaranteed on messaging platforms);
  /context all adds the expanded listings. Fail-open: breakdown errors
  never break the gauge.
- hermes_cli/commands.py: /context gains the 'all' subcommand; /version
  demoted to /hermes version on Slack to keep the 50-slash cap.
- tests: renderer unit tests against synthetic payloads, registry test,
  gateway /context + /context all + failure-degradation handler tests.
- docs: slash-commands reference + CLI guide entries.

Read-only and locally computed: no provider calls, no prompt-cache impact.

Co-authored-by: RemyFevry <29257684+RemyFevry@users.noreply.github.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com>
2026-07-26 18:06:21 -07:00
joelbrilliant
8b3da145f1 fix(prompt-size): include names-only skills in breakdown 2026-07-26 18:06:21 -07:00
joelbrilliant
8b9423444e feat(prompt-size): per-skill and per-toolset token-cost breakdown
`hermes prompt-size` reported skills as one <available_skills> block total
and tools as one json-bytes total, so there was no way to see which
installed skill or toolset actually dominates the fixed prompt budget.

Add two additive breakdowns to compute_prompt_breakdown (hermes_cli/
prompt_size.py):

- toolsets_breakdown: each resolved tool is attributed to its single
  canonical registry toolset (registry.get_tool_to_toolset_map), summed by
  group. Fully attributable — the grand total equals the existing
  tools.json_bytes minus JSON array framing (2*count bytes).
- skills_breakdown: parsed from the rendered <available_skills> block, one
  entry per skill with two honest, distinct numbers — index_line_bytes (the
  always-on cost of listing the skill) and skill_md_bytes (on-disk SKILL.md
  size, the real read cost paid only on skill_view). Sorted largest-first
  by read cost.

render_breakdown prints both as sorted "Toolsets by size" / "Skills by
size" tables (skills capped at 20; --json carries them all). All existing
keys and output are unchanged.

Runs fully offline (dummy credentials, no network). Tests cover shapes,
largest-first ordering, per-tool attribution reconciling to the total,
namespaced-name parsing, and unmapped-skill handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:06:21 -07:00
CharlesMcquade
4fe2fecf54 feat(gateway): add /context command for a detailed context-window view
A dedicated /context (alias /ctx) gateway slash command that gives a full
context-window view with:

- Usage gauge: visual bar + fraction + percentage + headroom
- Auto-compression threshold and how far away it is
- Compression count and how much the last one freed
- Cumulative session throughput (explicitly labelled as throughput,
  NOT context size — each call re-sends the window)
- Cascading fallback: running agent → cached agent → SessionStore metadata
  → rough transcript estimate

Not included (per current-main design):
- Cache reporting removed: commit 446b8e239 intentionally removed cache
  reporting from user-facing surfaces because providers that omit cached-token
  details produce misleading values
- Sync DB calls replaced with async_session_store (current main requires
  AsyncSessionStore with await)

Also rewords the /status tokens line from 'Cumulative API tokens (re-sent
each call)' to 'Lifetime tokens billed: ... (not your current context size;
use /context)' to reduce the recurring confusion that the cumulative figure
is the current context window.

Fixes salvation of PR #52184 (salvage commit replaces a 12K-commit-behind
fork branch with a fresh implementation against current main, incorporating
reviewer feedback from @whoislikemiha and the hermes-sweeper).
2026-07-26 18:06:21 -07:00