Commit Graph

2361 Commits

Author SHA1 Message Date
hermes-seaeye[bot] 2d5e93161b
fmt(js): `npm run fix` on merge (#81589)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 08:15:17 +00:00
kshitij ad59bd92c7 test(desktop): align renamed message-fetch mocks; brace query-param guards
The salvage's getLatestSessionMessages/getAllSessionMessages split left
two desktop test files mocking the old getSessionMessages name (vi.mock
partial-mock let the real function through, so calls hit the un-mocked
path). Also braces the new single-line if guards per the curly lint
rule.
2026-08-08 13:36:08 +05:30
kshitij 2607dc9a85 fix(desktop): forward pagination params through the remote session interceptor
The remote interceptor rebuilt session/messages requests from pathname
only, silently dropping limit/offset/order. Against a paginating remote
backend, getAllSessionMessages would refetch the same default page until
the safe-load guard threw, breaking export/artifacts/branch for remote
sessions over one page.
2026-08-08 13:36:08 +05:30
kinsolee c750d5354a fix(sessions): prevent oversized transcripts from exhausting memory 2026-08-08 13:36:08 +05:30
hermes-seaeye[bot] 005421d888
fmt(js): `npm run fix` on merge (#81276)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-07 19:02:38 +00:00
brooklyn! 33edfe9a81
Merge pull request #81261 from NousResearch/bb/desktop-stale-session-recovery-consolidation
fix(desktop): recover every session-scoped RPC after a stale runtime-session drop
2026-08-07 12:54:47 -06:00
hermes-seaeye[bot] 8370141f1c
fmt(js): `npm run fix` on merge (#81259)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-07 18:27:48 +00:00
bb 37aaecf48b test(desktop): cover the stale-session recovery bug class
Table-driven across every session-scoped RPC that can hit a dead runtime
id, plus the invariants the scattered copies disagreed on: resume targets
the session-owning profile (never forks into the active one), the recovered
id publishes exactly once, drift during the resume aborts instead of
retrying, a resume that itself 404s rethrows the ORIGINAL error, and
recovery is bounded to a single retry.

These run without touching the REST layer because profile resolution is
injected — the pre-consolidation helper reached through resolveStoredSession
-> getSession(), which made its coverage depend on store state left behind
by whichever test file ran first (passed alone, timed out at 15s alongside
index.test.tsx).

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: rapsealk <rapsealk@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-08-07 13:24:10 -05:00
bb c1305b645d fix(desktop): recover checkpoint restore and tile actions after a stale session drop
Same bug class, remaining call paths. runRewindSubmit handled only
"session busy" — a rewind runs right after cancelRun, and interrupting can
drop the gateway's session, so "Restore checkpoint" after a stop hit a dead
runtime id and surfaced the raw error. The session-tile delegate's
interrupt/submit had no recovery either, so a tile left open across sleep
was dead until reopened.

Both now use the shared resolver. The tile delegate resolves its durable id
by reversing the stored->runtime cache and repoints that mapping on
recovery, so later tile actions use the live binding instead of recovering
on every call.

Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
Co-authored-by: akivavh <akivavh@users.noreply.github.com>
2026-08-07 13:24:10 -05:00
bb 3c4f5c5217 fix(desktop): recover image/file attach and /compress after a stale session drop
After sleep/wake, a long idle, or a remote backend restart, the gateway
drops its in-memory runtime session while the desktop still holds the old
id. prompt.submit already recovered, so plain text kept working and the
failure looked selective: attaching an image or running /compress died
with a bare "session not found" and a new chat was the only workaround.

Attach runs BEFORE prompt.submit, so submit's recovery never got a
chance — image.attach_bytes / image.attach / file.attach failed first.
Route them (and session.compress) through the shared resolver, and thread
the recovered id back to the caller so the follow-up submit targets the
live session instead of the dead one.

Attachment bytes are read once, outside the retry, so recovering a large
upload doesn't re-read it. /compress deliberately does NOT opt into
timeout recovery: it is legitimately LLM-slow and retrying a timeout
would double a minutes-long call.

Reported-by: bapemonkey (Discord)
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: zzz163519 <zzz163519@users.noreply.github.com>
Co-authored-by: luxles <luxles@users.noreply.github.com>
2026-08-07 13:24:09 -05:00
bb fab99e828a refactor(desktop): one resolver for stale runtime-session recovery
main carried three hand-rolled copies of the same recovery policy —
prompt.submit (submit.ts), session.interrupt (cancelRun) and
session.redirect (steering) each open-coded
isSessionNotFoundError -> resolveSessionProfile -> session.resume ->
retry. Three copies of one policy is how call sites drift apart, and it
is why the RPCs that were never given a copy (attach, /compress,
checkpoint restore) still surface a raw "session not found" after
sleep/wake while plain text silently recovers.

Introduce withSessionNotFoundResume() as the single resolver and move
all three existing copies onto it. Profile resolution is injected rather
than imported so the helper is unit-testable without reaching through
resolveStoredSession -> getSession(); a drift callback lets each caller
keep its own abort semantics via SessionRecoveryAborted.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
2026-08-07 13:24:09 -05:00
brooklyn! 10a2b3d7a2
Merge pull request #81247 from NousResearch/bb/desktop-files-pane-cwd-ownership
fix(desktop): rebind the Files pane workspace when switching sessions
2026-08-07 12:18:39 -06:00
bb cdc10cd784 test(desktop): cover the Files-pane cwd desync bug class
Eight cases across the whole class, not just the reported path:

- cold resume rebinds from the selected row before resume settles
- an empty runtime cwd releases ownership (the permanent-staleness half)
- releasing leaves the PATH intact, so panes don't collapse and the persisted
  workspace survives
- a session row outside the loaded sidebar page doesn't blank the pane
- a non-git workspace with a null git_repo_root still uses its row cwd
- the branch label clears so the previous project doesn't leak

Verified as a real barrier: reverting utils.ts fails 5 of these.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
2026-08-07 13:07:11 -05:00
bb 9cdbeceda4 fix(desktop): don't let a named session.info rehome a fresh draft
`session.info` claimed the cwd for whatever the selection happened to be, so a
background tile's payload could re-point a fresh draft at the tile's workspace.

Treat a nonempty `stored_session_id` as non-matching when no primary session is
selected; only an ABSENT id uses the selected-session fallback (the backend
omits it on a lazy session, and refusing there would leave the workspace
un-owned for the rest of the conversation).

Matching goes through the lineage rather than raw string equality: the backend
id is the live session_key, which auto-compression rotates to the continuation
tip, while a selection made from a pinned row holds the stable lineage root.
Comparing those literally reads one conversation as two.

Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
2026-08-07 13:06:55 -05:00
bb 416e025c46 fix(desktop): rebind the Files pane cwd when switching sessions
Two defects left the Files pane showing the previous project's tree:

- `applyStoredSessionPreviewRuntimeInfo` reset every composer atom EXCEPT cwd,
  and runs before the `session.resume` RPC. The sidebar row already knows the
  conversation's workspace (`cwd` is in the compact row projection), so mirror
  it on the same tick the selection changes.

- `if (info.cwd)` was truthy-only, so a detached session reporting `cwd: ''`
  never cleared and the pane stayed pinned to the last project for the rest of
  the session — the "not always" in the report. Empty is now authoritative.

Empty routes through ownership release rather than a persisted `''`:
`setCurrentCwd` writes to localStorage and seeds `$currentCwd` on next boot, so
blanking would also wipe the remembered workspace.

Only `cwd` is consulted, never `git_repo_root` — the latter is documented null
for non-git workspaces and not-yet-backfilled rows, so falling back to it reads
as "no workspace" and blanks a pane that was correct. A session outside the
loaded sidebar page (no row at all) releases ownership instead of blanking, for
the same reason.

Also claims ownership on the warm-cache path (its missing-RPC compat branch
returns before `applyRuntimeInfo`) and for a center tile, whose Project "+"
create left the right rail on the previous session's folder.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
2026-08-07 13:06:47 -05:00
bb ae6eb578bb fix(desktop): add workspace-cwd ownership so switches are atomic
`$currentCwd` is a global singleton, but a conversation switch publishes the
new stored session id immediately while the new workspace only arrives when
`session.resume` settles. For that whole window the path still names the
PREVIOUS conversation, and every workspace-derived surface treats it as
authoritative.

Track WHICH conversation the live path describes instead of trying to keep the
path itself in lockstep. Ownership — not emptiness — is what makes the switch
atomic: clearing the path would collapse the workspace/review panes and drop
file-tree state on every switch, so the path stays put and is simply marked
not-yet-owned.

The released marker is deliberately not `null`: `null` MATCHES a fresh draft
(whose selected id is also null), so releasing to it would hand a leftover path
to the draft as its own workspace.

Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
2026-08-07 13:06:35 -05:00
Jakub Wolniewicz 24b7ca7258 fix(desktop): preserve root recovery through StrictMode replay 2026-08-07 12:28:19 -05:00
Jakub Wolniewicz bb9434d307 fix(desktop): match current assistant-ui lookup errors 2026-08-07 12:27:34 -05:00
Jakub Wolniewicz 0405c26645 fix(desktop): recover root boundary from tapClientLookup races 2026-08-07 12:27:33 -05:00
Adolanium a62eaaf316 fix(desktop): self-retry transient boundary errors, reactive edit composer context
Two correctness holes left by the session-switch perf work (#72504 / #72524):

1. MessageRenderBoundary only cleared a swallowed transient useClientLookup
   error when the structural resetKey changed. Mid-turn, ids/roles/count are
   stable, so a lookup race during a stream left the boundary rendering null
   for the rest of the turn. The boundary now self-retries on a 0ms timer
   (rAF never fires in a parked renderer), bounded to 5 consecutive
   transient catches with the budget reset on recovery; the structural
   resetKey path is unchanged, and non-transient errors still re-throw.

2. cwd / gateway / sessionId were removed from the messageComponents memo
   deps and read through a render-time ref so session switches stop
   reminting the component types. But a mounted UserEditComposer only
   reads that ref when it renders, and a same-session change (cwd remap,
   gateway reconnect) leaves every ThreadMessageList prop referentially
   equal, so the memo'd list bails out and the open composer keeps stale
   values: @-completions, slash completions, and OS-drop uploads target
   the old cwd / gateway / session. Thread now provides the three values
   through a memoized ThreadEditContext; context propagates through the
   bail-out, the component type identity is untouched, and the transcript
   never remounts.
2026-08-07 12:27:23 -05:00
hermes-seaeye[bot] 48e2dcd7a0
fmt(js): `npm run fix` on merge (#81102)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-07 14:30:33 +00:00
webtecnica a4b235c4b2 fix(desktop): virtualize git file-tree to cap DOM nodes (#77257) 2026-08-07 19:51:06 +05:30
Austin Pickett 5c6aff1430
fix(desktop): keep the chat in front of the terminal in Focus layout (#81019)
* fix(desktop): hide the terminal overlay when its pane is inactive

One xterm is CSS-overlayed onto whichever `<TerminalSlot />` is active,
positioned with `position: fixed` from the slot's bounding rect. Keep-alive
tab layers stay MOUNTED when inactive — hidden with `visibility: hidden` +
`data-pane-hidden`, deliberately preserving their layout box so scroll state
and xterm survive a tab round-trip.

So an inactive terminal slot still reports a full-size rect identical to the
front tab's, and `rect.width > 0 && rect.height > 0` cannot tell the two
apart. The overlay stayed painted at z-4 over whatever tab the user switched
to, swallowing its clicks.

Sample the hidden state alongside the geometry: `Rect` carries `hidden` from
`isElementInHiddenPane(slot)`, `sameRect` compares it so a tab switch wakes
the tracker, the ancestor MutationObserver watches `PANE_HIDDEN_ATTR`, and
the overlay gates on `!rect.hidden`. `TerminalWorkspace` stays mounted
throughout — PTYs are never torn down, only the surface stops painting.

`opacity: 0` rides alongside `visibility: hidden` because Electron can keep
xterm's WebGL canvas composited after an ancestor goes hidden.

Refs #71407

* fix(desktop): collapse an active tool pane onto the workspace, not a neighbour

`setPaneCollapsed` on the ACTIVE pane of a shared zone that holds the
uncloseable workspace handed the active slot to `group.panes[at - 1]` — the
tab to its left, whichever that happened to be.

The workspace can't minimize (it would strand the app), so tab-switching to a
sibling is the right shape; picking a positional neighbour is not. In the
Focus preset the terminal is a tab in the workspace's own group:

    [workspace, files, review, terminal]

Collapsing the active terminal therefore selected `review`. The user asked for
the terminal to go away and landed on a diff pane they never opened — and with
the overlay still painting (before the previous commit), it read as "the
terminal came back".

Hand the slot to the uncloseable pane itself. That pane is the zone's anchor:
it's the one member guaranteed to be a real destination rather than another
tool the user was not asking for. The positional fallback stays for the
defensive case of collapsing the uncloseable pane itself.

This is deliberately broader than one entry point — every route into
`setPaneCollapsed` for a shared zone gets it: the rail, the tab toggle, and
⌃`. Pure tool-only zones are untouched and still fold as a unit.

* fix(desktop): front the workspace when a fresh chat starts

`startFreshSessionDraft` resets the whole view — messages, usage, timers,
route intent, cwd — but left `$terminalTakeover` set. That atom is not a
cosmetic flag: `controller.tsx` binds it as the terminal's toggle store via
`bindToolPaneCollapse`, so while it stays true the terminal keeps the pane
fronted and ⌘N appeared to bounce straight back into the shell.

Clear it, then `revealTreePane('workspace')`. The reveal is not redundant
with the clear: takeover can already be false while the terminal is simply
the active tab (the flag stays true behind a stacked sibling, and tile flows
never touch it), so the state the user sees and the state the flag describes
drift apart. Clearing homes the common case; revealing states the intent
outright — a new chat shows the chat.

The terminal is not torn down. Tool panels collapse to a rail and keep their
PTYs; re-opening finds the same shell.

The `+` / ⌘T tile path needs no takeover clear — it fronts its new tile
through `revealTreePane` and relies on the hidden-pane-aware overlay.

* fix(desktop): reveal the workspace without closing the terminal

The fresh-session commit cleared `$terminalTakeover` on the way to fronting
the workspace. That atom is not a Focus-only fronting flag — it is the
terminal's open/closed state in every layout, and clearing it is wrong twice
over.

Only the Focus preset stacks the terminal with the workspace. Default,
Terminal deck, and Quad each give it a zone of its own, where it sits beside
the chat and obscures nothing — and there ⌘N minimized a terminal the user
had deliberately open.

The flag is also persisted, so the damage outlived the session. On the next
boot the Focus terminal tab is still in the strip and its zone is not
minimized, so clicking it only calls `activateTreePane`; `PersistentTerminal`
mounts its workspace solely while takeover is true, so the tab fronted empty.

`revealTreePane('workspace')` already carries the whole intent. Behind another
tab the terminal is HIDDEN, not closed: it keeps its PTYs, and the overlay
stops painting on the pane-hidden marker from the first commit in this branch
— which is what was actually covering the chat. Removing the clear costs
nothing and keeps the toggle store truthful.

Two regression tests, both verified to fail when the clear is reinstated: a
terminal in its own zone stays open and visible across a fresh chat, and a
Focus terminal tab still mounts after a restart.

Reported by Copilot review on #81019.

---------

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: Ritesh Patel <60716910+DECRUX9812@users.noreply.github.com>
2026-08-07 09:23:03 -04:00
HexLab98 c24ff38c51 fix(gateway): make a history-dropping submit prove it meant to
prompt.submit honored truncate_before_user_ordinal on every request. A
client that carried a leftover ordinal into an ordinary send therefore
issued something the gateway could not tell apart from a real rewind —
same method, same shape, an in-range target — and the cut was applied
with replace_messages(), which DELETEs the durable rows. One report lost
244 messages (296 -> 52) with no prompt and nothing to restore from.

The existing guard only covered ordinal 0, where the cut empties the
transcript; a mid-session ordinal sailed straight through. Only the
client knows whether a submit is a rewind, an edit, or a regenerate, so
require it to say so: an ordinal without confirm_truncate is refused on
4029 and neither memory nor the DB is touched. Desktop sends the flag
from the one place that builds these params, so every rewind path is
covered and a stale build fails closed with an actionable error instead
of quietly deleting a conversation.
2026-08-07 18:20:19 +05:30
brooklyn! 623d5c93e0
Merge pull request #80736 from NousResearch/bb/reasoning-summary-blocks
Reasoning steps read as separate blocks again instead of one glued paragraph
2026-08-06 22:02:13 -06:00
Brooklyn Nicholson 6f1072c83c fix(desktop): drop the gateway-pill dogfood plugin
It was a 1:1 rebuild of the core statusbar gateway item and shipped
enabled by default, so the pill showed up twice. Core chrome stays in
shell; demos that clone it belong in hermes-example-plugins.
2026-08-06 22:29:33 -05:00
Brooklyn Nicholson a5cddcd8dc fix(desktop): render already-glued reasoning as separate blocks
Repairs what is already in the transcript: reasoning persisted before the
backend fix, and any provider still gluing its parts. Handles both shapes —
heading-onto-heading (the **** run) and prose-onto-heading (vercel/ai#6742).
Verified against 46 real glued messages from a gpt-5.6-sol session; all repair
cleanly and idempotently.
2026-08-06 22:02:46 -05:00
hermes-seaeye[bot] eb8421ba98
fmt(js): `npm run fix` on merge (#80725)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-07 02:42:42 +00:00
brooklyn! aacd3194c9
Merge pull request #80719 from NousResearch/bb/running-process-elapsed
Keep elapsed status text from overlapping
2026-08-06 20:33:35 -06:00
brooklyn! 4b0238578e
Merge pull request #80711 from NousResearch/bb/sidebar-pin-sort
Pin as many sessions as you want, and they stay where you put them
2026-08-06 20:33:14 -06:00
brooklyn! 01a61c945f
Merge pull request #80718 from NousResearch/bb/show-earlier-threshold
Show earlier messages no longer hides most of a session
2026-08-06 20:31:40 -06:00
Brooklyn Nicholson 45f23205d7 fix(desktop): show every pinned session, however many there are
Pinned was capped at half the viewport by its own nested scroller, so past
roughly a dozen pins the rest were reachable only by scrolling inside a
scroller — a pin you have to go hunting for isn't doing its job.

Drop the cap and let the section grow into the sidebar's existing scroll,
and stop virtualizing Pinned: virtualization needs a bounded viewport to
measure against, which is exactly what's being removed. No count badge, no
"show more" — pin as many as you want and they all render.

Also back-fill pins on the API-server list route, which was the one list
path still windowing purely on recency.
2026-08-06 21:27:27 -05:00
Brooklyn Nicholson 0265797b8d fix(desktop): keep elapsed status text from overlapping 2026-08-06 21:26:29 -05:00
Brooklyn Nicholson 75717d29eb feat(desktop): stop hiding a session behind Show earlier
On real sessions the button showed up two or three turns from the bottom, over
a screen and a half of transcript that had barely painted anything.

The budget now spends paint weight, which is what the DOM actually mounts, and
600 units of it — 10-20 agentic turns measured, where a tool-heavy turn prices
at 30-90 and a plain exchange at 5-10. A floor of 8 turns covers the session of
enormous turns that a weight-only cut still truncates hard; it applies to a
real page only, so the small first-paint commit stays small and the backfill a
frame later fills the rest.

Measured on four stored sessions at the same budget: one went from 3 turns
visible to 12, another from 3 to 4, two unchanged. The store window still caps
what the DOM can reach at all.
2026-08-06 21:20:57 -05:00
Brooklyn Nicholson 31459ef0c9 feat(desktop): price a turn by what it paints
One weight function served two budgets that protect different things. The
store window protects the heap: every message it admits is normalized into the
runtime repository whether or not the transcript collapses it, so it has to
price the payload it holds. The DOM budget protects the paint, and what a turn
mounts is decided by the grouping, not by the bytes behind it.

Charging the DOM budget for payload made it count work that never happens. A
settled run of twelve reads is one grey summary line, a thought is one
collapsed disclosure, a todo is hoisted out of the transcript, and an image is
one img however long its data URL — all of it priced as if fully expanded.

messageStoreWeight keeps the payload price for the window. messagePaintWeight
prices what mounts: collapsed rows flat, silent rows free, cards fixed, and
markdown and diffs by size, since those really do build DOM. Both share one
character ceiling per message rather than one per part.
2026-08-06 21:20:41 -05:00
Brooklyn Nicholson 7eb461d693 refactor(desktop): share how a tool row renders
The transcript decides what a tool call draws and the render budget has to
price it. Both sides need the same answer, so the classification moves out of
the tool renderer into its own module rather than the budget importing the
formatting and i18n weight of fallback-model to ask one question.

Adds isSilentTool for the rows that render nothing at all: todo is hoisted to
its own panel, and a reaction's UI is the emoji on the bubble.
2026-08-06 21:19:44 -05:00
Brooklyn Nicholson 03b759db86 fix(desktop): rank dragged sessions inside their date group
Dragging one row switched the entire sidebar into a frozen manual mode
with no date dividers at all — permanently, for every session, because
the manual order replaced the recency sort outright instead of layering
on it. Chronology and ranking are separate concerns: keep the calendar
buckets where recency put them and apply the hand-picked order only
within a bucket, so a drag ranks a chat among its own day's chats and the
dividers survive.

Rows move as clusters, so a reorder can't strand a branch child from its
parent, and a session the saved order doesn't name keeps the slot recency
gave it. Two supporting fixes fall out: dnd-kit now receives the ids it
actually renders (it was handed the unrendered session order, so a drop
computed its target against a list the user wasn't looking at), and an
older page that loads no longer jumps above the hand-picked rows — new
ids fold in by position rather than all hoisting to the top.
2026-08-06 21:12:35 -05:00
Brooklyn Nicholson 256aac54d9 fix(desktop): show a pinned session once, and keep its drag order
Two ways a pin got misfiled. The duplicate: a pin is stored on the
durable lineage root, but recents, the messaging slice and the backend
project tree are three independent fetches and each can surface the same
conversation under either its live tip or its root — so the filter
compared one identity against the other, missed, and the session rendered
in both Pinned and its project group. Match on every id the pin is
reachable under.

The lost reorder: a drag only reports the pins whose row is loaded, and
setPinnedSessionOrder required that list to match the stored one in
length, so a single unresolved pin discarded the whole reorder. Treat it
as a permutation of a subset — re-slot the named ids, leave the rest.
2026-08-06 21:12:07 -05:00
Brooklyn Nicholson daeedf67c9 fix(desktop): hold the pin write guard until a page confirms it
The guard that stops a stale list page from reverting a fresh pin was
released on the PATCH's own ack. A list request issued just before the
write is slower than the write, so it lands after the ack still carrying
the old value, with no guard left to fence it: the pin flips back and the
next reconcile pushes that wrong value to the server, making it durable.

Keep the guard until a page actually confirms the value written, with a
cooldown so a row that never returns can't fence itself forever, and drop
it outright when the write fails — the server never changed, so it stays
authoritative.

Also reset the mirror bookkeeping on a gateway switch. mirrored/pending
are per-backend facts; carrying them across a re-home told us the pins
were already pushed to a backend that has never seen them.
2026-08-06 21:11:38 -05:00
Brooklyn Nicholson fd9fc50dd2 fix(desktop): stop dropping pinned sessions past the page limit
The list endpoints deliberately back-fill pinned conversations past their
LIMIT, then the client sliced the response back down to that same limit
and threw them away — so only pins that happened to land inside the most
recent page ever rendered, which reads as a cap on how many sessions you
can pin.

Keep the back-filled rows when trimming, and discount them from the
"window came back full" test that drives Load more. Counting a back-fill
as a loaded row invented a page that could never be fetched, leaving a
Load more button that refetched the same rows forever.
2026-08-06 21:11:10 -05:00
Brooklyn Nicholson ac745a0b07 docs(agents): surface capability belongs to the session, not the process env
The rule the preview-tool bug broke, written down so the next GUI-adjacent tool
does not rediscover it: the client and the backend are separate machines, so
"was this process spawned by Electron?" cannot answer "is a GUI watching?".
Names the working pattern (toolset gates the surface, check_fn answers only
reachability or user opt-in), the process-wide check_fn TTL cache that makes it
the wrong home for a per-session answer, and the test that would have caught
it — assert the GUI session gets the tool with the env var absent.
2026-08-06 19:35:47 -06:00
hermes-seaeye[bot] 25c7827ec9
fmt(js): `npm run fix` on merge (#79521)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 16:51:56 +00:00
brooklyn! 69bedb7bed
fix(desktop): stop reporting false failure on successful backend updates (#79513)
* feat(update): emit an action-scoped terminal receipt from hermes update

The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>

* fix(desktop): make remote backend updates terminal-state driven

Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.

A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.

Fixes #47359
Fixes #58764

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>

---------

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
2026-08-05 10:42:02 -06:00
brooklyn! 64646dda56
Hermes can read the in-app browser (#79482)
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser

The agent could open the preview pane (open_preview) and read the embedded
terminal (read_terminal), but the browser it had just opened was a black box —
'what does this page say?' had no answer. read_preview mirrors read_terminal
end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside
the GUI), dispatched through the same agent callback pattern, windowed with
start/count so a long page pages instead of flooding context.

* feat(gateway): preview.read blocking bridge

Same lifecycle as terminal.read: the tool blocks on preview.read.request, the
renderer answers preview.read.respond (allow_expired — a slow page extraction
losing the 45s race must not surface a raw 4009), and a timeout emits
preview.read.expire so late answers resolve quietly.

* feat(desktop): the renderer serializes the active preview tab for the agent

preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
2026-08-05 16:35:00 +00:00
brooklyn! 28a3fe5c33
Merge pull request #79507 from NousResearch/bb/remote-pdf-preview
fix(desktop): render remote PDFs in preview rail
2026-08-05 10:34:28 -06:00
Brooklyn Nicholson eb68ffbe43 fix(desktop): make remote backend updates terminal-state driven
Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.

A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.

Fixes #47359
Fixes #58764

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
2026-08-05 10:34:18 -06:00
hermes-seaeye[bot] 9e9b3fc669
fmt(js): `npm run fix` on merge (#79505)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 16:32:54 +00:00
brooklyn! 7e16241825
feat(wake): hands-free wake word for remote desktop via client mic streaming (#79491)
* feat(wake): client-capture wake word for remote desktop

Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)

* fix(wake): address review on client-capture re-arm and feed queue

- wake.status reports effective capture from the armed detector (client vs
  local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
  while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works

* fix(wake): auto capture keeps the backend mic when one exists

With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.

Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.

* perf(desktop): coalesce wake.feed frames

Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the
ear is armed. Drain up to 4 queued frames into a single wake.feed payload
(backend feed() already splits long buffers into engine frames) — ~3 RPCs/s
steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not
0.5 s).

* docs(config): document wake_word.capture in cli-config.yaml.example

---------

Co-authored-by: Andrew <drew@kainotomic.com>
2026-08-05 10:32:38 -06:00
Matt Prusak c8fdc51740 fix(desktop): render remote PDFs in preview rail
PDFs were classified as generic binary/text previews, rendering raw %PDF
bytes locally and failing entirely for remote-only files. Classify PDFs as
their own preview kind, load bytes through the existing local/remote
filesystem bridge, convert them to revocable Blob URLs for Chromium's
embedded viewer, migrate persisted pre-PDF tabs at restore, and retry
restored previews when the active filesystem connection changes.

Salvaged from #76008-era base onto current main: PDF classification now
composes with the remote-HTML enrichment branch, and the persisted-tab
migration runs before the One-Browser URL rekey in decodePreviewTabs.

Supersedes #76565.

Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
2026-08-05 10:26:48 -06:00
Brooklyn Nicholson c6fe31a9be test(desktop): expect client_capture in wake.start/status params
The GUI now passes client_capture: true on wake.start, wake.status, and the
post-voice re-arm; update the store and slash-handler tests to the new
param shape. 123/123 pass locally.
2026-08-05 10:25:57 -06:00