Two reliability gaps in the /events/speak subscriber:
1. resp.chunk().await had no idle timeout. A backend that accepts the
TCP connection but stops producing frames (deadlocked SSE endpoint,
zombie process) would block the task forever without reconnecting.
The pill window would never surface for agent-initiated speech and
there would be nothing to log. Backend emits a `:ping` heartbeat
every 15 s, so 45 s without any data is now treated as a dead
stream — the task errors out and the reconnect loop takes over.
2. Flat 2 s backoff escalates nowhere. Logs fill with reconnect lines
when the backend is down for minutes, and a backend that accepts +
immediately closes connections (no data) spins the loop tightly.
Backoff now escalates 500 ms → 30 s on unproductive rounds and
resets only when at least one frame arrives (the connection was
genuinely productive, not just accepted).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The pill window now surfaces for agent-initiated speech without main-window
involvement. Rust subscribes to /events/speak via a tokio task + reqwest
streaming body (speak_monitor.rs), shows the pill, and forwards events to
the dictate webview over Tauri's event bus. The pill plays audio via a
plain HTMLAudioElement and emits dictate:hide when playback ends. The
pill stays hidden through the ~1 s generation wait and only surfaces when
audio actually starts, with the counter armed at that moment.
Fixes a shared-dict mutation in mcp_server/events.publish() that caused
the second subscriber (Rust speak_monitor) to receive `event: message`
instead of named speak-start/speak-end frames. Also teaches the speak_monitor
parser to handle CRLF framing (sse-starlette default). Main-window
AudioPlayer now skips autoplay for source in {mcp, rest} to avoid
double-play when both windows are alive.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>