agent-peers 0.1.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/CHANGELOG.md +14 -0
- package/CLAUDE-snippet.md +3 -0
- package/CONTRIBUTING.md +55 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/SECURITY.md +13 -0
- package/__init__.py +8 -0
- package/after-install.md +11 -0
- package/agent_peers/__init__.py +6 -0
- package/agent_peers/plugin.py +182 -0
- package/agent_peers/runtime.py +293 -0
- package/bin/agent-peers.mjs +69 -0
- package/docs/compatibility.md +38 -0
- package/docs/hermes.md +55 -0
- package/docs/reference.md +66 -0
- package/docs/release-verification.md +31 -0
- package/hermes-plugin/__init__.py +15 -0
- package/hermes-plugin/plugin.yaml +13 -0
- package/package.json +67 -0
- package/plugin.yaml +20 -0
- package/scripts/install-hermes-plugin.py +34 -0
- package/src/app-server.mjs +137 -0
- package/src/codex-peer.mjs +504 -0
- package/src/diagnostics.mjs +42 -0
- package/src/discovery.mjs +57 -0
- package/src/guidance.mjs +5 -0
- package/src/hermes-bridge.mjs +376 -0
- package/src/version.mjs +3 -0
- package/src/wire.mjs +313 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Bounded subprocess transport and read-only runtime dependency checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import Future, TimeoutError
|
|
6
|
+
from contextvars import copy_context
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from queue import Empty, Full, Queue
|
|
13
|
+
import re
|
|
14
|
+
import select
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
MAX_LINE = 1_000_000
|
|
23
|
+
QUEUE_MAX = 50
|
|
24
|
+
PENDING_MAX = 64
|
|
25
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
_EXITED = "agent-peers bridge exited; delivery is unknown and was not retried"
|
|
27
|
+
_TIMED_OUT = "agent-peers request timed out; delivery is unknown and was not retried"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def context_thread(target, *, name, args=()):
|
|
31
|
+
"""Carry public contextvars (including the host profile) into daemon workers."""
|
|
32
|
+
context = copy_context()
|
|
33
|
+
return threading.Thread(target=context.run, args=(target, *args), name=name, daemon=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bridge_environment():
|
|
37
|
+
# Discovery paths only; provider credentials and Node preload hooks stay out.
|
|
38
|
+
return {key: os.environ[key] for key in (
|
|
39
|
+
"PATH", "HOME", "XDG_RUNTIME_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME",
|
|
40
|
+
"AGENT_PEERS_HOME", "AGENT_PEERS_CODEX_APP_SERVER",
|
|
41
|
+
) if key in os.environ}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_command(*, root=ROOT, hermes_home=None):
|
|
45
|
+
"""Resolve a usable system or Hermes-managed Node without changing either."""
|
|
46
|
+
home = Path(hermes_home or os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
|
|
47
|
+
homes = [home]
|
|
48
|
+
if home.parent.name == "profiles":
|
|
49
|
+
homes.append(home.parent.parent)
|
|
50
|
+
homes.append(Path.home() / ".hermes")
|
|
51
|
+
candidates = [str(path / suffix) for path in homes for suffix in ("node/bin/node", "node/node")]
|
|
52
|
+
if system := shutil.which("node"):
|
|
53
|
+
candidates.append(system)
|
|
54
|
+
env = bridge_environment()
|
|
55
|
+
deadline = time.monotonic() + 5
|
|
56
|
+
node = None
|
|
57
|
+
for candidate in dict.fromkeys(candidates):
|
|
58
|
+
if not os.access(candidate, os.X_OK) or not Path(candidate).is_file():
|
|
59
|
+
continue
|
|
60
|
+
remaining = deadline - time.monotonic()
|
|
61
|
+
if remaining <= 0:
|
|
62
|
+
break
|
|
63
|
+
try:
|
|
64
|
+
probe = subprocess.run([candidate, "--version"], stdout=subprocess.PIPE,
|
|
65
|
+
stderr=subprocess.DEVNULL, env=env, timeout=min(1, remaining), check=False)
|
|
66
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
67
|
+
continue
|
|
68
|
+
match = re.fullmatch(rb"v(\d+)\.\d+\.\d+\s*", probe.stdout)
|
|
69
|
+
if probe.returncode == 0 and match and int(match[1]) >= 22:
|
|
70
|
+
node = str(Path(candidate).absolute())
|
|
71
|
+
break
|
|
72
|
+
if node is None:
|
|
73
|
+
raise RuntimeError("agent-peers requires Node.js 22 or newer on PATH or in the Hermes-managed node directory")
|
|
74
|
+
bridge = root / "bin" / "agent-peers.mjs"
|
|
75
|
+
if not bridge.is_file():
|
|
76
|
+
raise RuntimeError(f"agent-peers bridge is missing at {bridge}; install the whole repository as a Hermes plugin")
|
|
77
|
+
# Import the actual bridge module, without starting its stdin server. This
|
|
78
|
+
# catches missing and incompatible dependencies before the first request.
|
|
79
|
+
try:
|
|
80
|
+
probe = subprocess.run([node, "--input-type=module", "-e", "await import(process.argv[1])",
|
|
81
|
+
(root / "src" / "hermes-bridge.mjs").as_uri()],
|
|
82
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
83
|
+
env=env, timeout=5, check=False)
|
|
84
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
85
|
+
raise RuntimeError("agent-peers bridge dependency check failed or timed out") from None
|
|
86
|
+
if probe.returncode:
|
|
87
|
+
command = shlex.join(["npm", "--prefix", str(root), "ci", "--omit=dev", "--ignore-scripts"])
|
|
88
|
+
raise RuntimeError(f"agent-peers bridge dependencies are unavailable; run: {command}")
|
|
89
|
+
return [node, str(bridge), "hermes-bridge"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BridgeClient:
|
|
93
|
+
"""One subprocess per plugin/profile; writes and responses share a deadline."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, command: list[str], receive):
|
|
96
|
+
if not isinstance(command, list) or not command or not all(isinstance(x, str) and x for x in command):
|
|
97
|
+
raise ValueError("agent-peers bridge_command must be a nonempty executable/argument list")
|
|
98
|
+
self.command = list(command)
|
|
99
|
+
self.receive = receive
|
|
100
|
+
self._environment = bridge_environment()
|
|
101
|
+
self._lock = threading.RLock()
|
|
102
|
+
self._write_lock = threading.Lock()
|
|
103
|
+
self._process = None
|
|
104
|
+
self._reader = None
|
|
105
|
+
self._pending = {}
|
|
106
|
+
self._next = 0
|
|
107
|
+
self._stopped = False
|
|
108
|
+
self._messages = Queue(maxsize=QUEUE_MAX)
|
|
109
|
+
self._delivery = context_thread(self._deliver, name="agent-peers-delivery")
|
|
110
|
+
self._delivery.start()
|
|
111
|
+
|
|
112
|
+
def _start(self):
|
|
113
|
+
# Called under both locks. Only this path creates processes or reuses FDs.
|
|
114
|
+
if self._stopped:
|
|
115
|
+
raise RuntimeError("agent-peers plugin has been unloaded")
|
|
116
|
+
if self._process is not None:
|
|
117
|
+
if self._process.poll() is None:
|
|
118
|
+
return self._process
|
|
119
|
+
self._process.stdin.close()
|
|
120
|
+
process = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
121
|
+
stderr=subprocess.DEVNULL, env=self._environment)
|
|
122
|
+
os.set_blocking(process.stdin.fileno(), False)
|
|
123
|
+
self._process = process
|
|
124
|
+
self._reader = context_thread(self._read, name="agent-peers-reader", args=(process,))
|
|
125
|
+
self._reader.start()
|
|
126
|
+
return process
|
|
127
|
+
|
|
128
|
+
def _fail_pending(self, process, message):
|
|
129
|
+
with self._lock:
|
|
130
|
+
for owner, future in list(self._pending.values()):
|
|
131
|
+
if (process is None or owner is process) and not future.done():
|
|
132
|
+
future.set_exception(RuntimeError(message))
|
|
133
|
+
|
|
134
|
+
def _write(self, process, data, deadline):
|
|
135
|
+
view = memoryview(data)
|
|
136
|
+
while view:
|
|
137
|
+
remaining = deadline - time.monotonic()
|
|
138
|
+
if remaining <= 0:
|
|
139
|
+
raise TimeoutError()
|
|
140
|
+
if self._stopped or process.poll() is not None:
|
|
141
|
+
raise BrokenPipeError()
|
|
142
|
+
fd = process.stdin.fileno()
|
|
143
|
+
if not select.select([], [fd], [], min(remaining, 0.1))[1]:
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
written = os.write(fd, view)
|
|
147
|
+
except BlockingIOError:
|
|
148
|
+
continue
|
|
149
|
+
if written <= 0:
|
|
150
|
+
raise BrokenPipeError()
|
|
151
|
+
view = view[written:]
|
|
152
|
+
|
|
153
|
+
def call(self, method: str, params: dict | None = None, *, timeout: float = 25):
|
|
154
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
155
|
+
raise ValueError("agent-peers timeout must be positive and finite")
|
|
156
|
+
deadline = time.monotonic() + timeout
|
|
157
|
+
request_id = None
|
|
158
|
+
if not self._write_lock.acquire(timeout=timeout):
|
|
159
|
+
raise RuntimeError("agent-peers bridge is busy; no message was sent")
|
|
160
|
+
try:
|
|
161
|
+
with self._lock:
|
|
162
|
+
if len(self._pending) >= PENDING_MAX:
|
|
163
|
+
raise RuntimeError("agent-peers has too many pending requests; no message was sent")
|
|
164
|
+
self._next += 1
|
|
165
|
+
request_id = self._next
|
|
166
|
+
data = (json.dumps({"id": request_id, "method": method, "params": params or {}},
|
|
167
|
+
ensure_ascii=False) + "\n").encode("utf-8")
|
|
168
|
+
if len(data) > MAX_LINE:
|
|
169
|
+
raise ValueError("agent-peers request exceeds the message size limit")
|
|
170
|
+
process = self._start()
|
|
171
|
+
future = Future()
|
|
172
|
+
self._pending[request_id] = (process, future)
|
|
173
|
+
try:
|
|
174
|
+
self._write(process, data, deadline)
|
|
175
|
+
except (TimeoutError, OSError, ValueError) as exc:
|
|
176
|
+
# A partially written frame cannot be reused safely.
|
|
177
|
+
try:
|
|
178
|
+
process.terminate()
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
message = _TIMED_OUT if isinstance(exc, TimeoutError) else _EXITED
|
|
182
|
+
self._fail_pending(process, message)
|
|
183
|
+
raise RuntimeError(message) from None
|
|
184
|
+
except BaseException:
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._pending.pop(request_id, None)
|
|
187
|
+
raise
|
|
188
|
+
finally:
|
|
189
|
+
self._write_lock.release()
|
|
190
|
+
try:
|
|
191
|
+
return future.result(timeout=max(0, deadline - time.monotonic()))
|
|
192
|
+
except TimeoutError:
|
|
193
|
+
raise RuntimeError(_TIMED_OUT) from None
|
|
194
|
+
finally:
|
|
195
|
+
with self._lock:
|
|
196
|
+
self._pending.pop(request_id, None)
|
|
197
|
+
|
|
198
|
+
def _read(self, process):
|
|
199
|
+
try:
|
|
200
|
+
while not self._stopped:
|
|
201
|
+
line = process.stdout.readline(MAX_LINE + 1)
|
|
202
|
+
if not line:
|
|
203
|
+
break
|
|
204
|
+
if len(line) > MAX_LINE or not line.endswith(b"\n"):
|
|
205
|
+
logger.warning("agent-peers bridge exceeded the protocol line limit")
|
|
206
|
+
break
|
|
207
|
+
try:
|
|
208
|
+
result = json.loads(line.decode("utf-8"))
|
|
209
|
+
except (ValueError, UnicodeError):
|
|
210
|
+
logger.warning("agent-peers bridge returned invalid JSON")
|
|
211
|
+
break
|
|
212
|
+
if not isinstance(result, dict):
|
|
213
|
+
continue
|
|
214
|
+
if result.get("method") == "peer_message":
|
|
215
|
+
try:
|
|
216
|
+
self._messages.put_nowait(result.get("params", {}))
|
|
217
|
+
except Full:
|
|
218
|
+
logger.warning("agent-peers incoming queue is full; dropped peer message")
|
|
219
|
+
continue
|
|
220
|
+
request_id = result.get("id")
|
|
221
|
+
if type(request_id) is not int:
|
|
222
|
+
continue
|
|
223
|
+
with self._lock:
|
|
224
|
+
pending = self._pending.get(request_id)
|
|
225
|
+
if pending and pending[0] is process and not pending[1].done():
|
|
226
|
+
error = result.get("error")
|
|
227
|
+
if error:
|
|
228
|
+
text = error.get("message", "bridge request failed") if isinstance(error, dict) else str(error)
|
|
229
|
+
pending[1].set_exception(RuntimeError(text))
|
|
230
|
+
else:
|
|
231
|
+
pending[1].set_result(result.get("result"))
|
|
232
|
+
except (OSError, ValueError):
|
|
233
|
+
logger.warning("agent-peers bridge connection closed")
|
|
234
|
+
finally:
|
|
235
|
+
self._fail_pending(process, _EXITED)
|
|
236
|
+
# A broken stdout protocol must not leave an orphaned subprocess.
|
|
237
|
+
try:
|
|
238
|
+
if process.poll() is None:
|
|
239
|
+
process.terminate()
|
|
240
|
+
process.wait(timeout=0.5)
|
|
241
|
+
except subprocess.TimeoutExpired:
|
|
242
|
+
process.kill()
|
|
243
|
+
process.wait(timeout=0.5)
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
process.stdout.close()
|
|
247
|
+
|
|
248
|
+
def _deliver(self):
|
|
249
|
+
while not self._stopped:
|
|
250
|
+
try:
|
|
251
|
+
message = self._messages.get(timeout=0.1)
|
|
252
|
+
except Empty:
|
|
253
|
+
continue
|
|
254
|
+
if self._stopped:
|
|
255
|
+
break
|
|
256
|
+
try:
|
|
257
|
+
self.receive(message)
|
|
258
|
+
except Exception:
|
|
259
|
+
# Never log peer bodies, which may contain task data.
|
|
260
|
+
logger.warning("agent-peers message could not be delivered", exc_info=False)
|
|
261
|
+
|
|
262
|
+
def close(self):
|
|
263
|
+
with self._lock:
|
|
264
|
+
self._stopped = True
|
|
265
|
+
process = self._process
|
|
266
|
+
reader = self._reader
|
|
267
|
+
self._fail_pending(None, "agent-peers plugin has been unloaded")
|
|
268
|
+
if process:
|
|
269
|
+
# Writers poll the stop flag every 100ms and release this lock.
|
|
270
|
+
if self._write_lock.acquire(timeout=0.5):
|
|
271
|
+
try:
|
|
272
|
+
process.stdin.close()
|
|
273
|
+
finally:
|
|
274
|
+
self._write_lock.release()
|
|
275
|
+
try:
|
|
276
|
+
process.wait(timeout=0.5)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
process.terminate()
|
|
279
|
+
try:
|
|
280
|
+
process.wait(timeout=0.5)
|
|
281
|
+
except subprocess.TimeoutExpired:
|
|
282
|
+
process.kill()
|
|
283
|
+
process.wait(timeout=0.5)
|
|
284
|
+
except OSError:
|
|
285
|
+
pass
|
|
286
|
+
for worker in (reader, self._delivery):
|
|
287
|
+
if worker and worker is not threading.current_thread():
|
|
288
|
+
worker.join(timeout=0.5)
|
|
289
|
+
while True:
|
|
290
|
+
try:
|
|
291
|
+
self._messages.get_nowait()
|
|
292
|
+
except Empty:
|
|
293
|
+
break
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { allPeers } from "../src/wire.mjs";
|
|
3
|
+
import { formatSessionPeer } from "../src/discovery.mjs";
|
|
4
|
+
import { VERSION } from "../src/version.mjs";
|
|
5
|
+
|
|
6
|
+
const usage = `agent-peers ${VERSION} — local agent messaging
|
|
7
|
+
|
|
8
|
+
Usage: agent-peers <command> [options]
|
|
9
|
+
|
|
10
|
+
list [--json] Discover running Claude Code and Codex inboxes
|
|
11
|
+
doctor [--json] Check local prerequisites without changing configuration
|
|
12
|
+
hermes-bridge Start the plugin-managed JSON-lines subprocess
|
|
13
|
+
--version Print the installed version
|
|
14
|
+
--help Show this help
|
|
15
|
+
|
|
16
|
+
Codex MCP server: codex-peer
|
|
17
|
+
Documentation: https://github.com/ThomasMarcelis/agent-peers`;
|
|
18
|
+
|
|
19
|
+
process.stdout.on("error", (error) => {
|
|
20
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
21
|
+
console.error(`agent-peers: ${error.message}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
27
|
+
if (!cmd || ["--help", "-h", "help"].includes(cmd)) {
|
|
28
|
+
if (args.length) throw new SyntaxError("unexpected arguments");
|
|
29
|
+
console.log(usage);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (["--version", "-v", "version"].includes(cmd) && !args.length) {
|
|
33
|
+
console.log(VERSION);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!["list", "doctor", "hermes-bridge"].includes(cmd) || args.length > 1 ||
|
|
37
|
+
(args.length && (args[0] !== "--json" || cmd === "hermes-bridge"))) {
|
|
38
|
+
throw new SyntaxError("unknown command or option; run agent-peers --help");
|
|
39
|
+
}
|
|
40
|
+
if (cmd === "hermes-bridge") {
|
|
41
|
+
const { runHermesBridge } = await import("../src/hermes-bridge.mjs");
|
|
42
|
+
runHermesBridge();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (cmd === "doctor") {
|
|
46
|
+
const { diagnose } = await import("../src/diagnostics.mjs");
|
|
47
|
+
const result = diagnose();
|
|
48
|
+
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
49
|
+
else {
|
|
50
|
+
console.log(`agent-peers ${VERSION}`);
|
|
51
|
+
for (const check of result.checks) console.log(`${check.status.toUpperCase()} ${check.name}: ${check.message}`);
|
|
52
|
+
}
|
|
53
|
+
if (!result.ok) process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const peers = allPeers();
|
|
57
|
+
if (args.includes("--json")) console.log(JSON.stringify({ peers }, null, 2));
|
|
58
|
+
else {
|
|
59
|
+
if (!peers.length) console.log("No agent sessions are running.");
|
|
60
|
+
for (const peer of peers) console.log(formatSessionPeer(peer));
|
|
61
|
+
if (peers.length) console.log("Codex native reachability depends on your session; call list_peers there for native targets.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try { await main(); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.error(`agent-peers: ${error.message}`);
|
|
68
|
+
process.exitCode = error instanceof SyntaxError ? 2 : 1;
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Compatibility
|
|
2
|
+
|
|
3
|
+
agent-peers 0.1.0 is an initial release. Its CLI and MCP tools are small, but the agent runtimes it connects expose version-sensitive interfaces. A newer agent release is not automatically compatible.
|
|
4
|
+
|
|
5
|
+
## Runtime requirements
|
|
6
|
+
|
|
7
|
+
| Component | Requirement |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Node.js | 22 or later; CI covers 22, 24, and 26 |
|
|
10
|
+
| Operating system | Linux and macOS; local Unix sockets are required |
|
|
11
|
+
| Python (Hermes only) | 3.11–3.13, as supported by the tested Hermes host |
|
|
12
|
+
| Claude Code | Cross-session inbox and `SendMessage` support |
|
|
13
|
+
| Codex | Shared app-server daemon, thread identity in MCP call metadata, and live `turn/start` delivery |
|
|
14
|
+
| Hermes | Native plugin loader plus `session_message_route`, `inject_session_message`, and route-retirement hook |
|
|
15
|
+
|
|
16
|
+
Windows is not supported. WSL must run all participating agents inside the same Linux environment. CI on macOS verifies the transport and package; live agent interoperability was exercised on Linux.
|
|
17
|
+
|
|
18
|
+
## Tested agent builds
|
|
19
|
+
|
|
20
|
+
- Codex CLI **0.160.0**.
|
|
21
|
+
- Claude Code **2.1.288**.
|
|
22
|
+
- Hermes fork [ThomasMarcelis/hermes-agent](https://github.com/ThomasMarcelis/hermes-agent) at [`1afb5d16d2d25e1d8e13703c1ce946e3ca0adc0d`](https://github.com/ThomasMarcelis/hermes-agent/commit/1afb5d16d2d25e1d8e13703c1ce946e3ca0adc0d).
|
|
23
|
+
|
|
24
|
+
The pinned Hermes host is reproducible in CI. It is a fork dependency, not a claim that any upstream version with the same version number includes these APIs.
|
|
25
|
+
|
|
26
|
+
Upstream Hermes was inspected at [`5d3c05977bb3c8b7cfd6b3e39d96f6e35a9e0662`](https://github.com/NousResearch/hermes-agent/commit/5d3c05977bb3c8b7cfd6b3e39d96f6e35a9e0662). Its public `inject_message` API cannot atomically pin a late reply to the original conversation across reset and compression. Gateway and TUI injection queue busy-session input rather than steering it. The plugin therefore rejects sends on that host instead of weakening its routing contract.
|
|
27
|
+
|
|
28
|
+
## What to expect
|
|
29
|
+
|
|
30
|
+
A Codex inbox binds on the session's first `list_peers` or `send_peer` call. Threads hosted in embedded servers, including sessions started with `-c` or `--no-daemon`, are not reachable through the shared daemon. A subagent sends under its root's address; replies return to the root. Native targets only refer to agents in the caller's own tree.
|
|
31
|
+
|
|
32
|
+
Claude's inbox protocol is undocumented. Inbound messages may be held for approval or rejected according to Claude settings. In particular, `bypassPermissions` does not imply accepting external messages. The package does not change that policy.
|
|
33
|
+
|
|
34
|
+
Hermes support is experimental. Tests verify plugin loading, profile isolation, and busy/idle replies to the originating Discord thread on the pinned fork. CLI and Desktop/TUI reset/compression behavior is not covered by this release's compatibility guarantee: the current fork has a Desktop idle-dispatch/reset race that requires a separate host fix. No upstream submission or host upgrade is part of installing this package.
|
|
35
|
+
|
|
36
|
+
Claude sessions may use different private socket directories on the same machine. Codex maintains a reply inbox in each verified directory; Hermes selects a private reply inbox in the destination's directory. Existing owned inboxes survive discovery changes, and a missing Codex inbox can be repaired on the next tool call without resetting the conversation.
|
|
37
|
+
|
|
38
|
+
Run the opt-in live checks after upgrading an agent. See [contribution guidance](../CONTRIBUTING.md) and [release verification](release-verification.md).
|
package/docs/hermes.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Hermes plugin (experimental)
|
|
2
|
+
|
|
3
|
+
The repository is a native Hermes plugin and an npm package. Both use the same Node transport. Hermes starts its own bridge subprocess lazily; a separate standalone daemon is unnecessary.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Use the [verified Hermes fork](compatibility.md#tested-agent-builds), Python 3.11–3.13, and Node.js 22+. Stock Hermes currently lacks the exact-conversation route APIs. Discovery remains available there, but sending returns an unsupported-host error.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
hermes plugins install ThomasMarcelis/agent-peers --enable
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For a reproducible release install, add `--ref` followed by the full 40-character commit from the [GitHub release](https://github.com/ThomasMarcelis/agent-peers/releases/tag/v0.1.0). Hermes does not accept tag names for this flag. Use `hermes --profile NAME plugins install ...` for a named profile. Current Hermes installers can offer to install the plugin's Node dependencies; accept that step. Older fork installers need a separate install in the plugin directory printed by Hermes:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm --prefix /absolute/plugin/directory ci --omit=dev --ignore-scripts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Replace `/absolute/plugin/directory` with the actual installed directory. The plugin reports the exact recovery command if Node or dependencies are unavailable. There are no install-time scripts in agent-peers.
|
|
22
|
+
|
|
23
|
+
Grant injection explicitly for each selected profile using Hermes configuration:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
hermes config set plugins.entries.agent-peers.allow_gateway_injection true
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This allows automatic peer messages to start or steer the original conversation. For messaging gateways, an agent response may be posted to its existing chat/thread. The plugin does not grant this permission itself. New hosts/conversations may be needed to load changed plugin tools; follow your host's normal restart workflow.
|
|
30
|
+
|
|
31
|
+
If saved platform tool selections exclude new plugins, enable the `agent-peers` toolset for the intended platform using `hermes tools`. Preserve other platform selections.
|
|
32
|
+
|
|
33
|
+
## Behavior
|
|
34
|
+
|
|
35
|
+
`list_peers()` returns Claude Code and Codex inboxes without publishing Hermes. The first `send_peer(to, message)` creates a private inbox for that conversation. Peers reply to its received address through their normal messaging tools.
|
|
36
|
+
|
|
37
|
+
- Every conversation and profile has an independent reply route.
|
|
38
|
+
- Busy conversations receive input through the host's steering boundary; idle conversations start a turn.
|
|
39
|
+
- Routes belong to the originating host conversation. Unload or host shutdown closes their inboxes; Hermes must send again to establish fresh return addresses.
|
|
40
|
+
- Discord replies return to the original thread. Switching chats does not change the return destination.
|
|
41
|
+
- There is no transcript scraping, history polling, offline mailbox, or automatic retry of an ambiguous send.
|
|
42
|
+
|
|
43
|
+
Discord gateway routing is the verified surface. CLI and Desktop/TUI lifecycle behavior remains experimental on the current fork; see the [host limitation](compatibility.md#what-to-expect).
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
An optional `bridge_command` list under `plugins.entries.agent-peers.settings` overrides the executable and arguments. Use absolute paths. Normal installs resolve Node and the bridge within the installed plugin; no checkout path is required.
|
|
48
|
+
|
|
49
|
+
The bridge inherits only environment needed for discovery and execution. Provider credentials are not passed to it. It reads Claude session registry metadata and agent-peers registry entries, communicates over local sockets, and keeps no transcript archive. Agent replies may themselves invoke the model providers configured in those agents.
|
|
50
|
+
|
|
51
|
+
## Update and remove
|
|
52
|
+
|
|
53
|
+
Use Hermes's native plugin manager to update or remove the plugin. For an explicitly pinned release, reinstall with the new release ref after reviewing its compatibility notes. The legacy Python installer is a compatibility helper; native installation is the supported path.
|
|
54
|
+
|
|
55
|
+
The optional generic Hermes host extension is separate from this package. It adds conversation-route capabilities for any plugin; peer discovery and messaging remain entirely here.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Reference
|
|
2
|
+
|
|
3
|
+
## CLI
|
|
4
|
+
|
|
5
|
+
| Command | Output |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `agent-peers list` | Sessions with exact Claude `SendMessage` and Codex `send_peer` targets |
|
|
8
|
+
| `agent-peers list --json` | `{ "peers": [...] }`; no status text mixed into stdout |
|
|
9
|
+
| `agent-peers doctor [--json]` | Runtime, dependency, and local path checks; does not modify configuration or send messages |
|
|
10
|
+
| `agent-peers --version` | Package version |
|
|
11
|
+
| `agent-peers --help` | Usage |
|
|
12
|
+
| `codex-peer` | Codex MCP server over stdio |
|
|
13
|
+
| `agent-peers hermes-bridge` | Plugin-managed JSON-lines subprocess |
|
|
14
|
+
|
|
15
|
+
Exit codes: `0` success, `1` runtime/diagnostic failure, `2` invalid CLI usage. Missing agent state is a diagnostic warning because running only one integration is valid. A present socket does not prove compatibility or model readiness.
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
`list_peers()` takes no arguments. Codex's structured result contains `peers`, each with its name, kind, working directory, available activity status, and native/bridge route. Native reachability is caller-relative: another root is bridge-only even on the same daemon. Discovery does not load historical Codex threads.
|
|
20
|
+
|
|
21
|
+
`send_peer({to, message})` accepts an exact peer name, unambiguous abbreviated name, or a valid inbox address. Prefer returned exact targets. Empty messages, ambiguous targets, self-sends, and invalid addresses fail clearly. Hermes host handlers return JSON text as required by its dispatcher; Codex uses MCP text content and error results.
|
|
22
|
+
|
|
23
|
+
A successful socket write confirms only inbox acceptance. Claude delivery receipts can report held/rejected input; not every recipient sends a receipt. No receipt is not proof of model consumption. Timed-out delivery is not automatically retried because it may already have arrived.
|
|
24
|
+
|
|
25
|
+
## Environment
|
|
26
|
+
|
|
27
|
+
| Variable | Purpose |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `CLAUDE_CONFIG_DIR` | Claude configuration directory used to locate session registry metadata |
|
|
30
|
+
| `CODEX_HOME` | Codex home used to locate its shared daemon |
|
|
31
|
+
| `AGENT_PEERS_HOME` | agent-peers registry directory |
|
|
32
|
+
| `AGENT_PEERS_CODEX_APP_SERVER` | Explicit absolute path to an app-server Unix socket |
|
|
33
|
+
| `AGENT_PEERS_LOG` | Optional absolute log file for `codex-peer` |
|
|
34
|
+
|
|
35
|
+
Defaults use the current OS user's standard agent directories. Every override should be absolute. Package-owned socket and registry state must have private ownership/permissions. The transport is local and same-user; it is not a network service.
|
|
36
|
+
|
|
37
|
+
## Hermes subprocess protocol
|
|
38
|
+
|
|
39
|
+
Requests are newline-delimited JSON objects `{id, method, params}`. Responses are `{id, result}` or `{id, error}`. Methods:
|
|
40
|
+
|
|
41
|
+
- `list_peers`: no parameters.
|
|
42
|
+
- `send_peer`: `session`, `name` (a `hermes:` identity), `to`, `message`.
|
|
43
|
+
- `close_session`: `session`.
|
|
44
|
+
- `shutdown`: close owned state and exit.
|
|
45
|
+
|
|
46
|
+
`peer_message` notifications identify the private session, message ID, sender, reply address, and body. Hermes creates no public registry entry. EOF and termination remove owned sockets. Standard output carries protocol data only; diagnostics go to standard error.
|
|
47
|
+
|
|
48
|
+
Messages use Claude's `<cross-session-message>` envelope. Incoming agent input is reframed with sender identity, a reply address, and a reminder that it carries no user approval. Closing tags in message bodies are escaped. A frame is bounded to 1,000,000 bytes. Resource bounds, token buckets, and duplicate suppression limit accidental floods; see the source constants for precise limits.
|
|
49
|
+
|
|
50
|
+
## Troubleshooting
|
|
51
|
+
|
|
52
|
+
- **No Codex inbox:** call `list_peers` in that session; check that it uses the shared daemon.
|
|
53
|
+
- **Wrong native target:** rediscover from the sending Codex session. A thread UUID alone does not establish native reachability.
|
|
54
|
+
- **Held Claude delivery:** review its inbound-message policy; agent-peers does not bypass it.
|
|
55
|
+
- **Hermes unsupported route:** use the documented host revision. Upstream `inject_message` is not an equivalent fallback.
|
|
56
|
+
- **Missing plugin dependencies:** run the exact install command in the plugin error, then reload through the host's supported lifecycle.
|
|
57
|
+
- **Permission failure:** inspect directory ownership and modes. Do not make the socket directory globally writable.
|
|
58
|
+
- **Old reply address:** ask the peer to discover or send again after the original conversation closes; addresses are not durable mailboxes.
|
|
59
|
+
|
|
60
|
+
## Updating active integrations
|
|
61
|
+
|
|
62
|
+
Keep production launchers pointed at a tested installed snapshot, separate from development checkouts. Running Node processes cache their modules: changing a file does not update an existing sidecar.
|
|
63
|
+
|
|
64
|
+
Use the agent host's supported MCP refresh with the new snapshot's launcher. Codex can preserve conversations and active work while replacing an MCP connection; an already captured tool step may still use the previous connection. A live legacy inbox owner blocks unsafe takeover. Verify the new registry owner, all inbox paths, and a real reply before considering an update complete.
|
|
65
|
+
|
|
66
|
+
Missing Codex inboxes repair on the next tool call only while the sidecar still owns its registry token. An inbox owned by a replacement is never reclaimed. A Hermes bridge restart retires its private reply addresses, so the Hermes conversation must send again to establish a fresh return route.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Release verification
|
|
2
|
+
|
|
3
|
+
Verified on 2026-10-03 for 0.1.0. Raw live transcripts are excluded from the repository and package.
|
|
4
|
+
|
|
5
|
+
## Automated checks
|
|
6
|
+
|
|
7
|
+
| Check | Result |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| JavaScript transport, MCP, discovery and CLI | 62 tests |
|
|
10
|
+
| Portable Python plugin and subprocess | 25 tests |
|
|
11
|
+
| Pinned Hermes loader and Discord routing | 5 tests |
|
|
12
|
+
| Packed npm artifact | 29 files; installed CLI and MCP handshake passed |
|
|
13
|
+
| Production dependency audit | 0 known vulnerabilities |
|
|
14
|
+
|
|
15
|
+
Regressions cover concurrent socket directories, destination-specific reply addresses, legacy sidecar takeover, missing-inbox repair, and an injected replacement race between validation and socket publication. An independent Opus 5.5 review identified lifecycle issues addressed by these changes.
|
|
16
|
+
|
|
17
|
+
All ten jobs in the [transport validation run](https://github.com/ThomasMarcelis/agent-peers/actions/runs/37149288716) passed: Linux/macOS on Node 22, 24 and 26, Python 3.11–3.13, and the pinned-Hermes integration. See [CI](https://github.com/ThomasMarcelis/agent-peers/actions/workflows/ci.yml) for the release commit. A credential-pattern scan of reachable Git history found no potential credentials.
|
|
18
|
+
|
|
19
|
+
## Agent compatibility
|
|
20
|
+
|
|
21
|
+
Live verification used Node 22.23.1, Codex CLI 0.160.0, and Claude Code 2.1.288 on Linux, with disposable sessions and isolated peer registries:
|
|
22
|
+
|
|
23
|
+
- All 12 directed request/reply routes among two Claude and two Codex sessions passed.
|
|
24
|
+
- All four sessions discovered their expected peers.
|
|
25
|
+
- Native Codex subagent discovery and messaging passed.
|
|
26
|
+
- All four Hermes bridge round trips passed: idle and busy Codex, and idle and busy Claude, with private-inbox lifecycle and discovery checks.
|
|
27
|
+
- A shared design task negotiated ownership, completed independent drafts, and coordinated a changed requirement. No peer messages were sent during the independent phase; this is an observation, not a quota.
|
|
28
|
+
|
|
29
|
+
Deterministic Discord tests exercise the real adapter path with fake model and network calls. They do not claim a production Discord API test. Host requirements and experimental Hermes surfaces are listed in [compatibility](compatibility.md).
|
|
30
|
+
|
|
31
|
+
The generic Hermes host extension remains separate and unsubmitted. Its local preparation does not make stock upstream Hermes supported.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Compatibility entrypoint for existing checkout symlinks; install the repo root."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_package = Path(__file__).resolve().parent.parent / "agent_peers"
|
|
8
|
+
_name = __name__ + "._implementation"
|
|
9
|
+
_spec = importlib.util.spec_from_file_location(_name, _package / "__init__.py",
|
|
10
|
+
submodule_search_locations=[str(_package)])
|
|
11
|
+
_module = importlib.util.module_from_spec(_spec)
|
|
12
|
+
sys.modules[_name] = _module
|
|
13
|
+
_spec.loader.exec_module(_module)
|
|
14
|
+
BridgeClient, PeerTools, register = _module.BridgeClient, _module.PeerTools, _module.register
|
|
15
|
+
__all__ = ["BridgeClient", "PeerTools", "register"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: agent-peers
|
|
2
|
+
version: "0.1.0"
|
|
3
|
+
description: "Discover and message local Claude Code and Codex sessions from an unlisted Hermes inbox."
|
|
4
|
+
manifest_version: 1
|
|
5
|
+
provides_tools:
|
|
6
|
+
- list_peers
|
|
7
|
+
- send_peer
|
|
8
|
+
provides_hooks:
|
|
9
|
+
- on_session_message_route_closed
|
|
10
|
+
config_schema:
|
|
11
|
+
bridge_command:
|
|
12
|
+
type: list
|
|
13
|
+
description: "Executable and arguments for the agent-peers hermes-bridge subprocess."
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-peers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local peer messaging for Claude Code, Codex, and Hermes agents",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Thomas Marcelis",
|
|
7
|
+
"homepage": "https://github.com/ThomasMarcelis/agent-peers#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ThomasMarcelis/agent-peers.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ThomasMarcelis/agent-peers/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"hermes",
|
|
17
|
+
"codex",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"mcp",
|
|
20
|
+
"agents",
|
|
21
|
+
"ipc"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"agent_peers/",
|
|
28
|
+
"__init__.py",
|
|
29
|
+
"plugin.yaml",
|
|
30
|
+
"hermes-plugin/",
|
|
31
|
+
"scripts/install-hermes-plugin.py",
|
|
32
|
+
"docs/",
|
|
33
|
+
"CLAUDE-snippet.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"after-install.md",
|
|
37
|
+
"!**/__pycache__/**",
|
|
38
|
+
"!**/*.pyc",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
"SECURITY.md"
|
|
41
|
+
],
|
|
42
|
+
"os": [
|
|
43
|
+
"linux",
|
|
44
|
+
"darwin"
|
|
45
|
+
],
|
|
46
|
+
"bin": {
|
|
47
|
+
"agent-peers": "bin/agent-peers.mjs",
|
|
48
|
+
"codex-peer": "src/codex-peer.mjs"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"check": "node scripts/check.mjs",
|
|
55
|
+
"test": "node --test test/discovery.mjs test/hermes-bridge.mjs test/wire.mjs test/app-server.mjs test/codex-peer.mjs test/cli.mjs",
|
|
56
|
+
"test:python": "python3 -m pytest -q",
|
|
57
|
+
"test:package": "node scripts/test-package.mjs",
|
|
58
|
+
"test:live": "node test/live-matrix.mjs",
|
|
59
|
+
"test:live:hermes": "node test/hermes-live.mjs",
|
|
60
|
+
"prepublishOnly": "npm run check && npm test"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@modelcontextprotocol/sdk": "^1",
|
|
64
|
+
"ws": "^8",
|
|
65
|
+
"zod": "^4"
|
|
66
|
+
}
|
|
67
|
+
}
|