9c829f965d
Cross-connection DMs were pure polling: the Desktop drains every gateway's bot_relay outbox on a 4s interval, so each hop eats up to 4s outbound plus 4s for the reply leg (#92760 'bots reply slowly'). Emission point: the gateway's existing change watcher (_CHANGE_WATCHES in tui_gateway/server.py). Envelopes are written by the AGENT process (message_agent -> tools.bot_relay.enqueue_envelope), not the gateway, so no gateway RPC is on the enqueue path and an in-process emit is impossible. That is exactly the situation the change watcher already solves for the pairing store (pairing.changed: 'written by a different process; the files are the only shared signal') - so a new bot_relay.outbox.pending entry in the existing watch table is the smallest correct diff: one cheap 1s-interval stat probe folded into the existing 0.5s watcher tick, no new thread, no new RPC, and _broadcast_global_event fans it to every connected WS client for free. The signature is monotone (newest envelope mtime ever seen) so a drain emptying outbox/ never re-fires the event. Desktop (hermes-bots plugin): subscribe via the existing host.onEvent tap (feature-detected - older shells lack it) and run drainRelayOutboxes through a 250ms trailing debounce so a burst of signals collapses to one drain. The 4s interval poll is intentionally UNCHANGED as the backstop: the event tap only hears the active gateway socket, so per-connection push detection would be complex and wrong to trade the poll against - push simply makes the common case near-instant while older backends keep working exactly as before. Tests: 3 new watcher contracts (fires on enqueue, monotone across drain, silent with no outbox) and a new relay-push-drain.test.mjs (debounce burst -> one drain, re-arm after window, disposed no-op, poll backstop intact).