When a Desktop tab references a session id that exists in no profile DB
(deleted elsewhere, or a stale id after a profile rename / wiped backend),
the resume path already drops the window to a fresh draft without toasting
or hot-looping (62af32efe7, bounded by goneSessionVerdict). But the text
the user had typed into that session stayed stashed under the dead key in
`hermes:composer-drafts:v3` — not lost, yet invisible, because nothing ever
opens that key again. From the user's side the message just vanished.
The gone verdict now announces the dead key (`announceGoneSessionDraft`),
and the composer's swap onto the fresh-draft scope consumes it once
(`adoptGoneSessionDraft`): the stash moves into the new-chat bucket, the
composer is seeded with it, and an inline "Restored your unsent message"
strip with Undo appears above the input. Undo returns the text to the dead
key while it is unchanged (still recoverable by the same path); once edited
it only dismisses. Keyed on the one-shot announcement rather than on the
composer observing an id→fresh transition, because the composer remounts
across the drop (a loading route mounts no composer). A fresh draft that
already has text is never clobbered; an empty stash shows nothing.
Offer, don't hijack (apps/desktop/AGENTS.md): no navigation beyond the drop
that already happened, no focus move, no toast.
Live (headless Electron over CDP, worktree backend): type into a live
session → delete its row + restart the backend → reload at the dead id.
Before: route drops to #/, composer empty, text only in localStorage under
the dead key. After: same drop, composer holds the text, strip + Undo shown;
Undo empties the composer and puts the text back under the key; a second
open of the dead id shows no strip; a dead id with no stash shows nothing.
Fixes#111868
`display.show_reasoning` is a client-side display decision since 40f2368875,
and the Desktop transcript honors it through `$showReasoning` (#115335). The
composer path did not: `/reasoning` was marked `desktop="advanced"` in the
Python slash registry, so the Desktop refused it as "not available", while a
gateway-routed `/reasoning hide` would only have written config.yaml and left
the atom waiting for the next config refresh.
Route `/reasoning` to the gateway's `config.set key=reasoning` — the Ink TUI's
path (ui-tui/src/app/slash/commands/session.ts) — and mirror the answer:
`hide`/`show` flip `$showReasoning` on the spot, effort levels leave the gate
alone and get session scope (a throwaway slash-worker CLI could never pin the
live session's effort). No arg reports the current effort and display, like
Ink. Drops the registry's `advanced` marker and regenerates the desktop dump.
Live (real `hermes serve`, scratch HERMES_HOME, real WebSocket): config.set
key=reasoning value=hide -> {value: hide}, config.yaml show_reasoning=False;
show -> True; high -> effort only, display untouched.
Fixes#111761
The ui_meta mirror of a room is head-trimmed to 16 messages and then to
the 48 KB envelope, silently. It is the only on-disk copy of the room, so
an agent that consults it to check what the user said reads a bounded
window with no sign that it is bounded (#114341, secondary). Stamp
`omitted: N` on the projected room whenever entries fall off the head,
recomputed after every byte-budget shift so the envelope bound stays
honest, and carry the larger writer's count through the publish merge as
a lower bound. Local room state never receives the field: the merge-back
into $groupChats builds from explicit fields.
The ui_meta projection sliced messages at 1200 chars with no
signal that the body continued. Stamp truncated and keep the
marker inside the existing per-message budget.
The salvaged tests exercised the formatGroupDeltaLines helper in
isolation, so a call-site regression (a bare slice reappearing in
prepareGroupRoundMember) would have stayed green. Drive the room through
runGroupChatRounds instead and assert on the prompt the gateway actually
received: over-long delta -> marker naming the dropped count and the
dropped head absent; delta that fits -> no marker. The helper-level pair
is dropped so the file keeps two invariant tests for this fix.
The salvaged guard covered only the save path. Validate, activate and delete completions
from a profile-A instance could still set state, fire onConfigSaved / onMainModelChanged
and toast after SettingsView remounted the panel under profile B — the identical stale-
completion race, three more doors. The same `mounted` ref now gates all four handlers
and `refresh()`.
Test: the salvaged vitest file now partially mocks `@/hermes` (the profile-scope note
subscribes to `setApiRequestProfile` at import time) and stubs `ActiveProfileNote`.
Closes#69451 (renderer `profileScoped()` forwarding, backend `_config_profile_scope`
and the profile-keyed remount landed on main earlier; this closes the last atom).
Salvage of #69452.
The docs line implied a manual refresh step — open chats update as soon as
the setting saves. The ungrouped `Reasoning` gate reads as dead next to the
group's, so name the path that reaches it. The on/off assertions now fail
with the element, not `expected false to be true`.
The desktop guide listed every other Settings toggle but not this one, and
the key's type only needs the two shapes config.yaml can hold (boolean, or a
quoted string), matching `display.timestamps` beside it.
Collapse the string-coercion table to the one case config.yaml can actually
produce (a quoted "false"), matching setDisplayTimestampsFromConfig, and trim
the config-hook coverage to the single mirror/default contract. The renderer
test (grouped + standalone parts, on and off) is unchanged apart from
formatting.
The desktop Settings → Chat "Reasoning Blocks" toggle (display.show_reasoning)
is persisted to config.yaml but the React renderer never read it, so toggling
it had no effect — reasoning blocks were always shown regardless (issue #49664).
Wire the renderer to the setting:
- add a $showReasoning nanostore atom + setShowReasoning setter
- refreshHermesConfig populates it from Boolean(config.display?.show_reasoning),
read from the same /api/config the Settings toggle uses, so an unset key
resolves to false (matching the toggle and the gateway/CLI default)
- gate ReasoningAccordionGroup (the "Thinking" disclosure) and ReasoningTextPart
on the atom; the existing hasContent guard is preserved
- add show_reasoning?: boolean to the HermesConfig display type
- store unit tests for the atom/setter
The atom defaults to true so reasoning keeps rendering if the (nice-to-have)
config read fails; the config refresh then resolves the real value.
Fixes#49664
(cherry picked from commit e199fadde81ffb8af70f49b9785f597295ac0f48)
One test per truncation flag: the batched /api/profiles/sessions/sidebar
route (real state.db, two pinned among the newest rows, cap 4) and the
desktop's legacy per-slice derivation (3 pinned + 17 unpinned against a cap
of 20). Both red on origin/main, green with the fix. Regression for #81484.
The recents window is a LIMIT-cap page by recency plus a back-fill of pins
the page missed. Both truncation flags — the batched /sidebar route and the
legacy per-slice derivation in the app — discounted every pinned row, on the
theory that pins only ever arrive past the LIMIT. Pins inside the window
occupy real slots, so with two pinned rows among the twenty newest the count
came to 18 < 20, `profiles_truncated` read false, and the sidebar never
mounted "Load more": every older session was unreachable (#81484).
Count every row. A list shorter than the cap has nothing past the page for
the back-fill to add, so pins cannot fake a full page there; at worst a list
of exactly cap rows costs one click that resolves to an empty page.
Salvaged from #81490.
Nothing ever passed a tail length other than 0 or 1, and a bare `1` at the
call site needed the comment to decode. `exemptNewest` reads at the call
site without it; the doc comment now also says the exempt turn counts toward
`minVisible`.
Review of the salvage: exempting the newest turn during the first-paint
budget made the walk always continue into the previous turn, so a heavy
previous turn landed in the synchronous first-paint commit that
FIRST_PAINT_BUDGET exists to keep small. Gate the exemption the way the turn
floor already is (renderBudget >= paneBudget); the rAF backfill reaches a
real page a frame later and the cut is stable from then on. Also start the
walk at the budgeted end instead of testing every index, and document the
parameter on the function.
The completion-time case is the same contract as 'exactly one newest group
is exempt' — a heavy tail never charges the budget whether it is still
streaming or done — so one test pinning the growth and one pinning the
single-group exemption cover it.
Same defect class as the session and project rows: the group header spread
the full dnd-kit handle, so Space on its ⋯ button (which opens a rename
dialog) armed a keyboard reorder and the sensor ate the next Space in the
dialog input. Keep the explicit onPointerDown forwarder — a press on the
label still starts the reorder, which the header test now proves through the
pointer path — and move the keyboard reorder assertion onto the grabber,
where the keyboard activator lives.
Renders the real SidebarSessionRow inside ReorderableList with the sidebar's
own sensor set. Space on the focused ⋯ button must pass through (not
defaultPrevented, no aria-pressed grabber); the grabber itself still starts a
keyboard reorder. Red on origin/main (the shell spread the keyboard
activator), green with the fix. Regression for #83617.
The session and project row shells spread the FULL dnd-kit handle
({...attributes, ...listeners}) and then forwarded onPointerDown again by
hand. The spread put dnd-kit's keyboard activator (and role/tabIndex) on the
whole row, so Space/Enter on any focused control inside it — the ⋯ menu
button that opens Rename — started a keyboard drag, and an armed
KeyboardSensor then consumed the next Space at window level: the rename
dialog's input dropped the keystroke (#83617).
Drop the spread. The explicit onPointerDown already forwards the pointer
activator, and the grabber keeps the full handle so keyboard reorder stays
accessible.
Salvaged from #83618 (minimal slice of the shell/grabber split).
The hermes-bots plugin's Routines card (and its inspector's `Next run` row)
still read `Next: 7 hr. ago` for a `next_run_at` parked past the scheduler
grace — the one Desktop surface the overdue labelling left out (#114309).
The card now switches to `t.cron.overdueSince` and the inspector row to
`Overdue since`, using the same `nextRunOverdueMs` decision as the core cron
panel and sidebar, so paused/completed/disabled jobs keep the plain label.
`nextRunOverdueMs` is exported through `@hermes/plugin-sdk` (the plugin only
imports from the SDK) and its parameter is widened to the optional-field shape
the plugin's `RoutineJob` carries; no plugin i18n key is needed because the
card already renders from core `t.cron`, which every locale resolves.
Test: one card test renders an overdue vs an upcoming job and checks both the
card label and the inspector rows; the fixture's fixed 2026-08-23 next_run_at
is made clock-relative so it never ages into "overdue" on its own.
`hermes cron list`, the web dashboard Cron page and the Desktop cron panel/sidebar all
rendered a `next_run_at` parked hours in the past as an ordinary upcoming "Next run" —
the only user-visible trace of a scheduler that stopped ticking (#114309). Every
surface now labels a slot past `cron doctor`'s 15-minute grace as overdue (CLI
`Overdue:` row with the lateness, web `Overdue since`, Desktop `Overdue since` label
on the detail panel and sidebar meta) while paused/disabled/completed jobs keep the
plain label because they are not expected to fire.
The CLI's overdue check now parses through `cron.jobs._parse_aware` and `hermes_time.now`
so status/list/doctor and the ticker agree on the instant (mixed offsets, DST folds,
legacy naive stamps read as system-local like the scheduler does), and both the
ordering and the subtraction normalise to UTC: Python compares same-tzinfo datetimes by
wall clock, which is wrong across a DST fold. Status still orders the soonest run by
instant (#113874) and prints the stored stamp.
Tests: the salvaged status tests are trimmed to two invariants (overdue + stale
heartbeat is loud on status AND list; within-grace stays plain on both), the DST-fold
ordering tests freeze the CLI clock as well so their 2026-11 fixtures never start
reading as overdue, and one vitest each pins the web and Desktop helpers.
Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
A routed prompt holds one lease per (route, runtime session) until the turn settles, and for a
streaming turn only a Secondary's own terminal-event listener ends it: releaseTerminalTurnLease
invokes `g.turnLeases.get(key)?.()`, so a lease that is not the mapped one is never called at all.
Two ways a hold ends up in that state, both closed here.
The function already skips the primary profile, and its comment says why: a no-op lease leaves a
phantom key that suppresses the real hold if the route is later re-homed as a secondary. But the
primary profile is not the only route served by the primary socket. retainGatewayForAgent returns a
no-op for a shared-remote collapse and for a build with no registry dialing, and gatewayForProfile
returns one for a shared-primary route; none of them creates a Secondary. A lease registered on any
of those is stored and can never be released — and because the map is keyed per session, it silently
suppresses every later hold on that session, including after the route becomes a real pooled
secondary. The shared-remote probe explicitly expects that transition: it prefers the primary "until
a later probe can prove isolation". The next turn then runs with no hold at all, so the socket is
free to be reaped mid-turn and the gateway interrupts the turn as client_gone, which is the failure
this mechanism exists to prevent.
The duplicate-lease guard is also a check-then-act: it runs before the retain awaits and the map is
written after it, so two submits for one (route, session) can both pass. The loser's hold is then
orphaned — its release is never the mapped one, so activeRequests stays above zero and the pooled
socket can never be reclaimed.
Both are decided by outcome rather than by re-probing: no pooled entry for the scope means nothing
can release the key, and a key already present means someone else owns the turn. Either way the
route is released and the caller gets a no-op.
These are the function's first tests. One takes a lease while the route rides the primary, leaves it
unreleased as a streaming turn would, then makes registry dialing available and asserts the next
turn really holds a socket. The other races two submits across the dial, fires the terminal event
the gateway's own listener would, and asserts the socket is then reclaimable. Each fails without its
own guard.
Clicking the reaction button on an assistant message opened the picker,
but the very next pointer movement over the transcript closed it, so no
emoji could ever be chosen. The group-hover / PopoverAnchor mechanism in
the report is not the cause: the anchor is already kept live while open
(styles.css `[data-state='open']`) and Radix does not close on a hidden
anchor. The picker died to the floating composer's focus-follow: Radix
autofocuses the popover content when it opens, `trackPointer` pulled
that focus back into the pane composer on `pointermove`, and the
popover's `onFocusOutside` dismissed it.
Guard `focusSelectedComposer()` — the chokepoint every focus-follow
branch funnels through — against focus that sits inside an open floating
layer (popover, menu, listbox, dialog, popper wrapper). The selector is
the one `composer-focus-keys` already uses for its overlay gate, hoisted
to the leaf `combo.ts` as `OVERLAY_SURFACE` so both consumers share it.
Tests: focus-follow leaves focus inside a floating layer (with the
plain-control control case), and the assistant reaction picker survives
a pointermove over its message with a registered floating composer. Both
red on origin/main.
Follow-up to the salvaged #114383 commit: the "<title> — <session>" template
moves out of the dispatcher into the locale bundles as
`notifications.native.approvalTitleNamed` / `inputTitleNamed` (types.ts + all
six locales), so translators own the separator and word order. The
dispatcher picks the key by kind (approval / input) and keeps the caller's
bare title for prompts without a session. Naming order matches the sidebar:
title → preview → short id tail.
Tests trimmed to two invariants: the handler-level approval toast in
server-requests.test.ts and one dispatcher test covering the sibling input
toast, the id-tail fallback and the untouched completion title.
Co-authored-by: JinUltimate1995 <92670540+JinUltimate1995@users.noreply.github.com>
Co-authored-by: Zeus-Deus <github.commits@widow.cc>
Three sessions parked on approvals raised three identical "Approval needed"
toasts with no way to tell them apart (#114337), even though the runtime
session id was already passed to the same call. The shared dispatch in
store/native-notifications.ts now resolves that id to the session name
(title, else preview, else a short id tail) and appends it to approval /
input titles: "Approval needed — Fix the flaky test". Every emitter of the
blocking-prompt family routes through it, so the approval toast and the
sibling input.request one both get the label from one place.
Session-scoped attention kinds only: completions stay as they are.
The Local Models page now imports the settings-scope chip, which pulls in
src/store/profile.ts; that module subscribes $activeGatewayProfile ->
setApiRequestProfile at load, so the test module failed with "No
"setApiRequestProfile" export is defined on the @/hermes mock". Add the
two exports the profile store reads, as the store tests already do.
`hermes doctor` (and the startup config-structure warning) now report a
`custom_providers` value that is not a YAML list — naming the key and the
received type — instead of the runtime silently serving "0 endpoints".
Doctor also warns about every legacy `custom_providers` list entry whose
endpoint URL has no `providers:` twin, with the exact move to make: such an
entry is served by the chat picker (dual-read view) but has no row on the
Custom Endpoints settings page, and the one-shot v11→v12 list migration
(config_migrations._migrate_to_12) never re-fires once the version is past 12.
Warn-only on purpose: re-running the migration would mint `<key>-N`
duplicates for entries that DO have a twin.
Desktop: Custom Endpoints and Local Models send unscoped requests and always
edit the app's active profile; they now print the same "Changes on this page
apply to the “X” profile." note the Model page uses (hidden with one profile).
Part of #114471 (items 2, 5, 6).
instanceWindowBounds added +32/+32 to the live source window unclamped, so a
source docked at the bottom/right edge produced a rect past the screen — the
same off-screen CreateWindow rect this PR fixes for the saved-state path. Run
the cascaded rect through computeWindowOptions with the live displays; with no
matching display the raw cascade is kept.
Follow-up to the salvaged clamp commit: put back the trailing newlines it
stripped from window-state.ts / window-state.test.ts and add the
padding-line-between-statements blank lines eslint asks for in the new
matchingWorkArea hunks. No behaviour change.
The reactions store dialed activeGateway() directly, so a reaction on a
secondary-profile session (or any session whose owner differs from the
foregrounded profile/connection — Bot-Mode tiles, post-reconnect
rehydration) hit a backend that never held the runtime and answered
4040 "message not found" even though the row existed in the owning
profile's state DB.
Route through requestForOwnedSession (tile route → owner hint →
connection-tagged row ladder, fail-closed), the same dispatcher every
other session-scoped RPC uses. The bound ambient request stays as the
legacy single-profile fallback, preserving the asserted call shape.
Diagnosed independently by emanogilbert-hash on #80670.
Refs #80670.
The wake edge recomputes on every streamed flush (~30/s); joining the whole
reply into a string only to check it is non-blank allocated the transcript
each time. A short-circuiting scan of the text parts keeps the contract
(wake once the first non-blank text exists) with no allocation.
The backward scan for a `message_agent` dispatch to the inbound sender ran
over the whole thread, so one dispatch to a teammate disabled the deliberate
"Replied to" fold (#85884) for every later unsolicited delivery from that
teammate for the rest of the session.
Stop the scan at the nearest earlier human user row, or at the previous
inbound "Message from" row signed by the same sender. One dispatch now
exempts only the answer that follows it.
Control test: dispatch to @Hermes, relayed answer, two ordinary turns, then
an unsolicited "Message from 🤖 Hermes" — the reply to it must fold again.
In Bot Mode the assistant message that follows an inbound
"Message from 🤖 <bot>" row was always folded into a "Replied to <bot>"
notice. Seen from the bot that DISPATCHED (message_agent → teammate →
answer comes back as that inbound row), the next assistant message is
its report to the human, not a reply to the teammate — nothing is sent
through it — so the fold hid the substance of the turn behind a
disclosure the operator had no reason to open (#114629).
Narrow the collapse predicate instead of removing the compact-exchange parity
fold (#85884): skip the fold when the thread before the inbound row
holds a `message_agent` tool call from this bot whose target names the
inbound sender (handle or display name). Unsolicited deliveries — the
recipient side, whose reply the transport does relay back — keep the
existing collapsed rendering.
Fixes#114629
The hook test drove announceNewSessionDraftKey by hand, so reverting the
restore's announce sites kept every test green. Stash a __new__ draft,
let the remembered-route restore run, and require the composer's
adoption to succeed for the restored key.
The salvaged guard migrated the `__new__` bucket when the composer's runtime
session id went from empty to set in the same render as the scope change.
That misses the reporter's path and over-reaches on another:
- Cold-start resume-last-session (use-desktop-integrations) navigates to the
remembered route as soon as the session list loads; the route flips the
composer scope immediately while `session.resume` publishes the runtime id
later, so the guard saw an empty id and never migrated — the typed text
vanished exactly as reported.
- Opening an existing session from a fresh chat also goes empty → set, so the
guard would carry the new-chat draft into whatever session the user clicked.
Drafts are per scope by design (the id-rotation migration in chat/index.tsx
is deliberately same-conversation only); a sidebar click must leave the
new-chat draft in its bucket.
The composer swap cannot tell the two apart from its props, so the site that
assigns the id says so: `announceNewSessionDraftKey(stored)` at the two seams
where a fresh chat becomes a stored session (first-send `session.create` in
createBackendSessionForSend, cold-start restore of the remembered session /
route), consumed once by the swap effect via `adoptNewSessionDraft(scope)`,
which moves the bucket after the outgoing cleanup stashed the live editor text
under it and before the incoming scope is restored — so the text follows the
chat with no duplicate left in `__new__`. The existing helper still refuses to
overwrite a non-empty destination.
The `'active'` sentinel in use-composer-draft.ts is a focus-routing target
(`markActiveComposer`), never a draft key, so it needs no migration.
Tests: the hook test now flips the scope with the runtime id still unknown
(red on origin/main and on the salvaged guard alone) plus a control that a
plain new-chat → existing-session switch leaves the bucket untouched (red on
the salvaged guard alone). The store-level duplicate of the migration test is
dropped; the non-empty-destination invariant stays.
Extend the salvaged regression test so it asserts the exact invariant from
#114048 rather than only "a replay happens": socket 3 asks for
last_seen=1, a live seq=3 racing that replay is parked by the NEW hold
(watermark stays at 1 until the gap is recovered), and the final order is
[1, 2, 3] with the watermark at 3. On origin/main this fails at the first
assertion (no replay is sent on socket 3 because the stale flag is still
set), which is the reporter's observed `replayOnThird: []`.
The builder takes an optional `platform` (default process.platform) so the
argv test can drive the darwin and non-darwin branches explicitly. The test
no longer redefines process.platform via Object.defineProperty.