borgmcp 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,743 @@
1
+ """Borg representative push: a Borg-owned Hermes user plugin.
2
+
3
+ When the bound Borg Coordinator replies to the human representative, this plugin
4
+ wakes the one Hermes *messaging-gateway* conversation named by
5
+ ``plugins.entries.borg-representative-push.settings.session_key``.
6
+
7
+ - It supervises ``borg representative listen`` (borgmcp >= 5.6.0), which emits
8
+ body-free JSON wake hints on stdout.
9
+ - It turns those hints into one fixed, body-free ``ctx.inject_message`` prompt.
10
+ Reply content never passes through the plugin: the woken conversation fetches it
11
+ with ``borg_representative-read`` and confirms it with
12
+ ``borg_representative-deliver``.
13
+
14
+ The listener starts only from ``ctx.register_platform_handler``, which Hermes calls
15
+ when a gateway platform connects, so it never runs in the CLI, the Desktop backend
16
+ (``hermes serve``) or worker processes. A Desktop chat cannot be woken: Hermes's
17
+ ``inject_message`` reaches only CLI and messaging-gateway conversations.
18
+
19
+ Standard library only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import atexit
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import os
29
+ import re
30
+ import signal
31
+ import subprocess
32
+ import threading
33
+ import time
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+ from typing import Any, Callable, NamedTuple, Optional
37
+
38
+ PLUGIN_NAME = "borg-representative-push"
39
+
40
+ # Fixed and body-free: no entry id, sender, text or document ever enters the prompt.
41
+ WAKE_TEXT = (
42
+ "Borg: new Coordinator reply. Call borg_representative-read, persist and relay, "
43
+ "then borg_representative-deliver through the last persisted entry_id."
44
+ )
45
+
46
+ DEBOUNCE_S = 2.0
47
+ BACKOFF_START_S = 1.0
48
+ BACKOFF_CAP_S = 60.0
49
+ INJECT_RETRY_CAP_S = 30.0
50
+ STATE_VERSION = 1
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
55
+ _PLATFORM = re.compile(r"^[a-z0-9_-]+$")
56
+
57
+
58
+ class Settings(NamedTuple):
59
+ session_key: str
60
+ platform: str
61
+ worktree: str
62
+ borg_command: str
63
+ mcp_server: str
64
+ reinject_after_s: int
65
+ max_reinjects: int
66
+
67
+
68
+ def load_settings(get: Callable[..., Any]) -> tuple[Optional[Settings], Optional[str]]:
69
+ """Validate this plugin's settings; return (settings, None) or (None, reason)."""
70
+
71
+ def value(key: str, default: Any = None) -> Any:
72
+ return get(key, default=default)
73
+
74
+ session_key = value("session_key")
75
+ if not isinstance(session_key, str) or not session_key.strip():
76
+ return None, "settings.session_key is required (a gateway session key such as agent:main:telegram:dm:<chat id>)"
77
+ parts = session_key.split(":")
78
+ if len(parts) < 4 or parts[0] != "agent" or not all(parts) or not _PLATFORM.match(parts[2]):
79
+ return None, "settings.session_key must look like agent:main:<platform>:<chat type>[:<chat id>...]"
80
+ worktree = value("worktree")
81
+ if not isinstance(worktree, str) or not os.path.isabs(worktree):
82
+ return None, "settings.worktree must be the absolute path of the prepared representative worktree"
83
+ borg_command = value("borg_command", "borg")
84
+ if not isinstance(borg_command, str) or not borg_command.strip() or "\n" in borg_command:
85
+ return None, "settings.borg_command must be a non-empty executable name or path"
86
+ mcp_server = value("mcp_server", "borg-representative")
87
+ if not isinstance(mcp_server, str) or not mcp_server.strip():
88
+ return None, "settings.mcp_server must name the mcp_servers entry that runs `borg representative mcp`"
89
+ reinject_after_s = value("reinject_after_s", 600)
90
+ if isinstance(reinject_after_s, bool) or not isinstance(reinject_after_s, int) or reinject_after_s < 1:
91
+ return None, "settings.reinject_after_s must be a positive integer"
92
+ max_reinjects = value("max_reinjects", 3)
93
+ if isinstance(max_reinjects, bool) or not isinstance(max_reinjects, int) or max_reinjects < 0:
94
+ return None, "settings.max_reinjects must be a non-negative integer"
95
+ return Settings(session_key, parts[2], worktree, borg_command, mcp_server, reinject_after_s, max_reinjects), None
96
+
97
+
98
+ def _sanitize(component: str) -> str:
99
+ return re.sub(r"[^A-Za-z0-9_]", "_", str(component or ""))
100
+
101
+
102
+ def deliver_tool_name(server: str) -> str:
103
+ """Hermes's registry name for the deliver tool on ``server``.
104
+
105
+ The ``mcp__<server>__<tool>`` convention is documented. The sanitising and the
106
+ 64-character hash clamp mirror Hermes's ``mcp_prefixed_tool_name``, which is
107
+ undocumented.
108
+ """
109
+ full = f"mcp__{_sanitize(server)}__{_sanitize('borg_representative-deliver')}"
110
+ if len(full) <= 64:
111
+ return full
112
+ suffix = "_" + hashlib.sha256(full.encode("utf-8")).hexdigest()[:8]
113
+ return full[: 64 - len(suffix)] + suffix
114
+
115
+
116
+ def _instant(created_at: Any) -> Optional[datetime]:
117
+ if not isinstance(created_at, str):
118
+ return None
119
+ try:
120
+ parsed = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
121
+ except ValueError:
122
+ return None
123
+ return parsed if parsed.tzinfo is not None else None
124
+
125
+
126
+ def _point(entry_id: Any, created_at: Any) -> Optional[tuple[datetime, str]]:
127
+ """Checkpoint order: (created_at, id), as the delivered checkpoint compares entries."""
128
+ instant = _instant(created_at)
129
+ if instant is None or not isinstance(entry_id, str) or not _UUID.match(entry_id):
130
+ return None
131
+ return instant, entry_id.lower()
132
+
133
+
134
+ def parse_delivered(result: Any, depth: int = 0) -> Optional[dict]:
135
+ """Find a borg_representative-deliver result ({checkpoint, advanced, binding_fingerprint}).
136
+
137
+ Returns {"entry_id", "created_at"} of the checkpoint, or None when the result is
138
+ not a successful deliver (an error, a refusal or an unrecognised shape).
139
+ """
140
+ if depth > 5:
141
+ return None
142
+ if isinstance(result, (bytes, bytearray)):
143
+ result = result.decode("utf-8", "replace")
144
+ if isinstance(result, str):
145
+ try:
146
+ result = json.loads(result)
147
+ except ValueError:
148
+ return None
149
+ if isinstance(result, dict):
150
+ checkpoint = result.get("checkpoint")
151
+ if isinstance(checkpoint, dict) and "binding_fingerprint" in result and "advanced" in result:
152
+ if _point(checkpoint.get("entry_id"), checkpoint.get("created_at")) is None:
153
+ return None
154
+ return {"entry_id": checkpoint["entry_id"], "created_at": checkpoint["created_at"]}
155
+ for key in ("structuredContent", "result", "content", "text"):
156
+ if key in result:
157
+ found = parse_delivered(result[key], depth + 1)
158
+ if found:
159
+ return found
160
+ return None
161
+ if isinstance(result, list):
162
+ for item in result[:8]:
163
+ found = parse_delivered(item, depth + 1)
164
+ if found:
165
+ return found
166
+ return None
167
+
168
+
169
+ def exit_action(code: Optional[int], stop_reason: Optional[str]) -> str:
170
+ """Listener exit policy: "stop", "restart" or "owned" (see docs/HUMAN_REPRESENTATIVE.md)."""
171
+ if code == 0:
172
+ return "stop" # SIGTERM/SIGINT: our own shutdown
173
+ if code == 2:
174
+ return "stop" # startup binding or usage refusal: the operator must act
175
+ if code == 3:
176
+ return "owned" # another listener holds the lease
177
+ if code == 4:
178
+ return "restart" if stop_reason == "lease-lost" else "stop"
179
+ return "restart" # 1 (fatal) or anything unexpected: capped backoff
180
+
181
+
182
+ def process_info(pid: int) -> Optional[tuple[int, str, str]]:
183
+ """(ppid, start time, command) of a live process, via ps; None when unavailable."""
184
+ try:
185
+ completed = subprocess.run(
186
+ ["ps", "-o", "ppid=", "-o", "lstart=", "-o", "command=", "-p", str(int(pid))],
187
+ capture_output=True, text=True, timeout=5, check=False,
188
+ )
189
+ except (OSError, ValueError, subprocess.SubprocessError):
190
+ return None
191
+ tokens = completed.stdout.split()
192
+ if completed.returncode != 0 or len(tokens) < 7:
193
+ return None
194
+ try:
195
+ ppid = int(tokens[0])
196
+ except ValueError:
197
+ return None
198
+ return ppid, " ".join(tokens[1:6]), " ".join(tokens[6:])
199
+
200
+
201
+ def default_data_dir() -> Path:
202
+ """<hermes home>/plugin-data/borg-representative-push, created private."""
203
+ try:
204
+ from plugins.plugin_storage import plugin_data_dir # Hermes's sanctioned plugin data root
205
+
206
+ directory = Path(plugin_data_dir(PLUGIN_NAME))
207
+ except ImportError:
208
+ home = os.environ.get("HERMES_HOME") or os.path.join(os.path.expanduser("~"), ".hermes")
209
+ directory = Path(home) / "plugin-data" / PLUGIN_NAME
210
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
211
+ if directory.is_symlink() or not directory.is_dir():
212
+ raise OSError(f"{directory} must be a real directory, not a symbolic link")
213
+ os.chmod(directory, 0o700) # Hermes creates it with the umask mode
214
+ return directory
215
+
216
+
217
+ _NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
218
+
219
+
220
+ class StateStore:
221
+ """state.json: the recorded listener child and the observed delivered checkpoint."""
222
+
223
+ def __init__(self, directory: Path):
224
+ self.path = Path(directory) / "state.json"
225
+
226
+ def load(self) -> dict:
227
+ try:
228
+ # O_NOFOLLOW: a symbolic link planted at state.json is refused, never read through.
229
+ descriptor = os.open(self.path, os.O_RDONLY | _NOFOLLOW)
230
+ except OSError:
231
+ return {}
232
+ try:
233
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
234
+ data = json.loads(handle.read())
235
+ except (OSError, ValueError):
236
+ return {}
237
+ return data if isinstance(data, dict) and data.get("version") == STATE_VERSION else {}
238
+
239
+ def save(self, data: dict) -> None:
240
+ payload = json.dumps({**data, "version": STATE_VERSION}, sort_keys=True).encode("utf-8")
241
+ temporary = self.path.with_name(f".state.{os.getpid()}.{threading.get_ident()}.tmp")
242
+ try:
243
+ os.unlink(temporary) # removes a leftover or planted entry itself, never its target
244
+ except FileNotFoundError:
245
+ pass
246
+ # O_EXCL|O_NOFOLLOW: only a fresh regular file is written; os.replace then swaps the
247
+ # name, replacing (not following) anything planted at state.json.
248
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW, 0o600)
249
+ try:
250
+ try:
251
+ os.write(descriptor, payload)
252
+ os.fsync(descriptor)
253
+ finally:
254
+ os.close(descriptor)
255
+ os.replace(temporary, self.path)
256
+ except OSError:
257
+ try:
258
+ os.unlink(temporary)
259
+ except OSError:
260
+ pass
261
+ raise
262
+
263
+
264
+ class Supervisor:
265
+ """Process-singleton owner of one `borg representative listen` child."""
266
+
267
+ def __init__(
268
+ self,
269
+ settings: Settings,
270
+ inject: Callable[[str], bool],
271
+ *,
272
+ data_dir: Callable[[], Path] = default_data_dir,
273
+ popen: Callable[..., Any] = subprocess.Popen,
274
+ info: Callable[[int], Optional[tuple[int, str, str]]] = process_info,
275
+ kill: Callable[[int, int], None] = os.kill,
276
+ clock: Callable[[], float] = time.monotonic,
277
+ debounce_s: float = DEBOUNCE_S,
278
+ backoff_start_s: float = BACKOFF_START_S,
279
+ backoff_cap_s: float = BACKOFF_CAP_S,
280
+ tick_s: Optional[float] = None,
281
+ ):
282
+ self.settings = settings
283
+ self._inject = inject
284
+ self._data_dir = data_dir
285
+ self._popen = popen
286
+ self._info = info
287
+ self._kill = kill
288
+ self._clock = clock
289
+ self._debounce_s = debounce_s
290
+ self._backoff_start_s = backoff_start_s
291
+ self._backoff_cap_s = backoff_cap_s
292
+ self._tick_s = tick_s if tick_s is not None else min(30.0, max(settings.reinject_after_s / 2, 0.05))
293
+ self._retry_s = min(INJECT_RETRY_CAP_S, float(settings.reinject_after_s))
294
+ self._lock = threading.RLock()
295
+ self._stopping = threading.Event()
296
+ self._started = False
297
+ self._store: Optional[StateStore] = None
298
+ self._state: dict = {}
299
+ self._child: Any = None
300
+ self._spawned: Optional[dict] = None
301
+ self._pending: dict[str, dict] = {}
302
+ self._flush_timer: Optional[threading.Timer] = None
303
+ self._inject_failures = 0
304
+ self._listening = False
305
+ self.injections = 0
306
+ self.final_action: Optional[str] = None
307
+
308
+ # ---- lifecycle -----------------------------------------------------------------
309
+
310
+ def ensure_started(self) -> bool:
311
+ """Start once; a platform reconnect calls this again and changes nothing."""
312
+ with self._lock:
313
+ if self._started:
314
+ return False
315
+ self._ensure_state()
316
+ self._started = True
317
+ threading.Thread(target=self._run, name=f"{PLUGIN_NAME}:listener", daemon=True).start()
318
+ threading.Thread(target=self._ticker, name=f"{PLUGIN_NAME}:reinject", daemon=True).start()
319
+ return True
320
+
321
+ def _ensure_state(self) -> None:
322
+ """Load persisted state once (callers hold the lock)."""
323
+ if self._store is None:
324
+ self._store = StateStore(self._data_dir())
325
+ self._state = self._store.load()
326
+
327
+ def _retire(self) -> None:
328
+ """Stop all wake activity: no queued, pending or repeat wake may follow."""
329
+ self._stopping.set()
330
+ with self._lock:
331
+ if self._flush_timer is not None:
332
+ self._flush_timer.cancel()
333
+ self._flush_timer = None
334
+ self._pending.clear()
335
+
336
+ def shutdown(self, timeout: float = 5.0) -> None:
337
+ self._retire()
338
+ with self._lock:
339
+ child = self._child
340
+ if child is not None and child.poll() is None:
341
+ try:
342
+ child.terminate()
343
+ child.wait(timeout=timeout)
344
+ except subprocess.TimeoutExpired:
345
+ child.kill()
346
+ except OSError:
347
+ pass
348
+
349
+ def _run(self) -> None:
350
+ delay = self._backoff_start_s
351
+ while not self._stopping.is_set():
352
+ code, stop_reason, refused = self._run_once()
353
+ if self._stopping.is_set():
354
+ break
355
+ action = exit_action(code, stop_reason)
356
+ if self._listening:
357
+ delay = self._backoff_start_s # a listener that got going resets the backoff
358
+ if action == "stop":
359
+ # A terminal stop (evicted, rebound, revoked, trust changed, binding refused) ends every
360
+ # wake for this binding, not only the listener: retire timers and pending repeats first.
361
+ self._retire()
362
+ self.final_action = "stop"
363
+ if code != 0:
364
+ logger.warning("%s: listener stopped (exit %s, %s); not restarting until the gateway restarts",
365
+ PLUGIN_NAME, code, stop_reason or (refused or {}).get("code") or "no reason")
366
+ return
367
+ if action == "owned" and self._reap((refused or {}).get("owner_pid")):
368
+ delay = self._backoff_start_s
369
+ if self._stopping.wait(delay):
370
+ break
371
+ delay = min(delay * 2, self._backoff_cap_s)
372
+
373
+ def _run_once(self) -> tuple[Optional[int], Optional[str], Optional[dict]]:
374
+ s = self.settings
375
+ args = [s.borg_command, "representative", "listen", "--worktree", s.worktree]
376
+ with self._lock:
377
+ through = (self._state.get("delivered") or {}).get("entry_id")
378
+ if isinstance(through, str) and _UUID.match(through):
379
+ args += ["--replay-after", through]
380
+ self._listening = False
381
+ errors = self._stderr_log()
382
+ try:
383
+ child = self._popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=errors,
384
+ text=True, bufsize=1, close_fds=True)
385
+ except OSError as error:
386
+ logger.warning("%s: cannot start %s: %s", PLUGIN_NAME, s.borg_command, error)
387
+ return 1, None, None
388
+ finally:
389
+ if errors not in (None, subprocess.DEVNULL):
390
+ errors.close()
391
+ info = self._info(child.pid)
392
+ with self._lock:
393
+ self._child = child
394
+ # Recorded as the lease holder only once it reports `listening`: a child refused
395
+ # with exit 3 must not overwrite the record of the orphan it was refused by.
396
+ self._spawned = {"pid": child.pid, "parent": os.getpid(), "started": info[1] if info else None}
397
+ if self._stopping.is_set():
398
+ child.terminate() # shutdown raced this spawn: do not leave a child behind
399
+ stop_reason: Optional[str] = None
400
+ refused: Optional[dict] = None
401
+ for line in child.stdout:
402
+ event = self._parse(line)
403
+ if event is None:
404
+ continue
405
+ kind = event.get("event")
406
+ if kind == "stopped":
407
+ stop_reason = event.get("reason") if isinstance(event.get("reason"), str) else None
408
+ elif kind == "refused":
409
+ refused = event
410
+ self.handle_event(event)
411
+ code = child.wait()
412
+ child.stdout.close()
413
+ with self._lock:
414
+ self._child = None
415
+ return code, stop_reason, refused
416
+
417
+ def _stderr_log(self) -> Any:
418
+ """Listener diagnostics go to a private, size-capped file, never to the prompt."""
419
+ with self._lock:
420
+ store = self._store
421
+ if store is None:
422
+ return subprocess.DEVNULL
423
+ path = store.path.with_name("listener.stderr.log")
424
+ try:
425
+ # O_NOFOLLOW: a symbolic link planted at the log is refused (diagnostics are dropped).
426
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | _NOFOLLOW, 0o600)
427
+ except OSError:
428
+ return subprocess.DEVNULL
429
+ try:
430
+ if os.fstat(descriptor).st_size > 1_000_000:
431
+ os.ftruncate(descriptor, 0)
432
+ return os.fdopen(descriptor, "a", encoding="utf-8")
433
+ except OSError:
434
+ os.close(descriptor)
435
+ return subprocess.DEVNULL
436
+
437
+ @staticmethod
438
+ def _parse(line: str) -> Optional[dict]:
439
+ try:
440
+ event = json.loads(line)
441
+ except ValueError:
442
+ return None
443
+ return event if isinstance(event, dict) else None
444
+
445
+ # ---- hints and injection ---------------------------------------------------------
446
+
447
+ def handle_event(self, event: dict) -> None:
448
+ kind = event.get("event")
449
+ if kind == "listening":
450
+ self._listening = True
451
+ with self._lock:
452
+ if self._spawned is not None:
453
+ self._state["child"] = self._spawned
454
+ self._save()
455
+ logger.info("%s: listening (binding %s)", PLUGIN_NAME, event.get("binding_fingerprint"))
456
+ elif kind == "entry":
457
+ self._hint(event.get("entry_id"), event.get("created_at"))
458
+ elif kind == "gap":
459
+ self._hint("gap", None)
460
+ # Unknown events and fields are ignored by contract.
461
+
462
+ def _hint(self, key: Any, created_at: Any) -> None:
463
+ with self._lock:
464
+ if self._stopping.is_set():
465
+ return
466
+ self._ensure_state()
467
+ if key != "gap":
468
+ point = _point(key, created_at)
469
+ if point is None:
470
+ return
471
+ key = point[1]
472
+ delivered = self._delivered_point()
473
+ if delivered is not None and point <= delivered:
474
+ return # already delivered; a replayed hint needs no wake
475
+ else:
476
+ point = None
477
+ if key in self._pending:
478
+ return
479
+ used = self._wakes_used(key)
480
+ if used > self.settings.max_reinjects:
481
+ return # budget spent (possibly before a restart): no wake until a delivery covers it
482
+ # A reply already woken before a restart waits reinject_after_s for its next wake.
483
+ self._pending[key] = {"point": point, "created_at": created_at, "count": used,
484
+ "last": self._clock() if used else None}
485
+ if not used:
486
+ self._schedule_flush(self._debounce_s)
487
+
488
+ def _wake_records(self) -> dict:
489
+ records = self._state.get("wakes")
490
+ return records if isinstance(records, dict) else {}
491
+
492
+ def _wakes_used(self, key: str) -> int:
493
+ """Persisted wakes already spent on this reply (or on the gap) since its last delivery."""
494
+ if key == "gap":
495
+ used = self._state.get("gap_wakes", 0)
496
+ else:
497
+ used = (self._wake_records().get(key) or {}).get("count", 0)
498
+ return used if isinstance(used, int) and not isinstance(used, bool) and used > 0 else 0
499
+
500
+ def _record_wakes(self, keys: list[str], delta: int) -> bool:
501
+ """Write-through wake counts; False (and nothing changed in memory) when the write fails.
502
+
503
+ Records are removed only by an observed delivery.
504
+ """
505
+ previous_state = dict(self._state)
506
+ previous_counts = {key: self._pending[key]["count"] for key in keys if key in self._pending}
507
+ records = dict(self._wake_records())
508
+ for key in keys:
509
+ item = self._pending.get(key)
510
+ if item is None:
511
+ continue
512
+ item["count"] = max(0, item["count"] + delta)
513
+ if key == "gap":
514
+ self._state["gap_wakes"] = item["count"]
515
+ elif item["count"]:
516
+ records[key] = {"created_at": item["created_at"], "count": item["count"]}
517
+ else:
518
+ records.pop(key, None)
519
+ self._state["wakes"] = records
520
+ if self._save():
521
+ return True
522
+ self._state = previous_state
523
+ for key, count in previous_counts.items():
524
+ if key in self._pending:
525
+ self._pending[key]["count"] = count
526
+ return False
527
+
528
+ def _schedule_flush(self, delay: float) -> None:
529
+ if self._stopping.is_set() or (self._flush_timer is not None and self._flush_timer.is_alive()):
530
+ return
531
+ timer = threading.Timer(delay, self.flush)
532
+ timer.daemon = True
533
+ self._flush_timer = timer
534
+ timer.start()
535
+
536
+ def flush(self) -> None:
537
+ """Wake once for every hint not yet injected (debounced burst coalescing)."""
538
+ with self._lock:
539
+ self._flush_timer = None
540
+ if self._stopping.is_set():
541
+ return
542
+ fresh = [key for key, item in self._pending.items() if item["count"] == 0]
543
+ if fresh:
544
+ self._wake(fresh)
545
+
546
+ def tick(self) -> None:
547
+ """Re-inject net: wake again for hints still undelivered after reinject_after_s."""
548
+ now = self._clock()
549
+ with self._lock:
550
+ if self._stopping.is_set():
551
+ return
552
+ due: list[str] = []
553
+ for key, item in list(self._pending.items()):
554
+ if item["count"] == 0 or now - item["last"] < self.settings.reinject_after_s:
555
+ continue
556
+ if item["count"] > self.settings.max_reinjects:
557
+ logger.warning("%s: a Coordinator reply is still undelivered after %d wakes; giving up on it "
558
+ "until it is delivered", PLUGIN_NAME, item["count"])
559
+ del self._pending[key] # its persisted record keeps the budget spent
560
+ continue
561
+ due.append(key)
562
+ if due:
563
+ self._wake(due)
564
+
565
+ def _wake(self, keys: list[str]) -> None:
566
+ with self._lock:
567
+ if self._stopping.is_set():
568
+ return
569
+ keys = [key for key in keys if key in self._pending]
570
+ if not keys:
571
+ return
572
+ # Count the wake on disk before Hermes can start the turn: a restart at any point after
573
+ # this can never renew the budget. A crash between here and the call loses one wake at
574
+ # most; it never adds one.
575
+ if not self._record_wakes(keys, +1):
576
+ # Fail closed: a wake whose count cannot be persisted could be repeated after a
577
+ # restart, so there is no in-memory fallback wake.
578
+ logger.warning("%s: not waking the conversation: the wake count could not be written to plugin state",
579
+ PLUGIN_NAME)
580
+ return
581
+ try:
582
+ accepted = bool(self._inject(WAKE_TEXT))
583
+ except Exception: # the host API must never take the supervisor down
584
+ logger.warning("%s: inject_message raised", PLUGIN_NAME, exc_info=True)
585
+ accepted = False
586
+ now = self._clock()
587
+ with self._lock:
588
+ if not accepted:
589
+ self._record_wakes(keys, -1) # Hermes refused it: nothing was spent
590
+ self._inject_failures += 1
591
+ if self._inject_failures == 1 or self._inject_failures % 10 == 0:
592
+ logger.warning("%s: Hermes did not accept the wake (%d failures); check allow_gateway_injection "
593
+ "and settings.session_key", PLUGIN_NAME, self._inject_failures)
594
+ self._schedule_flush(self._retry_s)
595
+ return
596
+ self._inject_failures = 0
597
+ self.injections += 1
598
+ for key in keys:
599
+ item = self._pending.get(key)
600
+ if item is not None:
601
+ item["last"] = now
602
+
603
+ def _ticker(self) -> None:
604
+ while not self._stopping.wait(self._tick_s):
605
+ try:
606
+ self.tick()
607
+ except Exception:
608
+ logger.warning("%s: re-inject check failed", PLUGIN_NAME, exc_info=True)
609
+
610
+ # ---- delivery observation -------------------------------------------------------
611
+
612
+ def _delivered_point(self) -> Optional[tuple[datetime, str]]:
613
+ delivered = self._state.get("delivered") or {}
614
+ return _point(delivered.get("entry_id"), delivered.get("created_at"))
615
+
616
+ def observe_delivered(self, checkpoint: dict) -> None:
617
+ """A deliver call in this gateway moved (or confirmed) the delivered checkpoint."""
618
+ point = _point(checkpoint.get("entry_id"), checkpoint.get("created_at"))
619
+ if point is None:
620
+ return
621
+ with self._lock:
622
+ self._ensure_state()
623
+ changed = False
624
+ current = self._delivered_point()
625
+ if current is None or point > current:
626
+ self._state["delivered"] = {"entry_id": checkpoint["entry_id"], "created_at": checkpoint["created_at"]}
627
+ changed = True
628
+ records = self._wake_records()
629
+ remaining = {}
630
+ for k, v in records.items():
631
+ woken = _point(k, (v or {}).get("created_at") if isinstance(v, dict) else None)
632
+ if woken is None or woken > point:
633
+ remaining[k] = v # not covered by this delivery
634
+ if remaining != records:
635
+ self._state["wakes"] = remaining
636
+ changed = True
637
+ if self._state.get("gap_wakes"):
638
+ self._state["gap_wakes"] = 0 # the conversation has read since the gap
639
+ changed = True
640
+ if changed:
641
+ self._save()
642
+ self._pending.pop("gap", None) # the conversation has read since the gap
643
+ for key, item in list(self._pending.items()):
644
+ if item["point"] is not None and item["point"] <= point:
645
+ del self._pending[key]
646
+
647
+ def pending(self) -> dict[str, dict]:
648
+ with self._lock:
649
+ return {key: dict(item) for key, item in self._pending.items()}
650
+
651
+ def _save(self) -> bool:
652
+ """Persist state; False when it could not be written."""
653
+ if self._store is None:
654
+ return False
655
+ try:
656
+ self._store.save(self._state)
657
+ except OSError:
658
+ logger.warning("%s: cannot write plugin state", PLUGIN_NAME, exc_info=True)
659
+ return False
660
+ return True
661
+
662
+ # ---- orphan reaping -------------------------------------------------------------
663
+
664
+ def _reap(self, owner_pid: Any) -> bool:
665
+ """SIGTERM a previous listener only if it is the recorded child and was orphaned."""
666
+ with self._lock:
667
+ recorded = dict(self._state.get("child") or {})
668
+ if isinstance(owner_pid, bool) or not isinstance(owner_pid, int) or owner_pid != recorded.get("pid"):
669
+ return False
670
+ info = self._info(owner_pid)
671
+ if info is None:
672
+ return False
673
+ ppid, started, command = info
674
+ if not recorded.get("started") or started != recorded["started"]:
675
+ return False # the pid was reused by another process
676
+ if ppid == recorded.get("parent") or ppid == os.getpid():
677
+ return False # its parent is alive: not an orphan
678
+ if "representative" not in command or "listen" not in command:
679
+ return False
680
+ try:
681
+ self._kill(owner_pid, signal.SIGTERM)
682
+ except OSError:
683
+ return False
684
+ logger.info("%s: stopped orphaned listener pid %d from a previous gateway", PLUGIN_NAME, owner_pid)
685
+ return True
686
+
687
+
688
+ _SUPERVISOR: Optional[Supervisor] = None
689
+ _SUPERVISOR_LOCK = threading.Lock()
690
+
691
+
692
+ def _start(settings: Settings, ctx: Any) -> Supervisor:
693
+ global _SUPERVISOR
694
+ with _SUPERVISOR_LOCK:
695
+ if _SUPERVISOR is None:
696
+ _SUPERVISOR = Supervisor(
697
+ settings,
698
+ lambda text: ctx.inject_message(text, role="user", session_key=settings.session_key),
699
+ )
700
+ supervisor = _SUPERVISOR
701
+ supervisor.ensure_started()
702
+ return supervisor
703
+
704
+
705
+ def _shutdown() -> None:
706
+ with _SUPERVISOR_LOCK:
707
+ supervisor = _SUPERVISOR
708
+ if supervisor is not None:
709
+ supervisor.shutdown()
710
+
711
+
712
+ def _post_tool_call_observer(expected_tool: str) -> Callable[..., None]:
713
+ def on_post_tool_call(tool_name: Any = None, result: Any = None, **kwargs: Any) -> None:
714
+ del kwargs
715
+ if tool_name != expected_tool:
716
+ return
717
+ with _SUPERVISOR_LOCK:
718
+ supervisor = _SUPERVISOR
719
+ if supervisor is None:
720
+ return # not the gateway process
721
+ checkpoint = parse_delivered(result)
722
+ if checkpoint is not None:
723
+ supervisor.observe_delivered(checkpoint)
724
+
725
+ return on_post_tool_call
726
+
727
+
728
+ def register(ctx: Any) -> None:
729
+ try:
730
+ settings, problem = load_settings(ctx.get_config)
731
+ except Exception as error: # an unreadable config must not break Hermes startup
732
+ settings, problem = None, f"settings could not be read: {error}"
733
+ if settings is None:
734
+ logger.warning("%s: disabled: %s", PLUGIN_NAME, problem)
735
+ return
736
+
737
+ def on_platform_connect(*args: Any, **kwargs: Any) -> None:
738
+ del args, kwargs
739
+ _start(settings, ctx)
740
+
741
+ ctx.register_platform_handler(settings.platform, on_platform_connect)
742
+ ctx.register_hook("post_tool_call", _post_tool_call_observer(deliver_tool_name(settings.mcp_server)))
743
+ atexit.register(_shutdown)