fix(wake): route desktop control and select input devices

This commit is contained in:
Gille 2026-07-29 14:04:21 -06:00
parent 70411a6152
commit 94d1dff50d
11 changed files with 523 additions and 26 deletions

View file

@ -22,6 +22,7 @@ import {
setSessions
} from '@/store/session'
import { dropSessionState, publishSessionState } from '@/store/session-states'
import { $wakeWord, resetWakeWordState } from '@/store/wake-word'
import type { SessionInfo } from '@/types/hermes'
import type { SubmitTextOptions } from './utils'
@ -427,6 +428,110 @@ describe('usePromptActions slash session targeting', () => {
})
})
describe('usePromptActions /wake', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
resetWakeWordState()
})
afterEach(() => {
cleanup()
resetWakeWordState()
vi.restoreAllMocks()
})
it('starts the GUI-owned listener through wake.start and never spawns the slash worker', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'wake.start') {
return {
owner_surface: 'gui',
phrase: 'hey hermes',
provider: 'openwakeword',
started: true
} as never
}
if (method === 'wake.status') {
return {
available: true,
configured_surface: 'gui',
enabled: true,
input_device: {
hostapi: 'Windows WASAPI',
name: 'Microphone Array',
selector: 'Microphone Array'
},
listening: true,
owner_surface: 'gui',
phrase: 'hey hermes',
provider: 'openwakeword'
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => seeds.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/wake on')
expect(requestGateway).toHaveBeenCalledWith('wake.start', { persist: true, surface: 'gui' }, 180_000)
expect(requestGateway).toHaveBeenCalledWith('wake.status', {})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
expect($wakeWord.get()).toMatchObject({ available: true, enabled: true, listening: true })
expect(renderedSeedTexts(seeds).join('\n')).toContain('Input: Microphone Array (Windows WASAPI)')
})
it('uses gateway truth for a bare toggle and stops through wake.stop', async () => {
let statusCalls = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'wake.status') {
statusCalls += 1
return {
available: true,
enabled: statusCalls === 1,
listening: statusCalls === 1,
owner_surface: statusCalls === 1 ? 'gui' : null,
phrase: 'hey hermes',
provider: 'openwakeword'
} as never
}
if (method === 'wake.stop') {
return { disabled_persisted: true, stopped: true } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await handle!.submitText('/wake')
expect(requestGateway.mock.calls.map(([method]) => method)).toEqual(['wake.status', 'wake.stop', 'wake.status'])
expect(requestGateway).toHaveBeenCalledWith('wake.stop', { persist: true })
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
expect($wakeWord.get()).toMatchObject({ enabled: false, listening: false })
})
})
describe('usePromptActions /compress', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])

View file

@ -36,6 +36,15 @@ import {
setYoloActive
} from '@/store/session'
import { $sessionStates } from '@/store/session-states'
import {
applyWakeStartResult,
applyWakeStatus,
applyWakeStopResult,
type WakeInputDeviceStatus,
type WakeStartResponse,
type WakeStatusResponse,
type WakeStopResponse
} from '@/store/wake-word'
import type {
BrowserManageResponse,
@ -60,6 +69,43 @@ import {
// default WS request timeout on large sessions — give it the TUI client's
// 120s RPC budget (HERMES_TUI_RPC_TIMEOUT_MS default) instead.
const SESSION_COMPRESS_TIMEOUT_MS = 120_000
const WAKE_START_TIMEOUT_MS = 180_000
const wakeDeviceLabel = (device?: WakeInputDeviceStatus): string => {
if (!device) {
return 'system default'
}
const selector = device.selector
const name = device.name?.trim() || (selector == null ? 'system default' : String(selector))
return device.hostapi?.trim() ? `${name} (${device.hostapi.trim()})` : name
}
const renderWakeStatus = (status: WakeStatusResponse): string => {
const lines = [
'Wake Word Status',
`State: ${status.listening ? 'LISTENING' : 'OFF'}`,
`Phrase: "${status.phrase?.trim() || 'hey hermes'}"`,
`Provider: ${status.provider?.trim() || 'unknown'}`,
`Surface: ${status.owner_surface?.trim() || status.configured_surface?.trim() || 'auto'}`,
`Input: ${wakeDeviceLabel(status.input_device)}`
]
if (status.audio_silent) {
lines.push('Audio: silent')
}
if (status.input_device?.error?.trim()) {
lines.push(`Input error: ${status.input_device.error.trim()}`)
}
if (status.hint?.trim()) {
lines.push(`Hint: ${status.hint.trim()}`)
}
return lines.join('\n')
}
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
@ -592,6 +638,67 @@ export function useSlashCommand(deps: SlashCommandDeps) {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
},
// /wake must stay in the gateway process that owns the Desktop wake
// lease. Sending it through slash.exec creates a separate HermesCLI in
// the slash worker, which can claim the machine-wide microphone lock
// while the Desktop UI still reports the GUI listener as off.
wake: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput } = resolved
const requested = ctx.arg.trim().toLowerCase()
if (requested && !['on', 'off', 'status'].includes(requested)) {
renderSlashOutput('usage: /wake [on|off|status]')
return
}
const status = async (): Promise<WakeStatusResponse> => {
const current = await requestGateway<WakeStatusResponse>('wake.status', {})
applyWakeStatus(current)
return current
}
try {
let action = requested
// Bare /wake is an authoritative toggle. Query the gateway instead
// of trusting a potentially stale renderer cache.
if (!action) {
action = (await status()).listening ? 'off' : 'on'
}
if (action === 'on') {
const started = await requestGateway<WakeStartResponse>(
'wake.start',
{ persist: true, surface: 'gui' },
WAKE_START_TIMEOUT_MS
)
applyWakeStartResult(started)
if (!started?.started) {
renderSlashOutput(
`Failed to start wake word: ${started?.hint?.trim() || started?.reason?.trim() || 'unknown error'}`
)
return
}
} else if (action === 'off') {
applyWakeStopResult(await requestGateway<WakeStopResponse>('wake.stop', { persist: true }))
}
renderSlashOutput(renderWakeStatus(await status()))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /handoff hands this session to a messaging platform. The platform is
// completed inline in the slash popover (backend _handoff_completions),
// so there is no overlay: `/handoff <platform>` runs the desktop's own

View file

@ -75,6 +75,14 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashCommand('/pets')).toBe(false)
})
it('routes /wake through the desktop wake action instead of the slash worker', () => {
expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' })
expect(desktopSlashCommandArgumentMode('/wake')).toBe('options')
expect(isDesktopSlashSuggestion('/wake')).toBe(true)
expect(isDesktopSlashCommand('/wake')).toBe(true)
expect(desktopSlashUnavailableMessage('/wake')).toBeNull()
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.

View file

@ -56,6 +56,7 @@ export type DesktopActionId =
| 'profile'
| 'skin'
| 'title'
| 'wake'
| 'yolo'
/** A command fulfilled by opening a desktop overlay picker. */
@ -168,6 +169,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
surface: action('branch')
},
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
{
name: '/wake',
description: 'Control the desktop wake-word listener [on|off|status]',
surface: action('wake'),
argumentMode: 'options'
},
{
name: '/handoff',
description: 'Hand off this session to a messaging platform',

View file

@ -34,12 +34,14 @@ const INITIAL_WAKE_WORD_STATE: WakeWordState = {
export const $wakeWord = atom<WakeWordState>(INITIAL_WAKE_WORD_STATE)
export interface WakeStatusResponse {
/** Armed but the mic delivers only silence (macOS backend-permission gap). */
/** Armed but the selected backend input delivers only silence. */
audio_silent?: boolean
available?: boolean
configured_surface?: string
/** Config truth (wake_word.enabled) — drives post-voice re-arm. */
enabled?: boolean
hint?: string
input_device?: WakeInputDeviceStatus
listening?: boolean
owned_by_caller?: boolean
owner_surface?: string | null
@ -63,6 +65,16 @@ export interface WakeStopResponse {
stopped?: boolean
}
export interface WakeInputDeviceStatus {
default_samplerate?: number
error?: string
hostapi?: string
hostapi_index?: number
max_input_channels?: number
name?: string
selector?: number | string | null
}
/** Minimal requester shape — satisfied by both `useGatewayRequest`'s
* `requestGateway` and the `$gateway` instance wrapper below. */
export type WakeRequester = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
@ -111,8 +123,8 @@ const noticeFrom = (result: { hint?: string; reason?: string | null } | null | u
export function applyWakeStatus(status: WakeStatusResponse | null | undefined): void {
const current = $wakeWord.get()
const listening = Boolean(status?.listening)
// "Armed but deaf" (macOS backend without mic permission) keeps its hint
// visible in the tooltip even though the toggle shows listening.
// "Armed but deaf" keeps its input-device hint visible in the tooltip even
// though the toggle shows listening.
const silent = Boolean(status?.audio_silent)
$wakeWord.set({