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.
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.
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>
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>
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>
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>
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>
`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>
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>
`$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>
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.
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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#47359Fixes#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>
* 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.
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#47359Fixes#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>
* 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>
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>
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.