From 71a2feeade179be125d7b2d1b435c9cf3715de46 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:39:19 -0700 Subject: [PATCH] feat(tui): /wake on|off|status slash command TUI-local handler over the wake.start/stop/status RPCs with friendly transcript one-liners (phrase, provider, foreign-owner note, refusal reasons, unavailability hints). /wake off sets a session-scoped opt-out the gateway.ready auto-arm respects, so the listener stays off across reconnects until /wake on. cli_only already means CLI+TUI (verified: messaging menus exclude it); zero Python changes needed. --- ui-tui/src/__tests__/wakeCommand.test.ts | 190 ++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 7 +- ui-tui/src/app/slash/commands/wake.ts | 119 ++++++++++++ ui-tui/src/app/slash/registry.ts | 2 + ui-tui/src/app/wakeState.ts | 15 ++ ui-tui/src/gatewayTypes.ts | 26 +++ 6 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/__tests__/wakeCommand.test.ts create mode 100644 ui-tui/src/app/slash/commands/wake.ts create mode 100644 ui-tui/src/app/wakeState.ts diff --git a/ui-tui/src/__tests__/wakeCommand.test.ts b/ui-tui/src/__tests__/wakeCommand.test.ts new file mode 100644 index 000000000000..185c35d00347 --- /dev/null +++ b/ui-tui/src/__tests__/wakeCommand.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { wakeCommands } from '../app/slash/commands/wake.js' +import { isWakeUserDisabled, setWakeUserDisabled } from '../app/wakeState.js' + +const wakeCommand = wakeCommands.find(cmd => cmd.name === 'wake')! + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +/** Build a ctx whose rpc routes by method name to a supplied map of results. */ +const buildCtx = (results: Record) => { + const sys = vi.fn() + + const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method])) + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + stale: () => false, + transcript: { page: vi.fn(), sys } + } + + const run = async (arg: string) => { + wakeCommand.run(arg, ctx as any, `/wake${arg ? ` ${arg}` : ''}`) + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + + return { ctx, rpc, run, sys } +} + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/wake slash command', () => { + beforeEach(() => { + vi.clearAllMocks() + setWakeUserDisabled(false) + }) + + it('registers with usage metadata', () => { + expect(wakeCommand).toBeDefined() + expect(wakeCommand.usage).toBe('/wake [on|off|status]') + }) + + it('/wake on calls wake.start with surface tui and reports listening', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.start': { phrase: 'hey hermes', provider: 'openwakeword', started: true } + }) + + await run('on') + + expect(rpc).toHaveBeenCalledWith('wake.start', { surface: 'tui' }) + expect(printed(sys)).toContain('listening') + expect(printed(sys)).toContain('hey hermes') + expect(printed(sys)).toContain('openwakeword') + }) + + it('/wake on clears the session opt-out flag', async () => { + setWakeUserDisabled(true) + + const { run } = buildCtx({ 'wake.start': { started: true } }) + + await run('on') + + expect(isWakeUserDisabled()).toBe(false) + }) + + it('/wake on prints the reason when the gateway refuses', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { owner_surface: 'gui', reason: 'owned', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('not started') + expect(out).toContain('another surface owns the listener') + expect(out).toContain('gui') + }) + + it('/wake on surfaces the hint when unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.start': { hint: 'pip install openwakeword', reason: 'unavailable', started: false } + }) + + await run('on') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('pip install openwakeword') + }) + + it('/wake off calls wake.stop, remembers the opt-out, and reports', async () => { + const { rpc, run, sys } = buildCtx({ 'wake.stop': { stopped: true } }) + + await run('off') + + expect(rpc).toHaveBeenCalledWith('wake.stop', {}) + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('listener off') + }) + + it('/wake off explains a not_owner refusal but still records the opt-out', async () => { + const { run, sys } = buildCtx({ 'wake.stop': { reason: 'not_owner', stopped: false } }) + + await run('off') + + expect(isWakeUserDisabled()).toBe(true) + expect(printed(sys)).toContain('nothing to stop') + expect(printed(sys)).toContain('doesn’t own the listener') + }) + + it('/wake status prints a listening one-liner', async () => { + const { rpc, run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: true, + owned_by_caller: true, + owner_surface: 'tui', + phrase: 'hey hermes', + provider: 'openwakeword' + } + }) + + await run('status') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + + const out = printed(sys) + expect(out).toContain('listening') + expect(out).toContain('hey hermes') + expect(out).toContain('openwakeword') + }) + + it('bare /wake behaves like /wake status', async () => { + const { rpc, run } = buildCtx({ 'wake.status': { available: true, listening: false } }) + + await run('') + + expect(rpc).toHaveBeenCalledWith('wake.status', {}) + }) + + it('status reports another surface owning the listener', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { + available: true, + listening: false, + owned_by_caller: false, + owner_surface: 'gui', + phrase: 'hey hermes' + } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('off here') + expect(out).toContain('gui') + }) + + it('status surfaces the hint when the wake word is unavailable', async () => { + const { run, sys } = buildCtx({ + 'wake.status': { available: false, hint: 'no microphone detected', listening: false } + }) + + await run('status') + + const out = printed(sys) + expect(out).toContain('unavailable') + expect(out).toContain('no microphone detected') + }) + + it('rejects unknown subcommands with usage text', async () => { + const { rpc, run, sys } = buildCtx({}) + + await run('banana') + + expect(rpc).not.toHaveBeenCalled() + expect(printed(sys)).toContain('usage: /wake [on|off|status]') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 49c55fc358f4..16971e18fbf2 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -32,6 +32,7 @@ import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' +import { isWakeUserDisabled } from './wakeState.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -623,7 +624,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // Arm "Hey Hermes" if this surface owns it (server gates on config). // Fire-and-forget + idempotent server-side, so reconnects are harmless. - void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) + // Skipped when the user explicitly ran `/wake off` this session — an + // explicit opt-out must survive gateway reconnects (see wakeState.ts). + if (!isWakeUserDisabled()) { + void rpc('wake.start', { surface: 'tui' }).catch(() => undefined) + } rpc('commands.catalog', {}) .then(r => { diff --git a/ui-tui/src/app/slash/commands/wake.ts b/ui-tui/src/app/slash/commands/wake.ts new file mode 100644 index 000000000000..d73dea4fa945 --- /dev/null +++ b/ui-tui/src/app/slash/commands/wake.ts @@ -0,0 +1,119 @@ +import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js' +import { setWakeUserDisabled } from '../../wakeState.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const + +type WakeSub = (typeof WAKE_SUBCOMMANDS)[number] + +const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value) + +// Friendly text for the gateway's wake.start refusal codes. Unknown codes +// fall through to the raw reason so new server-side codes stay visible. +const START_REASON_TEXT: Record = { + disabled_for_surface: 'disabled for this surface (config wake_word.enabled / wake_word.surface)', + not_owner: 'another surface owns the listener', + owned: 'another surface owns the listener', + unavailable: 'unavailable' +} + +const startFailureLine = (r: WakeStartResponse): string => { + const reason = r.reason ?? 'unknown' + const base = START_REASON_TEXT[reason] ?? reason + const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : '' + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: not started — ${base}${owner}${hint}` +} + +const statusLine = (r: WakeStatusResponse): string => { + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + + if (r.listening) { + return `wake: listening${phrase}${provider}` + } + + if (r.owner_surface && !r.owned_by_caller) { + return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}` + } + + if (r.available === false) { + const hint = r.hint?.trim() ? ` — ${r.hint.trim()}` : '' + + return `wake: unavailable${hint}` + } + + return `wake: off${phrase}${provider} · /wake on to arm` +} + +const runOn = (ctx: SlashRunCtx): void => { + setWakeUserDisabled(false) + + ctx.gateway + .rpc('wake.start', { surface: 'tui' }) + .then( + ctx.guarded(r => { + if (!r.started) { + return ctx.transcript.sys(startFailureLine(r)) + } + + const phrase = r.phrase ? ` for “${r.phrase}”` : '' + const provider = r.provider ? ` · ${r.provider}` : '' + + ctx.transcript.sys(`wake: listening${phrase}${provider}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runOff = (ctx: SlashRunCtx): void => { + // Remember the explicit opt-out so gateway reconnects don't re-arm the + // listener behind the user's back (see wakeState.ts). + setWakeUserDisabled(true) + + ctx.gateway + .rpc('wake.stop', {}) + .then( + ctx.guarded(r => { + if (r.stopped) { + return ctx.transcript.sys('wake: listener off (won’t re-arm this session)') + } + + const reason = r.reason === 'not_owner' ? 'this surface doesn’t own the listener' : (r.reason ?? 'not running') + + ctx.transcript.sys(`wake: nothing to stop — ${reason}`) + }) + ) + .catch(ctx.guardedErr) +} + +const runStatus = (ctx: SlashRunCtx): void => { + ctx.gateway + .rpc('wake.status', {}) + .then(ctx.guarded(r => ctx.transcript.sys(statusLine(r)))) + .catch(ctx.guardedErr) +} + +const WAKE_RUNNERS: Record void> = { + off: runOff, + on: runOn, + status: runStatus +} + +export const wakeCommands: SlashCommand[] = [ + { + help: "toggle the 'Hey Hermes' wake word listener [on|off|status]", + name: 'wake', + usage: '/wake [on|off|status]', + run: (arg, ctx) => { + const sub = arg.trim().toLowerCase() + + if (sub && !isWakeSub(sub)) { + return ctx.transcript.sys('usage: /wake [on|off|status]') + } + + WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index f87d53f9bfe7..6922280e16a5 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -5,6 +5,7 @@ import { sessionCommands } from './commands/session.js' import { setupCommands } from './commands/setup.js' import { subscriptionCommands } from './commands/subscription.js' import { topupCommands } from './commands/topup.js' +import { wakeCommands } from './commands/wake.js' import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ @@ -13,6 +14,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ ...sessionCommands, ...subscriptionCommands, ...opsCommands, + ...wakeCommands, ...setupCommands, ...debugCommands ] diff --git a/ui-tui/src/app/wakeState.ts b/ui-tui/src/app/wakeState.ts new file mode 100644 index 000000000000..a9e527ecd24d --- /dev/null +++ b/ui-tui/src/app/wakeState.ts @@ -0,0 +1,15 @@ +// Session-scoped memory of an explicit `/wake off`. +// +// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready` +// (see createGatewayEventHandler.ts). When the user explicitly disables the +// listener with `/wake off`, a reconnect must NOT silently re-arm it — this +// module-level flag records that intent for the lifetime of the process. +// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`) +// remains the durable on/off switch; this is only per-session steering. +let wakeUserDisabled = false + +export const isWakeUserDisabled = (): boolean => wakeUserDisabled + +export const setWakeUserDisabled = (disabled: boolean): void => { + wakeUserDisabled = disabled +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index db7503828b62..42d6e18f21f4 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -400,6 +400,32 @@ export interface VoiceRecordResponse { text?: string } +// ── Wake word ──────────────────────────────────────────────────────── + +export interface WakeStartResponse { + hint?: string + owner_surface?: null | string + phrase?: string + provider?: string + reason?: string + started?: boolean +} + +export interface WakeStopResponse { + reason?: null | string + stopped?: boolean +} + +export interface WakeStatusResponse { + available?: boolean + hint?: string + listening?: boolean + owned_by_caller?: boolean + owner_surface?: null | string + phrase?: string + provider?: string +} + // ── Tools (TS keeps configure since it resets local history) ───────── export interface ToolsConfigureResponse {