openvibers/openvibe.link Commits

Node: a request waiting for approval on the machine shows a desktop notification (#42)

OP OpenVibers committed 14 Oct 2026 · c146a4814f · 3 files, +44 −2

When the computer plugin is at level `ask` and a request starts waiting, the owner now gets a desktop notification naming the request and the approve/deny command. On Linux it uses `notify-send` when the Node runs in a graphical session (DISPLAY or WAYLAND_DISPLAY); on macOS, `osascript`.

It is best effort and never blocks a command. Config `notify: false` turns it off. The plugin is now 0.2.0. The docs/computer.md config table is updated.

Test: `test_a_waiting_request_notifies_the_desktop` checks the argv, that nothing is sent without a session, and that `notify` off sends nothing. The plugin tests pass (14).

Changes

ddiff --git a/docs/computer.md b/docs/computer.md
iindex 49c849e..a220df1 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -44,7 +44,7 @@ openvibe-node approve <ID> # let it run
openvibe-node deny <ID> # refuse it
```
--At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
++When the Node runs in your graphical session, a waiting request also shows a desktop notification (`notify-send` on Linux, `osascript` on macOS; config `notify: false` turns it off). At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
that a request is waiting on the machine.
**Kill switch:** `openvibe-node stop` (and an owner's e-stop) drops the plugin to `observe`: what runs is killed, what
@@@ -74,6 +74,7 @@ your graphical session for `screen`; without one the plugin simply does not offe
| `max_output_bytes` | 65536 (≤ 192 KB) | stdout and stderr are each cut there |
| `max_read_bytes` | 262144 (≤ 256 KB) | the most one read or write moves |
| `approval_timeout_s` | 120 (≤ 600) | how long a request waits for you |
++| `notify` | `true` | a desktop notification when a request waits (graphical session only) |
`describe` reports `terminal`, `files` and `screen` with the level and folders, so Bot shows what the machine allows;
telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex fabdace..d35c740 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -60,7 +60,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
driver = "computer"
-- version = "0.1.0"
++ version = "0.2.0"
motion_kinds = frozenset()
telemetry_interval_s = 2.0
@@@ -76,6 +76,7 @@ class Computer(Plugin):
self.max_output = 64 * 1024
self.max_read = 256 * 1024
self.allow_exec = True
++ self.notify = True
self.approval_timeout_s = 120
self.pending = {} # command id -> (run, expires_at, summary)
self.procs = set()
@@@ -99,6 +100,7 @@ class Computer(Plugin):
self.max_read = _int(config.get("max_read_bytes"), "max_read_bytes", 1024, MAX_READ_CAP, 256 * 1024)
self.approval_timeout_s = _int(config.get("approval_timeout_s"), "approval_timeout_s", 10, 600, 120)
self.allow_exec = config.get("allow_exec", True) is not False
++ self.notify = config.get("notify", True) is not False
def describe(self, config):
try:
@@@ -220,11 +222,31 @@ class Computer(Plugin):
raise Fault("not_ready", "%d requests already wait for approval on the machine" % MAX_WAITING)
self.pending[cmd.id] = ((run, threaded), self.clock() + self.approval_timeout_s, summary)
self.emit_event("approval", id=cmd.id, kind=cmd.kind, summary=summary, expires_s=self.approval_timeout_s)
++ self._notify(cmd.id, summary)
self.log("waiting for approval %s: %s", cmd.id, summary)
raise Deferred()
self.log("%s %s: %s", cmd.kind, cmd.id, summary)
return self._start(cmd, run) if threaded else run()
++ def _notify(self, cid, summary):
++ """A desktop notification that a request waits (config `notify`, on by default), when the Node runs in a
++ graphical session: notify-send on Linux, osascript on macOS. Best effort: it never blocks or fails a command."""
++ if not self.notify:
++ return
++ title = "OpenVibe: your agent asks"
++ body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)
++ if self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):
++ argv = ["notify-send", "--app-name=OpenVibe Node", title, body]
++ elif self.platform == "darwin" and self.which("osascript"):
++ quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')
++ argv = ["osascript", "-e", "display notification %s with title %s" % (quote(body), quote(title))]
++ else:
++ return
++ try:
++ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
++ except OSError:
++ pass
++
def on_approval(self, cid, approved):
with self.mu:
item = self.pending.pop(cid, None)
ddiff --git a/plugins/computer/tests/test_computer.py b/plugins/computer/tests/test_computer.py
iindex f30cc1b..a0fbd51 100644
---- a/plugins/computer/tests/test_computer.py
++++ b/plugins/computer/tests/test_computer.py
@@@ -166,6 +166,25 @@ class ComputerTest(unittest.TestCase):
self.assertEqual((r["fault_code"], r["message"]), ("not_allowed", "the owner denied it on the machine"))
self.assertFalse(os.path.exists(os.path.join(self.dir, "denied.txt")))
++ def test_a_waiting_request_notifies_the_desktop(self):
++ self.boot("ask")
++ calls = []
++ real = self.plugin.popen
++ self.plugin.popen = lambda argv, **kw: calls.append(argv) if argv[0] in ("notify-send", "osascript") else real(argv, **kw)
++ self.plugin.which = lambda name: "/usr/bin/" + name
++ self.plugin.env = {"DISPLAY": ":0"}
++ cid = self.cmd("exec", {"argv": ["echo", "hi"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval")
++ self.assertEqual(calls[0][:3], ["notify-send", "--app-name=OpenVibe Node", "OpenVibe: your agent asks"])
++ self.assertIn("openvibe-node approve %s" % cid, calls[0][3])
++ self.plugin.env = {}
++ self.cmd("exec", {"argv": ["echo", "again"]})
++ self.assertEqual(len(calls), 1, "no graphical session: no notification")
++ self.plugin.notify = False
++ self.plugin.env = {"DISPLAY": ":0"}
++ self.cmd("exec", {"argv": ["echo", "quiet"]})
++ self.assertEqual(len(calls), 1, "notify off")
++
def test_unanswered_requests_expire(self):
self.boot("ask", approval_timeout_s=10)
t = [1000.0]