fix(desktop): raise ws_max_size and strip previewUrl from persisted queue entries - #11
Merged
x7peeps merged 381 commits intoJul 22, 2026
Conversation
…streams Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/ hbars chart helpers (dimension-stable so live updates never resize the card), an Accordion for expand/collapse sections, animated shimmer loaders, and a streams demo that no longer reserves a phantom icon column on unfocused titles.
A full placement grid so the agent can put a widget where it asks — dock-top/ bottom and corner zones, with corners as reserved rails that take real space instead of floating over content. A per-widget error boundary plus lenient ShimmerRows means generated widget code can't crash the TUI.
host.tsx collapses to one placement router over a shared render context, and the grid-test app drops its width floor too (carrying the NousResearch#20379 review rule). Final formatting pass folded in.
… desktop Make the Python skin engine the single source of truth for a canonical theme shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml (by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at once — the theme analogue of the plugin SDK. - @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum, consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it). - Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style derive-from-seed) + `backend-sync` that registers backend skins into the theme registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on gateway.ready (never stomps a persisted pick), applies on skin.changed and the post-turn `config.get skin` poll (catch-all for agent-edited config.yaml). - TUI: `fromSkin` now maps the status bar + `background` keys it was dropping. - Gateway: `config.get skin` also returns the full resolved palette (additive). - Skill: `hermes-themes` teaches the agent to author + activate a skin. Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for the desktop, prompt_toolkit/Rich for the CLI).
…aml hand-edit
The skill told the agent to `patch` display.skin into config.yaml; a stray indent
corrupts the file and breaks the live gateway (the reported "/ menu broke"), and
a raw file edit never live-applies in a running CLI/TUI ("nothing happened").
Route activation through the safe writer (`hermes config set display.skin`), and
state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs
`/skin <name>` (desktop still auto-repaints on the next turn).
…cher A skin Hermes activates (`hermes config set display.skin X`) or recolors in place now goes live on every surface (CLI, TUI, desktop) within ~half a second, on its own — no `/skin`, no tool-hook timing, no user action. A gateway daemon polls the resolved skin signature `(name, active-file mtime)` every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a live color edit to the active skin. It routes through the SAME path `/skin` uses, so all surfaces repaint identically. The watcher seeds its baseline at gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC seeds the baseline too so it never double-broadcasts. Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed handler already applies).
The TUI inherited the terminal's background; now a skin's `background` paints the whole surface via OSC 11 when a skin is applied, and clears back to the terminal default (OSC 111) on revert and on exit (ridden in through resetTerminalModes). Opt-in: a skin with no `background` leaves the terminal untouched, and the restore only fires if we actually painted. Desktop already themed its own bg; this closes the loop so Hermes owns its background on every surface.
Theming was semantic-only: the gold tool `●` was `accent`, shared with headings/links/chevrons, so "recolor tool calls" was impossible and the agent had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking` (reasoning body) tokens that fall back to accent/muted — defaults unchanged, but now independently settable. Make diffs skinnable too (`diff_*`), which fromSkin previously hardcoded. Document the full element→key map in the skill so Hermes knows which knob turns what.
Changing one color ("make the tool ● cyan") forked `default` — which has no
`background` — so applying it reset the terminal to its own (black) default and
dropped the active skin's palette. Teach the skill to edit the active skin's file
in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in
only by carrying its full palette. Hard pitfall: never fork `default` for a tweak.
…ntouched Changing a single color kept wrecking the rest because the agent hand-authored a new skin (often from `default`, which has no `background`, resetting the terminal to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in place (a built-in is forked into an editable copy carrying its full palette), so everything else — background included — is preserved. Plus `skin use` / `skin list`. The skill now points tweaks at this command instead of hand-authoring.
Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't be themed independently. Add syntax_string/number/keyword/comment skin keys → syntax* theme tokens (defaulting to those brand tokens, so defaults are unchanged) and point the highlighter at them. Documented in the element→key map.
… pipeline Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys flow through buildPalette → adaptColorsToBackground instead of a hand-mapped color block, so they inherit NousResearch#20379's contrast/polarity machinery. thinking and syntaxComment track the EFFECTIVE muted (banner_dim override included); the skin's `background` feeds the surface (it also paints the terminal via OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the routing/independence contracts rather than pre-adaptation hexes.
ingestBackendSkin returned early for name === 'default' even when apply=true, so a real runtime switch to the default skin (/skin default on CLI/TUI, or config.set display.skin=default) emitted skin.changed but never repainted the desktop. 'default' is no-opinion on the PALETTE (the desktop keeps its own nous default, so we still never register a converted theme under it), but it IS a valid apply TARGET: setTheme normalizes 'default' -> nous, so switching back repaints to the desktop default. Skip only the registry step for 'default' and let it flow through the apply guard. Addresses Copilot review.
NousResearch#65919 persists verification candidates (finish_reason=verification_required / verify_hook_continue) to state.db but collapses them out of the in-memory model history via repair_message_sequence. The eager session.resume + REST paths read the verbatim display lineage (candidate present), but the warm/live-reuse payload (_live_session_payload) built its user-visible messages from the collapsed in-memory model history — so switching to a still-live session dropped the substantive verification answer that a cold resume of the SAME session showed. That divergence is the cross-session "substantive text vanishes on switch" class, and the direct sibling of the resume-duplication regression fixed in NousResearch#68149. Reconcile the persisted display lineage (candidate-inclusive, the same get_messages_as_conversation(..., include_ancestors=True) read the eager resume + REST paths use) with the fresh in-memory tail in _live_visible_history, so all three surfaces agree by construction while a not-yet-flushed live turn is still shown. Extracted _reconcile_display_with_live as a pure, DI-testable function (anchors on the last persisted row's (role, text); appends only the uncovered in-memory tail; trusts the DB display when the tail can't be anchored). Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB fallback, and the combined candidate+fresh-tail case. The existing freshness guard (test_session_resume_live_payload_uses_current_history_with_ancestors) stays green.
… E2E Complete the NousResearch#65919 warm/live-payload fix across its sibling path and add real-SessionDB cross-builder coverage. - Child-watch (lazy) resume: the delegated-subagent watch window served _history_to_messages(repaired_history) for its user-visible messages, which collapses out persisted verification candidates just like the warm-payload path did. Build the visible messages from the verbatim child-only display projection (repair_alternation=False) while the repaired history still feeds live replay; fall back to the repaired history if the display read fails. - E2E cross-builder consistency (real SessionDB, not mocks): a persisted verification candidate is collapsed out of the model projection but kept in the display projection, and _live_visible_history now equals the eager session.resume display projection (candidate present). Adds the combined candidate + fully-flushed-second-turn case and a lazy child-watch handler test that asserts the candidate survives in resp["result"]["messages"].
The new hermes skin subcommand must be declared so startup plugin discovery can skip when the user targets it.
…-candidate-warm-payload fix(tui_gateway): candidate-inclusive display on warm/live + child-watch resume (NousResearch#65919 fallout)
…-sdk feat(ui-tui): widget-app SDK — apps as state+reducer+render, with three reference apps
feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop, live
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…view, tier art (NousResearch#68722) * feat(desktop): revamp Billing page — plan card, in-app plans view, tier art Reshape the desktop Billing settings per wayfinder ticket 09. New page order: Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance summary strip unchanged at the top. - CurrentPlanCard replaces the old Subscription row: tier name + price + renewal and at most one button — "View plans" (free/no-sub + can_change_plan), "Change plan" (subscriber + can_change_plan), or none for teams / non-changers. Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button navigates in-app to the plans sub-view. - bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam, default overview). BillingPlansView renders a grid of PlanCard from live tiers[] (is_enabled, sorted by tier_order, free tier included). - PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo"). Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗" (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so this PR intentionally links them out/disabled rather than wiring the money path. - buildManageSubscriptionUrl gains an optional third arg (tierId) → appends plan=<tierId>. Signature kept identical to draft PR NousResearch#68666 for a trivial rebase; NAS NousResearch#748 validates the param server-side. - Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox); unknown name → text-only card. Imported via vite static imports for packaged file:// + webSecurity. - Top-up vs auto-refill disambiguated by section label + first sentence: "One-time top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured copy reads "Charges $X automatically when your balance falls below $Y."). - Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two $ fields with a pre-allocated error line) and the action column (Manage → Save/ Cancel) in place, with the row height reserved for the tallest state so the Usage section never shifts. Fixes the spurious on-open validation error (errors now show only after an edit or a save attempt). Save/disable API calls + confirm-disable flow unchanged. - Remove subscriptionTierChips and the subscription-row chips; reshape (not delete) deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam. - Dev fixtures: add free-personal and subscriber-personal (personal orgs, full 4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable. Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts; delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean. * fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription Visual verification caught a spec-fidelity bug: in the plans grid, an account with no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗" upgrade — clicking would deep-link the portal to "subscribe to Free". Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order tier as the stand-in current plan when there is no subscription, so the free card renders exactly like is_current (inert, "Current plan") and — being the lowest order — no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade. CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is unchanged (Free stays a disabled Downgrade below the current Plus tier). Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean. * chore(desktop): shrink bundled tier art to 128px thumbnails The plan-card wells render the art at ~40px; shipping the full landing images added 2.7 MB to the repo for no visible difference. 128px covers 2x displays; total is now 26 KB. * fix(desktop): address 6 adversarial-review findings on the Billing revamp 1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier is_enabled:false; the enabled-only filter dropped it, leaving currentOrder undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers now resolves current identity/ordering against the UNFILTERED tiers and keeps the grandfathered current tier in the grid as the inert "Current plan" card; downgrades classify against its tier_order. (Non-current disabled tiers are still dropped.) 2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on can_change_plan, but the grid could be empty / current-only and showPlans refused, so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1 actionable (non-current) tier; otherwise it falls back to the portal link. 3. Deep-link bypass. showPlans now gates on the same capability that renders the button (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls back to overview instead of a grid of live Choose buttons. 4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers, refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal link built from subscription?.portal_url ?? billing.portal_url — the refusal caption no longer promises a portal the UI didn't render. 5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan) instead of a bare return, so a null portal_url never strips the routing params. 6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two inputs stack below @2XL) with exact reservation: the edit form is always rendered and both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden when not editing — the row equals the tallest state at every width, no breakpoint math. The refusal stays inside the reserved layer. Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team & personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4. Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): reuse the shared openExternalLink helper in the plans view * fix(desktop): honor the auto_reload wire contract — null card + disable amounts A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two real desktop bugs in the auto-refill row: A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind card and _serialize_billing_state emits `card: null`, but the shared BillingAutoReload.card union had no null arm and use-billing-state dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null` to the shared union (contract honesty) and guard the read (`card?.kind`); null now falls through to the default enabled path, same as a canonical card. B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.) disable() now sends the current threshold_usd/reload_to_usd from the autoReload prop alongside enabled: false, matching the TUI. Tests: enabled auto_reload with card:null renders the normal enabled row (derivation + render, no crash); disable call carries both current amounts. Billing suite 80/80 green; typecheck (app/electron/e2e) + lint clean. * fix(tui): guard the nullable auto_reload card in the auto-reload screen The shared BillingAutoReload.card union gained its honest null arm (the gateway emits card: null for a missing/unknown card); the TUI's only bare dereference follows the same default path as a canonical card. * fix(desktop): align billing inputs to the sm control height The three billing inputs used an ad-hoc h-8 (32px) next to size=sm buttons (24px). They now use the control system's size=sm with a py-[3px] compensation for the input's real 1px border — buttons draw theirs as an inset shadow, so sm alone still sits 2px taller. All five controls in the buy row now measure 24px. * fix(desktop): plan-card actionability + billing view-model hardening Code-quality review of the Billing revamp (PR NousResearch#68722). BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a plans grid with zero enabled actions AND no portal link. The plan card gated its in-app button on `tiers.some(state !== 'current')`, which counts the (disabled) downgrade tiles. It now gates on an actual UPGRADE being present (`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same plan.action) falls back to overview. Reviewer structural items: - One "plans capability" verdict (personal + can_change_plan + subscription ok) is derived once in deriveBillingView and threaded to BOTH derivePlanCard and derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant lives in one place. - BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/ disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url` defensive branches in the consumers. - `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at its three sites (plan card price, grid ordering, summary plan line). - BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an `accountRows[]` + three `.find(id)` lookups. - The auto-refill row that edits in place carries an explicit `manageInApp: true`; AutoReloadRow keys off it instead of sniffing the action label/url. - tier-art header comment no longer cites an internal repo path; dead `?.` removed from RowValue (via a destructured const) and the plan-card link handler. Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): adopt inline-review nits on the billing plan card Resolves the inline suggestion threads: - plan-card gate reads a named `hasActionableTier` = "a tile carries an action" (union-safe `'action' in tier`, equivalent to the old upgrade-only check). - re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) rather than relying on outer narrowing. Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…l copy split (NousResearch#68689) * feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI billing changes: - /subscription on Free (admin/owner, interactive) prints the plan catalog (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly credits render as dollars). A numbered pick opens the manage-subscription deep-link directly with plan=<tier_id> appended. - subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=. The paid change flow's blocked/unknown-preview portal fallback carries plan= for upgrades only; downgrades stay generic/native. - /topup overview splits one-time top-up from automatic refill, the distinction stated in each first sentence ("Add funds now — a single charge…" vs "Refill when low — charges … automatically …"), keeping "credits" out of the dollars-only surface. - Downgrades remain native (chargeless scheduled change), unchanged. Updates the CLI-parity section of docs/billing-lifecycle.md and tests under tests/hermes_cli + tests/agent. * refactor(billing): share plan-catalog helpers + harden manage-url builder - subscription_manage_url now preserves unrelated portal query params (parse_qsl, popping only the contract-owned org_id/plan) and restricts to http/https schemes, matching the desktop URL builder — the function owns the contract. - Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free catalog and the paid picker/blocked-preview branch share one implementation: selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix hidden when absent/zero), and is_upgrade(state, tier_id). * fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy - Free catalog: accept a bare digit as a pick (the shared normalizer only knows the confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The Nth digit maps to the Nth printed row. - Extract one _open_url_in_browser used by every "open the portal" path, applying the device-code flows' console-browser / remote-session guard (webbrowser.open returns True even for lynx/w3m over SSH) and returning whether a real browser opened. - Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the Free catalog, the paid picker, and the blocked-preview branch. - /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when both amounts are present and finite; otherwise the generic sentence. * docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant) Remove the other-repo PR reference from the manage-URL row, and state the real downgrade invariant: a blocked downgrade may print the generic manage URL but never carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions and upgrades.
…e → undo (NousResearch#68761) * feat(desktop): native in-app downgrade (chargeless preview → schedule → undo) Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce to the portal — picking a lower tier runs the gateway pending-change flow in-app; the scheduled state renders on the plan card with an undo. Upgrades keep the portal deep link. - api.ts: add previewSubscriptionChange / scheduleSubscriptionChange / resumeSubscription wrappers over subscription.preview|change|resume ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse + BillingMutationResponse (now re-exported from types.ts). - use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule, refetch + onScheduled on success; typed refusals surface via the shared BillingRefusalInline, so insufficient_scope drives the existing step-up exactly like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo). Both accept a `simulate` switch so DEV fixtures click through with canned success. - plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect <date>. No charge now; you keep your current plan until then."). The scheduled downgrade target renders an inert "Scheduled" marker; other lower tiers stay actionable (picking one reschedules). - CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps. - use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView gains `pending`, derived from current.pending_downgrade_* . - inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline / StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware refusal renderer without a circular import; openExternal now delegates to the canonical @/lib/external-link opener. - dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free downgrade scheduled for Aug 15) for the plan-card pending state + grid marker. Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume + insufficient_scope refusal); view derivation (pending plan-card state, scheduled grid marker); confirm flow (preview shown, change called with the right tier_id, refetch on success, schedule refusal → step-up affordance); undo flow; the use-subscription-change hooks (preview-refusal retry, cancel, simulate path). Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck (app/electron/e2e) + lint clean. PR (later): base sid/desktop-billing-revamp; retarget to main after NousResearch#68722 (09) merges. * fix(desktop): format downgrade credits delta as signed dollars The downgrade preview rendered the raw wire string ("Monthly credits change: -88."), violating the "monthly credits are DOLLARS" ruling. NAS sends monthly_credits_delta as a bare decimal; format it as signed dollars through the same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero / absent still hides the line. Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/ absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm flow. Billing suite 99/99 green; typecheck + lint clean. * fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim Addresses the adversarial review of the native-downgrade diff. - Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule RPC is in flight). While a change commits, every other Downgrade tile and the Back button are disabled; the active panel's Confirm/Cancel already lock. The plan-card Undo blocks on its own resume via `busy`. (The server also 409s overlapping per-org mutations — this is UI honesty, not the only defense.) - Accessibility: the confirm panel is role="status" aria-live="polite" and takes focus on open (tabIndex=-1 container); closing it returns focus to the tile card, so keyboard focus is never stranded and the async preview text is announced. - DEV-gated simulation: the canned preview/change/resume seam is ignored unless import.meta.env.DEV, so a production build never takes the simulated branch even if a `simulate` prop leaks through. - Comments: documented the deliberate manual-retry-after-step-up (no auto-replay, matching auto-reload) and that name-matching the scheduled target is safe because SubscriptionTypes.name is @unique in NAS. Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule; simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule; Undo disabled mid-resume; confirm panel role + focus on open. typecheck (app/electron/e2e) + lint clean. * fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits Addresses the native-downgrade review threads. Scheduled cancellations were invisible (NEW review item). subscription.current carries cancel_at_period_end + cancellation_effective_* and subscription.resume clears cancellations exactly like downgrades, but the pending-transition helper only read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName, when } | { kind:'cancellation', when } — computed once in deriveBillingView and threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a cancellation has no target tier). Precedence: a downgrade wins if both fields are set (it names a concrete target — the stronger signal), commented at the helper. Adds a `pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker, downgrade-wins precedence). Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow) so two same-tick clicks — before React commits busy='schedule' — cannot fire two schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success). useResumeFlow reorders its unlock: a refusal releases immediately, a success holds runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending card. Test: a synchronous double-activation fires one schedule RPC. Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) instead of relying on outer narrowing / `?? ''`. Billing suite green (109); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): move DEV billing simulation behind the api seam The fixture simulation lived as `simulate` / `simulateResume` prop drills and `if (simulated)` branches inside the flow hooks, and it could not actually produce the state it advertised (a simulated schedule never showed the pending card). Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known, and supplied to the whole subtree via a new `BillingApiProvider` (context override on `useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy of the fixture and its subscription-change mutations WRITE that copy's pending state: schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation. Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted; queries always enabled; an effect refetches on fixture switch), so the click-through genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared. Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every `if (simulated)` branch — the hooks are now production-pure. Added a test driving the full simulated loop (schedule → pending appears → resume → cleared), plus cancellation undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): extract billing row/card components out of index.tsx Purely mechanical, no behavior change: split the settings billing route file (1065 → 593 lines) into focused siblings now that the downgrade feature has settled their final shape. - billing-amounts.ts — the dollar parse/format/validate/clamp helpers. - account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow). - current-plan-card.tsx — CurrentPlanCard. - auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor). index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules Polish that composes with the api-seam rework: - ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible combinations (a preview AND a refusal, "ready" with no quote) can no longer be represented; the hook and panel branch on one `kind`, and `mutating` is simply `phase.kind === 'scheduling'`. - The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase, fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`. - inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal` moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s `openExternalLink`), and `InlineMessage` moves back into its sole consumer (auto-reload-row.tsx). No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
The desktop Settings memory-provider dropdown read a hardcoded `ENUM_OPTIONS['memory.provider'] = ['', 'honcho', 'hindsight']` list, so user-installed and pip-installed providers never appeared even though the backend already discovers them (`GET /api/memory` -> `_discover_memory_provider_statuses()`) and the CLI (`hermes memory setup`) lists them. This was the one surface left where the memory config stack was not schema/discovery-driven. Fetch `getMemoryStatus()` on the settings page (mirroring the existing `elevenLabsVoiceOptions` pattern) and pass the discovered provider names to `enumOptionsFor` as `dynamicOptions` for the `memory.provider` key. The static `ENUM_OPTIONS` entry is demoted to a pre-load fallback; the current-value passthrough still keeps a selected-but-undiscovered provider visible. Completes the desktop half of the schema-driven memory-provider config surface (the CLI + backend + generic panel already landed via NousResearch#51020 / NousResearch#67206), superseding the stale NousResearch#48675 which built the same feature against the pre-refactor layout. Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com>
Addresses review on NousResearch#69077. The first pass added a second, heavier round-trip (`GET /api/memory` -> `_discover_memory_provider_statuses()`, which imports every provider module and probes install state) just to fill the desktop dropdown, and left `schema.options` for memory.provider dead — three sources of truth for one list. Root cause is narrower: the desktop schema *already* carried a discovery-driven `memory.provider` option list (`_SCHEMA_OVERRIDES` -> `_memory_provider_options()`), but `enumOptionsFor` returned the static `ENUM_OPTIONS['memory.provider']`, which shadowed `schema.options` in config-field.tsx. The only real gap was liveness: `_SCHEMA_OVERRIDES` is frozen at import time, so a provider installed mid-session never showed. Fix at the layer the rest of this stack already uses: - Backend: generalize `_schema_with_voice_provider_options` -> `_schema_with_dynamic_provider_options`, which now also recomputes `memory.provider` options per request (cheap plugin-dir scan via `_memory_provider_options`, plus current-value preservation). Fixes the same staleness for CLI + dashboard, not just desktop. - Frontend: drop the `memory.provider` entry from `ENUM_OPTIONS` so `enumOptionsFor` returns undefined and config-field consumes the discovery-driven `schema.options` directly. No new frontend round-trips. - Remove the now-unnecessary `getMemoryStatus()` fetch/state/wiring in config-settings.tsx (reverted to main). - Fix the stale `helpers.ts` comment ("schema omits memory.provider"). Tests: backend tests for the per-request merge (recomputes discovered providers; preserves a configured-but-undiscovered value); frontend test asserts enumOptionsFor no longer shadows the schema for memory.provider. Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com>
Cleanup pass on the per-request provider-options merge — behavior
unchanged:
- collapse the duplicated entry-validation shared by merge() and its
callers into a single guard inside merge()
- read the configured memory provider in readable steps instead of a
nested ternary
- build the merged mapping as one {**base, **overlay} expression
- space out logical blocks
…ldowns (NousResearch#69494) When Codex returns 429 usage_limit_reached, Hermes persists the provider's reset_at on the pool entry and freezes the credential until it elapses -- which can be days out for weekly windows. But the upstream window can reopen EARLY: the user redeems a banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades their plan, or OpenAI resets the window. Hermes never re-checked, so it kept erroring with 'Codex provider quota exhausted (429); retry after Ns' until a manual re-auth rewrote the tokens (issue NousResearch#43747, externally-reset variant). - hermes_cli/auth.py: add _probe_codex_quota_restored() -- a throttled (5 min/token) GET of the Codex /usage endpoint; quota counts as restored when every reported window is <100% used. Add clear_codex_pool_quota_cooldowns() to lift 429/quota-shaped cooldowns from persisted pool entries (DEAD and auth-shaped entries untouched). - resolve_codex_runtime_credentials(): before surfacing a pool-only cooldown as 'quota exhausted', probe upstream; on a positive probe clear the cooldown and return the pool credential. - agent/credential_pool.py: _available_entries() probes frozen openai-codex entries (clear_expired path only) and unfreezes them when upstream confirms the reset. - agent/account_usage.py: a successful /usage reset redemption now clears persisted pool cooldowns immediately. Negative paths preserved: probe 429/exhausted/indeterminate keeps the cooldown; read-only enumeration never probes; non-JWT tokens never probe (no network in hermetic tests).
…ed the activation Real-world failure from dogfooding the live-theme flow: display.skin was already 'synthwave' in config, but the desktop never visibly applied it (the activation event predated the WS transport fix / the connect). The desktop's gateway.ready seed records the baseline WITHOUT painting (by design — never stomp the persisted desktop theme on connect), so it believed it was synced. Re-running 'hermes config set display.skin synthwave' then did nothing twice over: the watcher signature (name, skin-file mtime) hadn't moved, so no skin.changed fired; and even on an event, the desktop's name-equality guard blocked the apply against the seeded baseline. Two halves: - hermes_cli: setting display.skin touches the named skin file so the watcher signature always moves on an explicit set — a same-name re-affirm now broadcasts skin.changed like any real move. Built-ins (no file) are unaffected; a name switch already moves their signature. - desktop: track whether the synced baseline was actually APPLIED vs merely seeded at connect. A skin.changed matching a seed-only baseline is an intentional apply and repaints; once applied, repeat same-name events stay no-ops (protects a manual desktop-side theme switch from snap-back, incl. across a reconnect re-seed).
…hadow-guard fix(desktop): clean stale tsc emit + guard gateway WS URLs
…ousResearch#54855) (NousResearch#67364) * chore(gitignore): ignore installer .install_method stamp Salvage of NousResearch#54855 by @drissman — rebased onto current main with root-scoped rule and sister-marker comments alongside .update-incomplete. Closes NousResearch#66189 Root cause: scripts/install.sh writes <install>/.install_method but git did not ignore it, so managed checkouts show ?? .install_method and hermes update may autostash the untracked marker. Fix: add /.install_method to .gitignore (repo-root only). Verification: git check-ignore -v .install_method * test(update): assert .install_method survives update autostash (NousResearch#66189) Add hermetic regression mirroring the .hermes-bootstrap-complete test: adopt the real .gitignore, drop the installer .install_method stamp, run the exact 'git stash push --include-untracked' the updater uses, and assert the marker is neither swept nor reported dirty. Requested by hermes-sweeper review on NousResearch#67364.
…n-steering feat: redirect active turns when users correct the agent
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ighten comments _emit and _broadcast_global_event were each building the JSON-RPC event envelope — extract _event_frame and use it from both. Type the registry as set[Transport] (protocol already imported), and cut comment bloat at the call sites. No behavior change; suites stay green.
…broadcast fix(themes): live skin sync reaches every surface — WS fan-out + missed-activation recovery
Live theme authoring's core loop — Hermes recolors the skin file it just activated — repainted the TUI but not the GUI. The event path was fine (post-NousResearch#69533 the WS broadcast lands and ingestBackendSkin refreshes the $backendThemes registry); the same-name apply guard also no-ops correctly (it's what protects a manual desktop theme pick). The repaint was supposed to come from the registry: the active theme IS that skin, its palette just changed. But ThemeProvider memoized deriveTheme on [themeName, resolvedMode] only, while deriveTheme reads the registry non-reactively via resolveTheme — so the store update re-rendered the provider and handed back the stale palette. Name switches repainted (themeName moves); recolors never did. Add the theme stores (user/backend/registry) to the memo's deps — they are deriveTheme's actual reactivity, same as the availableThemes memo directly above. applyTheme is idempotent, and $backendThemes only publishes on a real palette change, so no spurious repaints. Tests: render ThemeProvider for real — activation applies; a same-name recolor repaints (fails without the fix); an inactive-skin seed doesn't touch the painted theme.
…edit-repaint fix(themes): desktop repaints when the ACTIVE skin is edited in place
…ousResearch#69578) The submit "session context drift" guard (regression 7acaff5 / NousResearch#54527, partially fixed by 8c28876 and da52ffe) aborted a prompt submission whenever the selected stored id OR the route token changed mid-submit. Both signals churn programmatically on a busy gateway, so on machines with background streaming sessions, per-minute cron sessions, the Telegram surface, or gateway-profile switches, essentially every send from a second chat aborted silently: the optimistic message was dropped, the draft was left in the composer, no error was shown, and prompt.submit never fired. The false-positive churn sources were: - selection null-resets — gateway-switch's setSelectedStoredSessionId(null) on a gateway/profile switch or reconnect read as a switch away; - search/hash-only route-token changes — overlays and side panels park state in location.search/hash, so the pathname (the only part that selects a chat) was unchanged yet the raw token differed; - background-event active-ref retargets — createBackendSessionForSend's 3-prong check also watched activeSessionIdRef, which gateway events retarget while other sessions stream (NousResearch#47709 class), during a seconds-long session.create round-trip. New shared helper session-context-drift.ts reduces a route token to the chat it targets (pathname only; the new-chat route is '__new__', non-chat routes null) and reports drift only when selection or the routed chat moves to a DIFFERENT, non-null chat that is not the submit's own target. Selection null-resets, search/hash-only churn, and moves onto the submit target are no longer drift; genuine user switches (click another chat, click New Session mid-submit) still abort. Site A (submit.ts) routes all five guard points through the helper and logs '[submit-drift-abort]' with a per-site phase; the post-create active-ref check and baseline re-pin from 8c28876 are kept intact. Site B (createBackendSessionForSend) drops the active-ref prong entirely — every real switch retargets selection and route synchronously — and logs before closing the orphaned session. (cherry picked from commit b390e3a) Co-authored-by: Kennedy Umege <kenmege@yahoo.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…cated RPC routing (NousResearch#68229) * fix(desktop): route /compress through session.compress RPC with transcript replacement Salvages NousResearch#44462, NousResearch#53755, and NousResearch#68218 into a single canonical fix for the desktop /compress cluster. The desktop routed /compress through slash.exec, which sends it to the _SlashWorker subprocess. Compressing a large session outlives both the desktop's 30s WS timeout and the worker's 45s pipe timeout — the client gives up, runExec's blanket catch swallows the error, and command.dispatch surfaces a misleading "not a quick/plugin/skill command: compress" (NousResearch#44456). Even when compression succeeded via the _mirror_slash_side_effects path, the desktop never received the post-compress message list, so summarized bubbles stayed on screen forever — /compress looked like a no-op. This change routes /compress to the dedicated session.compress RPC (the TUI's path), combining the best of all three PRs: - 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (NousResearch#44462) - Transcript replacement from the response `messages` via toChatMessages, the same converter session.resume uses (NousResearch#68218, teknium1 review on NousResearch#44462) - Session-isolation guard: updateSessionState only publishes for the active runtime, so a late result after a session switch can't clobber the foreground transcript (NousResearch#53755, teknium1 review on NousResearch#53755) - Coalescing: dedup concurrent compress requests per session (NousResearch#53755) - Progress toast ("compressing context...") outside the transcript (NousResearch#53755) - Error unmasking in runExec: when slash.exec fails and command.dispatch only adds "not a quick/plugin/skill command" routing noise, surface the original worker error instead (NousResearch#44462) - /compact alias + focus_topic forwarding Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com> Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com> * feat(desktop): route slash commands with dedicated RPCs to those RPCs Salvages NousResearch#63513 — introduces a new `rpc` kind on DesktopCommandSurface so commands with a first-class gateway @method handler bypass slash.exec / command.dispatch entirely, and a `renderRpcResult` utility that shapes each RPC's structured reply into readable transcript text. Migrates 6 commands from exec() to rpc(...): /agents → agents.list /save → session.save /status → session.status /steer → session.steer /stop → process.stop /usage → session.usage /compress stays as action('compress') — it needs transcript replacement from the response `messages`, which the generic rpc path can't do (per teknium1 review on NousResearch#44462/NousResearch#63513). Also includes the json-rpc-gateway timeout message improvement: the error now includes the configured timeout duration ("request timed out after 120s: session.compress") so a user can tell whether the default 30s fired or a per-call override. Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com> * fix(desktop): preserve provider choice during config initialization * fix(desktop): preserve slash command and host compression semantics Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec. Propagate the full compression timeout through compute-host control, return structured host compression outcomes with metadata, and retain successful compression feedback in the desktop transcript. Add regressions for timeout forwarding, host aborts and metadata sync, structured host control responses, command routing parity, and numeric stop counts. * fix(desktop): harden compression state handling Preserve the invoking stored-session binding for delayed compression results, normalize replacement histories, and serialize provider selection. Stabilize gateway platform tests and guard the desktop Git facade during renderer teardown. --------- Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com> Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com> Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
Exercise the full Electron, gateway, and mock-provider submit path while same-chat route query tokens churn during session creation. Assert the mock provider receives the prompt and its streamed response reaches the transcript.
* nous portal model pricing * update top message
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.
Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
(MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
self-update path, the deprecation-banner state, the postinstall subcommand,
wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
installs (which don't set HERMES_MANAGED) are correctly identified as
"nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
.install_method stamps (both code-scoped and home-scoped) are ignored by
the allowlist reader and fall through to "unknown" instead of resurrecting
a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
tests; adds parametrized coverage for the packaging build guard covering
BOTH sdist and wheel paths (the guards live in separate cmdclass entries
— a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
a finding, while additions or modifications still require the existing
ci-reviewed label gate.
Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
…'s fg, not the skin's
Live-repaint's composer gap: flip a light terminal to a dark skin and the
input goes black-on-black. The placeholder was already explicit truecolor
(theme muted), but TYPED text rendered with no color at all — the terminal's
default foreground — in both paint paths:
- the Ink render (<Text wrap="wrap">{rendered}</Text>, no color), and
- the fast-echo bypass, which writes raw cells straight to stdout.
The skin owns the background (OSC-11) but the default fg still belongs to
the host terminal's polarity, so any skin/terminal polarity mismatch made
input invisible. Every other transcript line already paints
theme.color.text (the completed inputBuf rows directly above the composer).
Give TextInput a color prop and paint both paths with it: the Ink <Text>
(chalk re-opens the outer color after the placeholder chips' embedded [39m
closes; INV cursor/selection cells never touch fg) and the fast-echo write
via colorizeEcho — same explicit-truecolor-only rule as colorizeHint, so
the bypass cell can't flash terminal-default before the next frame. All six
TextInput sites (composer, prompts, masked, billing ×2, session switcher)
pass theme text; no color ⇒ passthrough, unthemed inputs keep the terminal
default.
Tests: colorizeEcho SGR wrap + passthrough contracts; full ui-tui suite
1338✓; typecheck clean.
…response-nudge fix(desktop): keep first response layout stable
…theme-color fix(ui-tui): input text goes invisible when a live skin flips the terminal's polarity
…ide the OSC-11 background The input fix's sibling, hit immediately after: the composer was themed but AGENT text went black-on-black the same way. Root cause is the class, not the call site — markdown body, borders, and every token rendered without an explicit color falls back to the terminal's DEFAULT foreground, which belongs to the HOST profile's polarity, not the skin's. A dark skin on a light terminal repaints the backdrop via OSC-11 while thousands of default-fg cells stay near-black. Chasing every <Text> is unwinnable. Instead own the default itself: when a skin authors a background (the existing opt-in), paint the default foreground from the resolved theme's text color via OSC-10. Every unthemed token — present and future — re-bases onto the skin atomically, exactly like the background. terminalModes: the OSC-11 slot generalizes to defaultColorSlot(10|11) — same paint/clear/exit-restore contract, tracked per slot, so a skinless session still never touches the terminal. reapplyTheme repaints the fg too: polarity flips swap paired palettes, moving the text tone while the background stays. Tests: slot contract runs table-driven over both OSC codes; handler test pins the invariant (default fg == theme text; dropping the background releases both defaults). Suite 1344✓, typecheck/lint/prettier clean.
…nal-default-fg fix(ui-tui): a skin owns the terminal's DEFAULT foreground (OSC-10) — kills the invisible-text class
…esearch#69635) * test(desktop): e2e for warm-route resume render jitter Pre-seeds a 32-message session into state.db, boots the app, does a cold resume (populates warm cache), navigates away, then clicks back (warm resume). A MutationObserver + innerHTML-length poll detects whether the transcript is re-rendered after the initial warm-cache paint — the jitter bug where syncSessionStateToView fires twice (warm cache paint, then session.activate RPC reconcile). * fix(desktop): warm-route resume jitter from double setMessages The warm resume path in resumeSession() calls syncSessionStateToView twice: once for the warm cache paint, then again after the session.activate RPC reconciles messages. The second call created new message objects via toChatMessages (different references, same content), and flushPendingViewState's sameMessageList guard used reference equality per slot — so it always failed and setMessages fired a second time, causing a visible transcript re-render. Replace sameMessageList (reference equality) with chatMessageArraysEquivalent (deep content comparison: id, role, parts, pending, error, etc.). This was already used by the cold path's fast-path guard; the flush guard was the last holdout using shallow reference equality. Also updates the e2e test to use textContent polling on the first message element (instead of innerHTML on the full viewport) to avoid false positives from metadata-only DOM changes. * test(desktop): e2e for warm resume after background inference Extract the render counter (MutationObserver + text-content poll) and assertion into reusable helpers. Add a second test that sends a message, waits for the mock response to complete, navigates away, then warm- resumes — verifying the warm cache already has the completed turn from message.complete events and no second paint occurs.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…rch#69649) Keep a live session projection from adding its user turn when the latest persisted row already represents that same inflight prompt. Add real Electron coverage for fast and cold resume with idle and background-inference sessions.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ueue entries Fix NousResearch#69638 Two complementary fixes for large image queued prompts causing reconnect loops: 1. hermes_cli/web_server.py: Set uvicorn ws_max_size=32 MiB so a 16 MiB binary image (~21 MiB base64) doesn't breach the default 16 MiB limit. 2. apps/desktop/src/store/composer-queue.ts: Strip previewUrl data URLs and uploadState from queued attachments before persisting to localStorage. Without this, 6 large images can push the hermes.desktop.composerQueue.v1 value past 49 MB, which both stalls the renderer on restore and produces WebSocket frames that hit the uvicorn limit, triggering a reconnect loop. Also adds a load-time sanitization pass that retroactively cleans any previously persisted heavy entries without requiring manual intervention.
x7peeps
deleted the
fix/issue-69638-desktop-large-image-reconnect-loop-ls-persistence
branch
July 22, 2026 22:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Fix NousResearch#69638: Large images (~13MB JPEG -> ~18MB base64) in a queued Desktop prompt exceed Uvicorn's default 16MB WebSocket limit, causing persistent reconnect loops. Full previewUrl data URLs are persisted to localStorage, reaching 49MB with 6 images, surviving restarts.
Root Cause
Uvicorn ws_max_size not set: hermes_cli/web_server.py's uvicorn.Config omits ws_max_size, defaulting to 16,777,216 bytes. A 16MB binary image inflates to ~21MB base64, exceeding the limit and causing WebSocket disconnect (code 1006). Desktop reconnects and retries indefinitely.
localStorage persists full previewUrl data URLs: composer-queue.ts shallow-clones attachments including previewUrl (base64 data URL) and persists the full state via JSON.stringify. Six images produce a 49MB localStorage value.
Fix
hermes_cli/web_server.py: Set ws_max_size=32 MiB in uvicorn.Config to accommodate 16 MiB binary images after base64 expansion (~21 MiB) with comfortable headroom.
apps/desktop/src/store/composer-queue.ts:
The previewUrl is a display-only thumbnail regenerated from the local file on restore; the actual upload path remains intact.
Closes NousResearch#69638