mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): de-dupe cross-window cues so peers don't spam
With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer.
This commit is contained in:
parent
a90ca7fe34
commit
fb0c6d9ee1
9 changed files with 143 additions and 7 deletions
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
|
||||
test('collapses the same key inside the window (two windows, one event)', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false, 'first window claims')
|
||||
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
|
||||
})
|
||||
|
||||
test('distinct keys are independent', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false)
|
||||
assert.equal(isDup('approval:s1', 0), false, 'different kind')
|
||||
assert.equal(isDup('input:s2', 0), false, 'different session')
|
||||
})
|
||||
|
||||
test('re-fires once the window elapses', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('turnDone:s1', 0), false)
|
||||
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
|
||||
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
|
||||
})
|
||||
|
||||
test('prunes stale keys so the map cannot grow unbounded', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
// Each far-apart key is pruned before the next, so none linger as duplicates.
|
||||
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
|
||||
}
|
||||
})
|
||||
35
apps/desktop/electron/event-dedupe.ts
Normal file
35
apps/desktop/electron/event-dedupe.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
|
||||
// sound, spoken replies). Every desktop window is its own renderer process, so N
|
||||
// open windows each independently react to the same backend event. The main
|
||||
// process is the one place they all share and it handles IPC serially, so it's
|
||||
// the race-free owner: the first window to claim a key within the window wins;
|
||||
// peers see it's taken and stay quiet.
|
||||
//
|
||||
// Pure + injectable clock so it's unit-testable without Electron.
|
||||
|
||||
const DEDUPE_WINDOW_MS = 1000
|
||||
|
||||
// Returns true when `key` was already claimed within the window (caller drops
|
||||
// this one). Self-evicting: stale keys are pruned on every call, so the map
|
||||
// can't grow unbounded.
|
||||
function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) {
|
||||
const lastSeenAt = new Map<string, number>()
|
||||
|
||||
return function isDuplicate(key: string, now = Date.now()): boolean {
|
||||
for (const [k, at] of lastSeenAt) {
|
||||
if (now - at >= windowMs) {
|
||||
lastSeenAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeenAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastSeenAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { createEventDeduper, DEDUPE_WINDOW_MS }
|
||||
|
|
@ -67,6 +67,7 @@ import {
|
|||
uninstallArgsForMode
|
||||
} from './desktop-uninstall'
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
|
|
@ -8516,11 +8517,28 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
})
|
||||
})
|
||||
|
||||
// One deduper per cross-window cue — the choke point every window shares. Main
|
||||
// handles IPC serially, so the first window to claim a key wins with no race.
|
||||
const isDuplicateNotification = createEventDeduper()
|
||||
const claimedAmbientCue = createEventDeduper()
|
||||
|
||||
// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?".
|
||||
// The first caller within the window gets true; peers get false and stay quiet.
|
||||
ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? '')))
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Multiple full windows each run their own renderer throttle, so the same
|
||||
// kind+session can arrive here twice. Collapse it at this single choke point.
|
||||
// Return true (not false): a notification for the event IS being shown by the
|
||||
// first caller, so the settings "send test" success probe stays honest.
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
||||
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
|
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
|
|||
}
|
||||
|
||||
markSpoken()
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
// Only one window voices a given reply when the same chat is open in
|
||||
// several (reply.id is the shared backend message id). markSpoken already
|
||||
// ran in every window, so peers just stay quiet.
|
||||
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
|
||||
if (owns) {
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Re-check on a reply completing ($messages) and on the prior clip ending
|
||||
|
|
|
|||
|
|
@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
playCompletionSound()
|
||||
// Keyed by session so only one window beeps when several are open.
|
||||
playCompletionSound(sessionId)
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -35,6 +35,10 @@ declare global {
|
|||
// renders the complete app against the shared backend, so the user can run
|
||||
// multiple GUI windows at once.
|
||||
openWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
// Claim a one-shot cross-window ambient cue (turn-end sound / spoken
|
||||
// reply). Resolves true for the first window to claim a key, false for
|
||||
// peers — so N open windows don't all fire the same cue.
|
||||
claimAmbientCue: (key: string) => Promise<boolean>
|
||||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Completion sound bank for agent turn-end cues.
|
||||
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
|
||||
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
|
|
@ -452,13 +453,26 @@ export function previewCompletionSound(variantId?: number) {
|
|||
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
|
||||
}
|
||||
|
||||
// Plays the selected completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
|
||||
// (the session id) so only one window beeps when several are open — the mute
|
||||
// check runs first, so a muted window never claims the cue out from under an
|
||||
// audible peer.
|
||||
export function playCompletionSound(dedupeKey?: string) {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant($completionSoundVariantId.get())
|
||||
if (!dedupeKey) {
|
||||
playVariant($completionSoundVariantId.get())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => {
|
||||
if (owns) {
|
||||
playVariant($completionSoundVariantId.get())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface AirPuffSpec {
|
||||
|
|
|
|||
18
apps/desktop/src/store/ambient.ts
Normal file
18
apps/desktop/src/store/ambient.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
|
||||
// so N open full windows don't all fire it for the same backend event. The main
|
||||
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
|
||||
// or when the bridge/claim fails — every window emits, preserving the
|
||||
// single-window behavior rather than going silent.
|
||||
export async function ownsAmbientCue(key: string): Promise<boolean> {
|
||||
const claim = window.hermesDesktop?.claimAmbientCue
|
||||
|
||||
if (!claim) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return await claim(key)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue