feat(plugins): public event bridge for plugin backends

A plugin backend pushing an update to its own desktop half imports
tui_gateway.server._broadcast_global_event — a core-private whose signature
is not a plugin contract. Give plugin authors a public, documented door.

hermes_cli.plugin_events.broadcast_plugin_event(plugin_id, event, payload)
emits plugin.<plugin_id>.<event> on the app's global event stream (the one
host.onEvent subscribes to), with the plugin id forced into the namespace and
the bare event name validated — a mangled name would strand the desktop half
waiting on a name nobody emits. Fire-and-forget, safe from any request
handler.

Wishlist item 8 of #116305; unblocks rss-reader (#115972).
This commit is contained in:
tobenwarrior
2026-09-19 23:24:35 +02:00
committed by Teknium
parent ecacf3d0c9
commit 8034d493dc
3 changed files with 153 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
"""Public event bridge for plugin backends (``plugin_api.py``).
A plugin's backend runs inside the gateway process. To push an update to its
OWN desktop half it emits on the app's global event stream — the same stream
``host.onEvent`` subscribes to in the renderer::
from hermes_cli.plugin_events import broadcast_plugin_event
broadcast_plugin_event("rss-reader", "items", {"count": 3})
# event "plugin.rss-reader.items" reaches every connected desktop client
The desktop half filters for its own name::
host.onEvent('plugin.rss-reader.items', payload => …)
This module is the sanctioned door for that: plugin backends must never import
``tui_gateway.server`` privates (``_broadcast_global_event``), whose signature
is core-internal. The ``plugin.`` prefix keeps plugin traffic out of core's own
event names (``skin.changed``, ``session.reclaimed``, …).
"""
from __future__ import annotations
import re
from typing import Any, Optional
#: Every plugin event name starts with this — core owns all other names.
PLUGIN_EVENT_PREFIX = "plugin."
# Plugin ids are the manifest names (lowercase, dashes): ``rss-reader``.
_PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
# One event segment: no whitespace, no dots (the dot is the name separator).
_EVENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
def plugin_event_name(plugin_id: str, event: str) -> str:
"""The wire name for one plugin event: ``plugin.<plugin_id>.<event>``.
Raises ``ValueError`` for an id or event that cannot form one — a silently
mangled name would strand the desktop half waiting on a name nobody emits.
"""
if not isinstance(plugin_id, str) or not _PLUGIN_ID_RE.match(plugin_id):
raise ValueError(f"invalid plugin id {plugin_id!r}: expected [a-z0-9][a-z0-9._-]{{0,63}}")
if not isinstance(event, str) or not _EVENT_RE.match(event):
raise ValueError(
f"invalid plugin event {event!r}: expected one segment of [A-Za-z0-9_-] "
"(the plugin id already namespaces the name)"
)
return f"{PLUGIN_EVENT_PREFIX}{plugin_id}.{event}"
def broadcast_plugin_event(plugin_id: str, event: str, payload: Optional[dict[str, Any]] = None) -> None:
"""Emit ``plugin.<plugin_id>.<event>`` to every connected client.
Fire-and-forget and safe to call from any request handler: delivery fans out
over the gateway's live transports, and a wedged peer is skipped rather than
stalling the caller. ``payload`` must be a dict (or ``None`` for ``{}``).
"""
if payload is not None and not isinstance(payload, dict):
raise TypeError(f"plugin event payload must be a dict or None, got {type(payload).__name__}")
# Late import: plugin backends load before the gateway server is up in some
# hosts (CLI tooling imports plugin_api modules for route inspection), and
# tui_gateway.server pulls in the transport stack.
from tui_gateway.server import _broadcast_global_event
_broadcast_global_event(plugin_event_name(plugin_id, event), dict(payload or {}))

View File

@@ -0,0 +1,61 @@
"""Public event bridge for plugin backends (``plugin_api.py``).
Regression for #116305 item 8: a plugin backend pushes events to its own
desktop half through ``hermes_cli.plugin_events`` instead of importing
``tui_gateway.server._broadcast_global_event``.
"""
from __future__ import annotations
import pytest
from hermes_cli import plugin_events
def test_event_name_namespaces_under_the_plugin():
assert plugin_events.plugin_event_name("rss-reader", "items") == "plugin.rss-reader.items"
def test_broadcast_reaches_the_global_stream_with_the_namespaced_name(monkeypatch):
seen: list[tuple[str, object]] = []
import tui_gateway.server as server
monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload)))
plugin_events.broadcast_plugin_event("rss-reader", "items", {"count": 3})
assert seen == [("plugin.rss-reader.items", {"count": 3})]
def test_broadcast_defaults_the_payload_to_an_empty_dict(monkeypatch):
seen: list[tuple[str, object]] = []
import tui_gateway.server as server
monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload)))
plugin_events.broadcast_plugin_event("kanban", "changed")
assert seen == [("plugin.kanban.changed", {})]
@pytest.mark.parametrize(
("plugin_id", "event"),
[
("", "items"),
("Bad Id", "items"),
("has/slash", "items"),
("ok", ""),
("ok", "bad name"),
("ok", "with.dot"),
("ok", "plugin.other.items"),
],
)
def test_invalid_names_raise(plugin_id, event):
with pytest.raises(ValueError):
plugin_events.plugin_event_name(plugin_id, event)
def test_payload_must_be_a_dict():
with pytest.raises(TypeError):
plugin_events.broadcast_plugin_event("ok", "items", ["not", "a", "dict"]) # type: ignore[arg-type]

View File

@@ -1376,6 +1376,31 @@ never auto-import Python. This is a security boundary, not an oversight
(GHSA-mcfc-hp25-cjv7).
:::
#### Pushing events to your desktop half
Your backend runs inside the gateway process, so it can push an update to your
own desktop half over the app's global event stream — the same stream
`host.onEvent` subscribes to:
```python
from hermes_cli.plugin_events import broadcast_plugin_event
broadcast_plugin_event("rss-reader", "items", {"count": 3})
# → event "plugin.rss-reader.items" reaches every connected desktop client
```
```javascript
host.onEvent('plugin.rss-reader.items', payload => queryClient.invalidateQueries({ queryKey: ['items'] }))
```
The name is always `plugin.<your plugin id>.<event>` — the plugin id namespaces
it, so `broadcast_plugin_event` takes the BARE event name (`"items"`, not
`"plugin.rss-reader.items"`) and raises on anything else. Delivery is
fire-and-forget (a wedged client is skipped, never stalling your handler). Use
this instead of importing `tui_gateway.server` internals; for plugin-scoped
frames with a payload tailored per connection, `ctx.socket('/events')` remains
the richer door.
### Calling it from the plugin
```javascript