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.
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +4 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +9 -2
- package/dist/cli-help.js.map +1 -1
- package/dist/hermes-plugin-install.d.ts +19 -0
- package/dist/hermes-plugin-install.d.ts.map +1 -0
- package/dist/hermes-plugin-install.js +131 -0
- package/dist/hermes-plugin-install.js.map +1 -0
- package/dist/representative-cmd.d.ts +4 -0
- package/dist/representative-cmd.d.ts.map +1 -1
- package/dist/representative-cmd.js +30 -1
- package/dist/representative-cmd.js.map +1 -1
- package/docs/HUMAN_REPRESENTATIVE.md +109 -0
- package/hermes-plugin/borg-representative-push/__init__.py +743 -0
- package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
- package/package.json +3 -1
- package/src/claude.ts +4 -0
- package/src/cli-help.ts +9 -2
- package/src/hermes-plugin-install.ts +147 -0
- package/src/representative-cmd.ts +28 -2
|
@@ -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)
|