fix(desktop): deliver wake.detected over the websocket, not stdio

write_json routes via the request-scoped transport ContextVar, but the
wake detector's callback runs on a background thread where that var is
unset — so wake.detected fell back to _stdio_transport and was dumped to
the backend's stdout (visible as raw [hermes] {...} frames in desktop
logs) instead of crossing the desktop/dashboard websocket. The TUI was
unaffected because it IS stdio.

Capture the arming request's transport at wake.start and bind it around
the emit in _wake_on_detect so the background thread routes to the right
peer. Re-armed on each wake.start, so reconnects pick up the new socket.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 23:19:13 -05:00 committed by Teknium
parent a6aada24f5
commit 813d9ffad0
No known key found for this signature in database

View file

@ -17602,6 +17602,12 @@ def _voice_record_key() -> str:
_wake_lock = threading.Lock()
_wake_active = False
_wake_event_sid = ""
# Transport captured at wake.start time. The detector callback fires on a
# background thread where the request-scoped transport ContextVar is unset, so
# write_json would fall back to stdio and the event would never cross the
# desktop's websocket (#wake-detected-not-delivered). We pin the arming
# request's transport here and bind it for the emit.
_wake_transport: "Optional[Transport]" = None
def _wake_is_active() -> bool:
@ -17623,13 +17629,23 @@ def _wake_on_detect() -> None:
"""Detector-thread callback: tell the client to open a fresh voice session."""
with _wake_lock:
sid = _wake_event_sid
transport = _wake_transport
try:
from tools.wake_word import wake_phrase
phrase = wake_phrase()
except Exception:
phrase = ""
logger.info("wake.detected: emitting to sid=%r", sid)
_emit("wake.detected", sid, {"phrase": phrase})
logger.info("wake.detected: emitting to sid=%r (transport=%s)",
sid, type(transport).__name__ if transport else None)
# Bind the arming request's transport so write_json reaches the right peer
# (WS for desktop/dashboard) instead of falling back to stdio on this
# background thread.
token = bind_transport(transport) if transport is not None else None
try:
_emit("wake.detected", sid, {"phrase": phrase})
finally:
if token is not None:
reset_transport(token)
@method("wake.start")
@ -17639,7 +17655,7 @@ def _(rid, params: dict) -> dict:
Idempotent and gated: returns ``{started: False, reason}`` when the wake
word is disabled, scoped to another surface, or its deps/mic aren't ready.
"""
global _wake_active, _wake_event_sid
global _wake_active, _wake_event_sid, _wake_transport
surface = str(params.get("surface") or "auto").strip().lower()
try:
from tools.wake_word import (
@ -17663,6 +17679,9 @@ def _(rid, params: dict) -> dict:
with _wake_lock:
_wake_event_sid = params.get("session_id") or _wake_event_sid
# Capture the live transport (WS for desktop) so the background detector
# thread can route wake.detected back to this client, not stdio.
_wake_transport = current_transport() or _wake_transport
try:
start_listening(_wake_on_detect, config=cfg)
except Exception as e: