Commit Graph

3006 Commits

Author SHA1 Message Date
hermes-seaeye[bot] bb92473074 fmt(js): npm run fix on merge (#88720)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 22:59:42 +00:00
embwl0x 55e34fb7d0 fix(desktop): avoid local profile REST backend spawns 2026-08-17 15:51:02 -07:00
Gille 976183f536 fix(desktop): stop deleted profiles reconnecting 2026-08-17 15:46:44 -07:00
Teknium 4151599e81 fix(desktop): cross-machine bot DMs — canonical chat, sender attribution, reply relay
Follow-up to #88664. The remote @mention delivery path had three gaps that
made cross-machine DMs half-work:

1. No reply relay: deliverRemoteRosterMentions submitted the prompt and
   toasted, but never polled — the handoff note promised a relay that never
   came. Now a bounded poll (same shape as a group member turn: new
   assistant message after the baseline, 180s cap) relays the recipient's
   reply as a notification, or says it's still pending.
2. No sender attribution: the raw user text was submitted, so the
   recipient's messaging protocol never recognized an agent-to-agent
   message. Deliveries now carry the standard
   "Message from 🤖 <sender> (@handle):" prefix.
3. Duplicate Bot Chats: every mention minted a fresh "Bot Chat" session.
   ensureRemoteCanonicalChat now resolves the recipient's pinned canonical
   chat from its profile ui_meta, falls back to resume-by-title, and only
   creates when neither exists — mirroring ensureGroupChatSession.

Tests: remote-dm-delivery.test.mjs (pin-resume without create, attribution
prefix + reply relay via vm-run behavior tests, source contract for the
bounded poll). Plugin suite 187/187.
2026-08-17 15:28:43 -07:00
Teknium d0b02efa9d fix(desktop): don't treat a mid-switch 404 as session deletion (#88540)
A resume racing a profile/connection swap can 404 on a backend that
does not own the session; the terminal-failure branch then dropped the
window to the blank new-chat route while the target session was alive.

goneSessionVerdict() now gates the draft fallback: a session created
this run, still listed on some profile, or looked up while a gateway
swap is in flight arms the bounded auto-retry latch instead of
discarding the route. Draft remains the calm-conditions path for
verifiably dead ids.
2026-08-17 15:27:00 -07:00
NealZhouPanda 8484786832 fix(desktop): align BOTS roster preview with the session its click opens
The BOTS sidebar previewed each profile's most recently active session
(last_session) but clicking the row opened the pinned canonical chat —
two different session identities, so the preview described one
conversation and the click landed in another.

- profiles.list gains an optional preferred_session_ids param
  ({profile: session_id}): an exact, existence-checked per-profile
  lookup that resolves hidden rows and compression lineages to the
  live tip (the same resolver session.resume uses) and returns a
  preferred_session summary alongside the unchanged last_session.
- The hermes-bots plugin sends its canonical-chat pins with each
  roster poll and previews preferred_session ?? last_session.
- openBotCanonicalChat verifies pins through the precise resolver
  instead of a paginated, hidden-excluding session.list window that
  misjudged real hidden pins as gone; transient lookup failures no
  longer clear the pin or mint a replacement chat.
- Grandfathering: a bot with history but no pin adopts the previewed
  session on first open instead of minting a new empty chat — the
  behavior the design comment already promised.

Closes #88200
2026-08-17 15:26:48 -07:00
Adolanium 69a62ce2c8 fix(desktop): keep the Bot Chat pin
A failed intro used to clear the pin even though the chat was already made, so the next click opened a second one. A missing pin grabbed the newest session, even when a real Bot Chat was in the list.

Failed intros now keep the pin. A missing pin looks for a session titled Bot Chat. If that is not there, we try the stored pin instead of the newest row.

Ported from NousResearch/Hermes-Bot-Mode#59 after Bot Mode moved in-tree.
2026-08-17 15:26:48 -07:00
hermes-seaeye[bot] dc2e9dde9a fmt(js): npm run fix on merge (#88671)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 21:39:36 +00:00
Teknium 62f376279b feat(desktop): cross-connection Bot Mode — Create-on picker + multi-machine group chats
Builds on the salvaged #88598 multi-source roster work:

- New Agent "Create on" picker (multi-connection registries only): the
  profiles.create/configure/describe/mcp.catalog calls route to the picked
  connection's backend via host.requestProfile route descriptors — the
  window's active gateway never switches. Remote-target drafts discard via
  the remote CLI; appearance/title write into the remote profile's ui_meta
  and asset store; the taken-name check is scoped to the target machine's
  roster; the live SkillsView Capabilities tab (active-gateway-bound) falls
  back to the staged checklists for remote targets.

- Group chats can seat bots from other registered connections: member turns
  (session.create/resume, prompt.submit, reply polling) route to each
  member's own source through the new requestForBot helper. Remote member
  descriptors persist on the room record (bot-meta is active-gateway-scoped
  by design); watermarks/sessions key by source-qualified member keys so
  same-named agents on two machines never share state; @name-device handles
  resolve in room mentions; room lines and turn prompts badge cross-machine
  speakers with their device.

- pidIsOurDashboard: a dead remote PID (ps exits non-zero) now reads as
  FOREIGN instead of throwing "Could not verify SSH backend process
  ownership" — the misleading error from #88625's secondary report.

Tests: cross-connection-bots.test.mjs (route descriptors, requestForBot
routing, member keys, disambiguated mentions, device badges, source
contracts); plugin suite 180/180; electron vitest 220/220.
2026-08-17 14:32:52 -07:00
Al Zilla f89f884a5d fix(desktop): mention Connections bots from this local chat
Stay on the default gateway. Bot Mode still lists every Connections
agent. Clicking a remote row no longer hops the window onto SSH —
@dixie / @bob-spark in this chat resolve against the roster and
Desktop delivers in the background via requestProfile.
2026-08-17 14:32:52 -07:00
Al Zilla 30299efa38 fix(desktop): open SSH default bots on the remote root profile
Clicking Mac Mini / Spark (the device default row) passed the desktop
pool key as the remote Hermes profile. That profile does not exist, so
the chat never opened. Named profiles (bob, dixie) already sent a real
name and worked.

Also refuse to fall back to this-device's default chat pin when the
remote source did not actually become active.
2026-08-17 14:32:52 -07:00
Al Zilla c2c3058eca fix(desktop): never spawn SSH dashboards just to list Bot Mode agents
A leftover sshConnections key made roster polling call
ensureRegistryBackend for every SSH source every ~5s. That spawned
remote dashboards, the mux died, and the renderer hit hermes:api
ECONNRESET / liveness-probe drops.

SSH inventory stays on the cached ls path only. Clicking a bot still
dials that one source.
2026-08-17 14:32:52 -07:00
Al Zilla 4afe08f494 fix(desktop): retry failed SSH Bot Mode inventory after cooldown
A first inventory miss used to stick forever as a seeded default until
the user hit Test. Retry after 60s; a successful cache still never
re-probes, and Test still forces an immediate refresh.
2026-08-17 14:32:52 -07:00
Al Zilla 89877d10a5 fix(desktop): drop Bot Mode rows for removed SSH connections
Remembered remotes were restored whenever the union omitted their
connection id, so a deleted registry source could keep resurrecting
until remount. Only restore rows that still belong to a registered
source.
2026-08-17 14:32:52 -07:00
Al Zilla 4068162118 fix(desktop): keep SSH Bot Mode agents visible across local clicks
Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.

Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
2026-08-17 14:32:52 -07:00
Teknium bd9c7d87f0 feat(desktop/bots): add disband (delete) for group chats
Group chats could be created but never deleted — the only way out was
manually ungrouping every member, which still left the shared room log
in plugin storage forever.

The group-chat workspace header now has a trash button behind a
ConfirmDialog. Disband is soft: it clears every member's group
assignment (syncs cross-machine via ui_meta), drops the room log from
the atom and the persisted group-chats map, clears the needs-you badge,
and closes the room view. The members' per-group gateway sessions
("Group: <name>") are intentionally kept and remain reachable from each
bot's session browser. A room with a drive still in flight leaves a
runtime-only epoch-bumped tombstone so the round-robin loop bails at its
next member boundary; the tombstone is never persisted.
2026-08-17 14:32:11 -07:00
Teknium 39c0bc80b7 feat(desktop): link the installer from the app-build-out-of-sync warning
The skew banner told users to run the in-app update, but on machines where
the local desktop pack is the broken step (e.g. the get-windows win32
binding staging failure in #88251), rebuilding in place cannot succeed.
Reinstalling from the packaged installer sidesteps the local build
entirely, so offer it as the escape hatch:

- Settings > About skew banner gains a "Get the installer" button opening
  https://hermes-agent.nousresearch.com/ via openExternal
- banner copy now mentions reinstalling when the update doesn't clear the
  warning
- all 5 locales updated (new bundleOutOfSyncAction key)
2026-08-17 14:31:09 -07:00
hermes-seaeye[bot] cf7b3d0c90 fmt(js): npm run fix on merge (#88652)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 21:15:38 +00:00
Brooklyn Nicholson 3ead0f8dc1 feat(desktop): widget clicks reach the agent as hidden user turns — the widget updating IS the response
An inline ::preview widget could render and be clicked, but the click went
nowhere: the sandbox has no channel to the agent, so an interactive chart
was a dead end. Now the frame injects a second script beside the measurer
that gives the page one voice:

  window.hermes.send('get-price eth')
  <button data-hermes-send="get-price eth">ETH</button>  (zero-script form)

The prompt rides postMessage up tagged with the mount token, then goes
through the composer's own send path (requestComposerSubmit -> prompt.submit)
flagged display_kind=hidden — the same row-typing auto-continue and internal
notifications already use. The agent wakes and takes a real turn; the
durable row persists (context, resume, DB audit); but NO bubble renders,
live or on reload. The user clicks ETH and the chart just changes — the
off-screen loop is click -> hidden turn -> agent rewrites the widget file ->
frame hot-swaps.

Trust boundary matches size reports and is tighter where it matters: mount
token required (frames can't forge each other's intents), string-only,
trimmed, capped at 500 chars, throttled to one intent per second per frame.
The gateway whitelists display_kind to "hidden" — the RPC can't mint
arbitrary row types — and the flag threads through both turn paths (inline
and compute-host isolation) so isolated sessions don't resurrect bubbles on
resume.

The desktop platform hint teaches the model to wire interactive widgets
with data-hermes-send and to answer clicks by updating the widget's file
rather than with prose; the SDK doc documents the contract.
2026-08-17 16:08:18 -05:00
Teknium a13be69572 feat(desktop): warn in About when the app build is out of sync with the runtime
hermes update moves the source tree, but the desktop UI (including bundled
plugins like Bot Mode) is compiled into the app binary at build time. A
terminal-side update — or an in-app update whose bundle-swap leg failed —
leaves a new runtime under an old renderer: About reports the new Hermes
version while the sidebar is missing that version's desktop features
(the 'no Bots tab after the Bot Mode update' reports).

Detect the skew by comparing the packaged install-stamp commit against the
tree (git rev-list --count <stamp>..HEAD -- apps/desktop) and surface it:

- Settings > About: amber warning banner pointing at the updater
- macOS native About panel: suffix on the version line
- hermes:version IPC gains bundleOutOfSync / bundleCommitsBehind

Fail-quiet by design (no stamp / fallback stamp / git failure = no warning)
so dev runs and non-git builds never see a false 'install is torn' alarm.
All 5 locales covered.
2026-08-17 14:05:09 -07:00
Teknium b779fbf423 feat(desktop): route the bots face clock through the SDK budgeted loop
Exports createBudgetedLoop (+types) through the plugin SDK and migrates the
Bots plugin's face-animation clock onto it. The hand-rolled clock only
checked document.hidden; via the shared loop it now also pauses while the
window is minimized or unfocused, matching every other desktop render loop.

- sdk/index.ts: export createBudgetedLoop/BudgetedLoop/BudgetedLoopOptions
  from @/lib/budgeted-loop with plugin-facing guidance
- hermes-bots/plugin.js: startFaceClock delegates scheduling to the SDK loop
  when present (fps 15, idleWhen = no visible faces); paint body, IO-based
  visibility tracking, and the 1Hz rescan are shared; the hand-rolled rAF
  path remains as a feature-detected fallback for older desktops, per the
  plugin's established SkillsView/McpTab pattern
- tests: new SDK-path case (fps/idleWhen wiring, visibility wake, re-entry
  wake, dispose-on-stop) driven through an injected fake loop; verified to
  fail with the SDK branch removed; existing fallback-path tests unchanged
2026-08-17 13:54:53 -07:00
Brooklyn Nicholson aeabff6aec fix(desktop): satisfy the plugin-render and import-order lint rules
CI's check:lint caught three real issues in the directive surface:

- TranscriptDirectiveLeaf called the contribution's render() inline in JSX
  — the exact pattern no-restricted-syntax bans because the callback's hooks
  land in the host and a plugin reload changes the host hook count
  (React #310). The callback is now memoized and mounted via ContribRender.
- The inline frame mirrored its height state into a ref from the message
  handler (the stale-read pattern no-restricted-syntax flags). Functional
  setState reads current state directly; the shadow refs are gone.
- perfectionist import/export ordering in the frame and the SDK index.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson a1fea53454 feat(desktop): inline previews read as native widgets — content-sized, theme-bridged, interactive
The first inline frame was a full-width bordered box at a fixed height:
webpage-in-a-rectangle, not a widget. Now the frame disappears into the
message flow:

- Content-driven size. The injected measurer reports height (live) and
  intrinsic width (adopted once, so %-width children can't feedback-loop the
  frame toward zero). A sparkline shrink-wraps and sits flush left like an
  inline image; a full-bleed page measures the whole viewport and stays
  column-wide. The height attribute is now only a starting value.
- Theme bridge. A style prelude injects first with the app's resolved theme
  tokens under stable names (--foreground, --muted-foreground, --accent,
  --border, --card), the app font, zero body margin/padding, and a
  transparent background — reference HTML written against those vars renders
  native in any theme. Page styles override the prelude, so a page that
  brings its own design keeps it.
- No chrome. Border, rounded box, and the rail-opener card under the frame
  are gone; the fallback paths (non-HTML, remote gateway, unreadable file)
  keep the classic card. The wheel gate went with the border — frames size
  to content, so there is nothing to scroll inside, and widgets are fully
  interactive.
- The desktop platform hint now teaches the default: an inline widget is
  transparent, token-colored, flush left, no page chrome — only a standalone
  page brings its own background. "Make me an inline sparkline" gets native
  styling without the user spelling it out.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson d690e0220e fix(desktop): drop provider-echoed duplicate text so replies don't render twice
Some providers re-send the previous assistant text verbatim when a turn
continues past a tool call (a tool_calls row, then a stop row with identical
prose — both persisted). The turn merge folds both rows into one bubble, so
every paragraph in the reply rendered twice; inline ::preview frames made it
obvious. Repeated text parts now dedupe in the same pass as generated-image
echoes — the last occurrence wins.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson 5c4f1e744c fix(desktop): inline preview frames size to their content instead of a fixed default
The frame was a hardcoded 280px unless the model guessed a height attribute
— tall pages clipped (the flip-clock demo cut its last digit), short ones
floated in dead space. The opaque-origin sandbox means the parent can't
measure the document, but we own the srcdoc string: a tiny injected script
observes the document with ResizeObserver and posts its scrollHeight up via
postMessage, and the frame tracks it live within the 120-1200 clamp.

Reports are validated before they can move layout — per-mount random token
(two previews in one transcript, or a hostile page inventing messages,
can't move each other's frames), finite-number check, clamp. A 4px
tolerance stops vh-sized pages (which measure exactly what they're given)
from oscillating; an explicit height attribute still opts out of
auto-sizing entirely.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson 8425f8286b feat(desktop): ::preview renders the page live inside the message, not just a rail-opener card
The first cut of the core ::preview consumer rendered the classic
preview-attachment card — a button into the right rail we already had, which
made the directive indistinguishable from an ordinary preview link. Now the
directive shows the thing itself: the workspace HTML file renders in a
sandboxed srcdoc iframe inline in the assistant message (opaque origin,
allow-scripts only — no reach into the app, its storage, or the bridge),
with an optional height attribute clamped to 120-1200px and the classic
card kept below as the rail escape hatch.

The frame waits for turn settle before reading the file (mid-stream it is
often mid-write), resolves relative paths against the session's own cwd,
and falls back to the plain card for non-HTML targets and remote gateways
(no local file door there).
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson 59b1c40cdf feat(desktop): plugins can render inline components in assistant messages via ::name{...} directives
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.

Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
2026-08-17 15:12:53 -05:00
Teknium d109785bb3 feat(desktop): shared createBudgetedLoop helper for decorative rAF animations
Desktop shipped the same bug class four times in one week: a decorative
animation loop that never sleeps (bots face clock #88543, pixel egg #88406,
diffusion placeholder #88564, plus the #77651 hidden-renderer wave). Each fix
hand-rolled the same four behaviors. This extracts them into one helper in
src/lib/budgeted-loop.ts:

- fps budget (default 15) on top of rAF
- observability pause via the existing createRendererLoopPauseController
- idle dormancy: idleWhen() true after a draw parks the loop with zero
  pending work until wake() — the piece every hand-rolled loop forgot
- teardown: dispose() cancels the frame, disposes the controller, and makes
  wake() a no-op

DiffusionCanvas migrates onto it as the first consumer (net -40 lines at the
call site); its existing scheduling/budget/instance-cap tests pass unchanged
against the migrated implementation. Helper suite covers budget, pause,
park/wake, dormancy-survives-focus-churn, and dispose idempotency; sabotage
run (budget+dormancy stripped) fails 4/5.
2026-08-17 12:12:24 -07:00
hermes-seaeye[bot] d7f5acb94e fmt(js): npm run fix on merge (#88567)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 18:39:22 +00:00
Teknium 187e5b2b95 fix(desktop): stop phantom local default on remote-gateway desktops; keep the main agent named Hermes
Follow-up to #88523/#88542 from a community report (main agent listed twice,
both rows unnamed @default handles). Two distinct bugs, both specific to
remote-gateway-primary desktops:

1. Phantom "This device" default (electron): the roster enumeration dialed
   ensureRegistryBackend for the registry's local entry unconditionally. On a
   remote-primary desktop that forces resolveRegistryLocalRoute into the
   forced-local branch — SPAWNING a local backend the user never asked for.
   That backend enumerates a `default` profile, so a second default agent
   appears AND the duplicate-handle rule forces -device suffixes onto the
   real one. New shouldDeferLocalEnumeration() treats the forced-local route
   as connect-on-demand (same courtesy as undialed SSH sources): the local
   entry only enumerates when it is the delegate route (local-primary
   desktops, byte-identical behavior) or a forced-local child is already
   pooled (the user opened one).

2. Main agent renamed to a connection label (plugin): displayName keyed the
   "show the connection label for a default row" rule off sourceScoped —
   which annotation also sets on ACTIVE-source rows. A remote-gateway user's
   main agent rendered as an IP-derived label (or bare handle) instead of
   "Hermes"/their title. Key it off remoteSource: only THIN rows from
   another source trade the friendly name for their source label.

Tests: shouldDeferLocalEnumeration route matrix (delegate always enumerates;
forced-local defers until a conn:local:: child exists; bare-key remote
descriptor doesn't count), displayName regression (active default stays
Hermes/title; thin remote default still shows its source label).
2026-08-17 11:33:05 -07:00
Teknium 577f5cf207 fix(desktop): route Bot Mode group-chat Markdown through the plugin SDK
Follow-up to the cherry-picked fix for #88391: the bare `streamdown` import
broke the plugin's side-load contract (vm-harness tests and the legacy-sdk
tmpdir loader can't resolve bare specifiers, and the runtime plugin door only
maps @hermes/plugin-sdk and react). Export Streamdown from the SDK instead,
feature-detect it in plugin.js like SkillsView/McpTab (plain-text fallback on
older desktops), and fix the indentation at the render site.
2026-08-17 11:29:47 -07:00
negroni334 cf2d28a2ff fix(desktop): render Markdown in Bot Mode group chat messages (fixes #88391) 2026-08-17 11:29:47 -07:00
Teknium e3eddbb934 test(desktop): cover DiffusionCanvas 15fps budget and instance cap
Extends the scheduling test suite from #88407 with behavioral coverage for
the salvaged #79327 work: frames inside the 1000/15 budget reschedule
without repainting, instances over MAX_ANIMATED_INSTANCES draw one static
frame and skip the loop, and the counter releases on unmount. Both tests
verified to fail against the pause-controller-only version.
2026-08-17 11:29:28 -07:00
Chen Jin ee616b4b64 fix(desktop): throttle DiffusionCanvas rAF loop and cap concurrent instances
- Throttle frame rate to ~15fps via timestamp check instead of
  redrawing on every requestAnimationFrame callback
- Pause animation when document.hidden, resume on visibilitychange
- Cap concurrent animated instances at 2; extras render a single
  static frame instead of starting another animation loop
- Fixes #79077
2026-08-17 11:29:28 -07:00
Jakub Wolniewicz c776bdd244 fix(desktop): pause inactive diffusion rendering 2026-08-17 11:29:28 -07:00
Jakub Wolniewicz 36d5dd3aee fix(desktop): sleep idle pixel egg animation 2026-08-17 11:26:23 -07:00
Ayush Nangia eb63c254bc test(desktop): remote-sub-profile routing cases
- remote primary + no own entry -> shared primary, scoped per request
- remote primary + own local entry -> local pooled backend
2026-08-17 10:58:48 -07:00
Ayush Nangia 15dd3bf586 fix(desktop): route remote sub-profiles through the primary's remote gateway
A URL-remote desktop whose PRIMARY profile has a per-profile remote
override lists the gateway's sub-profiles in the Bots pane, but
clicking one fell through the routing table's last case and spawned a
fresh local backend that shared nothing but the name (#88296).

resolveProfileBackendRoute now consults primaryRemoteActive: when the
primary's own backend is remote and the sub-profile has no stored
entry of its own, it routes through the primary gateway with profile
scoping (the same shared-primary flow global remote uses). Profiles
with their own local entries still pool locally.
2026-08-17 10:58:48 -07:00
David Dudok de Wit ed20a6f01a fix(desktop): preserve Bot Mode source routing 2026-08-17 10:58:48 -07:00
Teknium ce1f5dd30d fix(desktop): park the bot face clock when idle and tear it down on plugin disable
Follow-up to the salvaged #88219 visibility/15fps work:

- Dormancy: the rAF loop stops scheduling frames when no faces are mounted
  or none are visible, instead of running the 1Hz whole-document shadow-root
  scan forever. A mounting BotFace or a face scrolling into view wakes it.
- Teardown: register() now hooks ctx.onDispose so disabling the Bots plugin
  (or a hot reload) cancels the animation frame, disconnects the
  IntersectionObserver, drops cached nodes, and clears window.__hbFaceClock.
  Previously the loop ran until app restart even with the plugin disabled.
- Behavioral tests for park/wake/stop via a vm-extracted clock harness,
  verified to fail against the pre-fix source.
2026-08-17 10:58:45 -07:00
digitalbase ba2fb191c6 fix(desktop): bound bot face animation work 2026-08-17 10:58:45 -07:00
Teknium ece487ceee fix(bot-mode): live active-connection id for roster classification + source-qualified row keys
Layers on the salvaged #88489 (@29206394) and #88341 (@frizikk):

- sdk: host.activeConnectionId() — registry id of the LIVE active gateway.
  The salvaged fix classifies against the registry primary; after the user
  activates a non-primary source's agent, profiles.list answers from THAT
  source and primary-based matching would duplicate the active source's
  agents again. Live id wins, primaryConnectionId is the fallback, the
  legacy kind==='local' rule covers older desktops.
- plugin: roster/chip/picker list keys are botRowKey(bot) — source-qualified
  (connectionId, name) — so same-named agents on two genuine sources can
  never collide as duplicate React keys (the render half of the dupe-bots
  smear: name-keyed rows + duplicate names = repeated blocks every poll).
  Annotated active-source rows keep the plain-name key, so nothing remounts
  when a desktop gains the union roster.
- tests: live-id-beats-primary regression, botRowKey stability, source-shape
  anchor refresh.
2026-08-17 10:02:32 -07:00
Jakub Wolniewicz 7dd945b920 fix(desktop): deduplicate Bots roster and reuse canonical chats 2026-08-17 10:02:32 -07:00
netease 185209242e fix(desktop): stop duplicating active-gateway bots in the multi-source roster
The union agent roster (host.agents) enumerates EVERY registered connection,
including the active gateway that already answered profiles.list. The plugin
merger treated the active gateway's own agents as rows from other sources
because a remote-primary desktop reports them with connectionKind 'remote',
so every bot appeared twice (baseline) and kept growing with each refetch.

Match union agents to the active gateway via the new primaryConnectionId
field on the roster RPC response and annotate the local rows in place
instead of appending phantom copies. Same-named profiles on genuinely
separate sources (This device, other remotes) still get their own tagged
rows, preserving the @name-device disambiguation rule.

Fall back to the legacy connectionKind==='local' rule when
primaryConnectionId is absent (older Electron builds), so single-source
behavior is byte-identical.

Fixes #88344
2026-08-17 10:02:32 -07:00
hermes-seaeye[bot] 046a868b7f fmt(js): npm run fix on merge (#88321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 10:09:48 +00:00
Teknium 2c0035d3b4 fix(desktop): bound the pre-start settle hold so a restore/edit arm can't latch the composer shut
The no-payload settle gate in gateway-event.ts held session.info
running=false off unconditionally while an optimistically armed turn
(busy/awaitingResponse from restore/edit/submit) had not gone live
backend-side. When the turn never went live at all — a rewind refused
after the optimistic arm, a submit response lost to a gateway bounce, a
terminal error event that never arrived — busy latched forever:
isTargetSessionBusy refused every send, the composer queued each message
('moves to the send area'), and the queue drain (gated on busy→false)
never fired. Only an app restart cleared it (#86795).

Bound the hold to PRE_TURN_LIVE_SETTLE_GRACE_MS (15s) measured from
turnStartedAt; past the window (or with no clock) the gateway's
running=false is authoritative and settles the session. Seed the clock +
reset turnLive in applyRewindOptimistic/applyReloadOptimistic (the
restore/edit/regenerate arm sites), and clear both on every rewind
rollback path in use-prompt-actions and session-tile-actions so a failed
rewind can't leave a stale seed.

Fixes #86795
2026-08-17 03:04:00 -07:00
Adam Durham f1dd8d32a8 test(desktop): extract ProfileRail focus/visibilitychange wiring into a tested hook
Addresses review feedback from the hermes-sweeper (salvageability=high,
keep_open): "The new focus/visibility listener behavior lacks a runtime
UI regression test... no ProfileRail test."

Rendering the full ProfileRail component for this would drag in
drag-and-drop, dialogs, hotkeys, and i18n unrelated to what needs testing.
Instead, extracted the focus/visibilitychange wiring into its own
use-profile-rail-refresh-on-active hook, matching this exact directory's
own established convention (use-profile-prewarm.ts is the same shape:
a small side-effect hook pulled out of ProfileRail specifically so it's
unit-testable in isolation).

Added 6 tests covering exactly what the review asked for: refresh on
mount, refresh on window focus, refresh on visibilitychange while
visible, NO refresh on visibilitychange while hidden, listener cleanup
on unmount, and no listener accumulation across repeated mount/unmount
cycles.

Verified the tests have real teeth: simulated the exact bug this PR
originally fixed (dropped the cleanup return, leaving listeners attached
after unmount) and confirmed 4 of 6 tests correctly fail against it --
including "no accumulate listeners" showing 7 calls instead of 1, the
exact leaked-listener signature. Restored the real fix and all 6 pass.

ProfileRail itself is otherwise unchanged in behavior -- this is a pure
extraction (same effect, same dependencies, same cleanup), not a
behavior change. Full sidebar test suite: 93 passed across 12 files (up
from 87 across 11), 0 regressions. Python side unaffected: 158 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-17 02:11:27 -07:00
Adam Durham 19c68e8c96 fix(desktop): work profile deletion silently reverted after app restart
Two independent bugs let a deleted profile reappear / leave orphaned
resources on next launch:

1. hermes_cli/profiles.py's backend-process scanner required argv[0] to
   resolve to an executable literally named "hermes". Electron's
   pool-backend spawn resolves the hermes console-script shim's path and
   execs it via the interpreter directly (python3 /path/to/hermes ...), so
   argv[0] reports as "python3" and the scanner never matched the running
   backend -- delete removed the profile's files but left its live backend
   process running (still bound to a port via uvicorn), which
   accumulates across repeated delete/recreate cycles.
2. The desktop sidebar's ProfileRail only refreshed its cached profile
   list once, on mount, so a delete/create/rename from another surface
   (another window, or the CLI) left a stale ghost entry until something
   unrelated triggered a refetch. Note: a delete via this window's own
   Manage-Profiles view already refreshes the shared $profiles atom
   ProfileRail subscribes to (confirmed by reading refreshProfiles() and
   handleConfirmDelete()) -- this fix only covers the cross-window/cross-
   process staleness gap, not a duplicate of the already-merged
   #57329's Manage-Profiles rail-refresh work.

Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named
console-script shim via argv[1]. Fix 2: refresh the profile list on window
focus/visibilitychange, matching the existing pattern used elsewhere in
the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx,
use-gateway-boot.ts all use the same focus+visibilitychange pattern).

## Related work already on main

PR #57329 (merged) fixed the *headline* symptom from issue #52279
(deleted profile respawns) via a different, non-overlapping mechanism:
routing profile-delete through the primary backend instead of spawning a
fresh pool backend, plus a separate recreation guard in
ensure_hermes_home() (#49435, merged) that makes a backend spawned into a
deleted profile's directory raise FileNotFoundError instead of silently
recreating it.

This PR is NOT a duplicate of that fix. Verified: even with both of those
merged, a backend process that survives because of gap #1 above still
holds a bound port via uvicorn -- it just can no longer resurrect the
profile directory. That's real resource-hygiene, not a symptom already
covered. Gap #2 touches a different file/component (ProfileRail /
profile-switcher.tsx) than #57329's rail-refresh half (which touched the
Manage-Profiles view's own $profiles.ts / index.tsx) and covers a
distinct staleness path (cross-window/cross-process, not same-window
delete-then-refresh).

Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing +
regression coverage for the argv[0] python-interpreter detection case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-17 02:11:27 -07:00
Teknium 6cca9f3e71 fix(desktop): validate SSH host input and redact credentials in ssh target logging
A user typed their root password into the Desktop SSH host field
(root@IP:PASSWORD form). Three failures compounded:

1. validateSshTarget() only checked for option injection (leading dash),
   control chars, and port range — commas in an IP, whitespace ("ssh "
   prefix pastes), and non-numeric ":<segment>" leftovers all dialed ssh
   with garbage and failed silently five times.
2. normalizeSshConfig() only strips a ":<segment>" when it is numeric, so
   a pasted password stayed glued to the hostname all the way into ssh
   argv and the desktop.log connect line.
3. redactSecrets() had no pattern for ssh targets, so the password landed
   verbatim in desktop.log and then in a PUBLIC debug-share paste.

Changes:
- validateSshTarget(): reject whitespace, commas, non-numeric colon
  segments (with a "never put a password in the host field" hint that
  does NOT echo the credential), and garbage hostnames; still accepts
  bare IPv6 (::1, fe80::1%eth0). Reject whitespace/@ in user.
- redactSecrets(): new pattern masks any non-numeric segment where a
  port belongs in user@host:... strings — defense in depth so future
  parse gaps can't leak credentials into logs or debug shares.
- normalizeSshConfig(): strip a pasted leading "ssh " prefix.
- Tests for all three, including the exact incident shapes.
2026-08-17 01:54:41 -07:00
Teknium 1a74e7fb43 feat(desktop): offer Bot Mode agent handles in the composer @ autocomplete
The @ popover only completed filesystem references; bot handles worked
when fully typed (mention middleware parses at submit) but were never
offered, so users had to know the exact handle — worse with multi-source
@name-device handles. Fixes #88060 (ported from Hermes-Bot-Mode#43).

- composer contrib: new 'composer.atCompletions' data area
  (ComposerAtCompletionSource) — contributed rows merge AHEAD of path
  results; a throwing source drops its rows, never the popover
- use-at-completions: merge contributed entries in all three fetch paths
  (gateway results, gateway-less, fetch error)
- SDK: export the new area + types for plugins
- bundled Bot Mode plugin: registers 'mention-completions' — roster
  handles from the query cache (\u22645s stale), active profile excluded,
  'default' offered as @hermes, multi-source @name-device handles via
  botHandle, display name + connection label in the row meta, capped at 8
- registered early in register(ctx) so vm harnesses reach it before the
  pane/UI registrations that stubs can't fully model
2026-08-17 01:43:04 -07:00