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
package/src/wire.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Claude Code's cross-session inbox protocol, spoken by every agent-peers participant.
|
|
2
|
+
//
|
|
3
|
+
// One newline-delimited JSON line per message on a Unix socket in Claude's socket
|
|
4
|
+
// directory. The content is a <cross-session-message> envelope whose `from` is the
|
|
5
|
+
// sender's own socket, so the receiver replies by writing to that address.
|
|
6
|
+
// Claude Code does not document this line format; test/contract.mjs pins it.
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from "node:fs";
|
|
10
|
+
import { connect } from "node:net";
|
|
11
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const TAG = "cross-session-message";
|
|
15
|
+
export const MAX_LINE = 1_000_000;
|
|
16
|
+
export const MAX_SOCKET_PATH = 103;
|
|
17
|
+
const uid = userInfo().uid;
|
|
18
|
+
|
|
19
|
+
export const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
20
|
+
export const peersHome = () => process.env.AGENT_PEERS_HOME || join(homedir(), ".agent-peers");
|
|
21
|
+
|
|
22
|
+
// Process group identifiers (0 and negative numbers) are never peer identities.
|
|
23
|
+
export function alive(pid) {
|
|
24
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fffffff) return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return e.code === "EPERM";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeDirectory(path, privateOnly = false) {
|
|
34
|
+
const st = lstatSync(path);
|
|
35
|
+
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== uid || (st.mode & (privateOnly ? 0o077 : 0o022))) {
|
|
36
|
+
throw new Error(`unsafe peer directory ${path}: expected an owned ${privateOnly ? "0700 " : ""}directory without symlinks or shared write access`);
|
|
37
|
+
}
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ensurePrivateDir(path) {
|
|
42
|
+
if (typeof path !== "string" || !isAbsolute(path) || normalize(path) !== path) {
|
|
43
|
+
throw new Error("peer directory must be an absolute normalized path");
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
46
|
+
return safeDirectory(path, true);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read registry files without following a substituted symlink or allocating an unbounded file.
|
|
50
|
+
export function readPeerRecord(path) {
|
|
51
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
52
|
+
try {
|
|
53
|
+
const st = fstatSync(fd);
|
|
54
|
+
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.size > 65_536) throw new Error("unsafe peer registry file");
|
|
55
|
+
const buffer = Buffer.allocUnsafe(65_537);
|
|
56
|
+
let bytes = 0, count;
|
|
57
|
+
while (bytes < buffer.length && (count = readSync(fd, buffer, bytes, buffer.length - bytes, null))) bytes += count;
|
|
58
|
+
if (bytes > 65_536) throw new Error("peer registry file is too large");
|
|
59
|
+
const value = JSON.parse(buffer.toString("utf8", 0, bytes));
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid peer registry record");
|
|
61
|
+
return value;
|
|
62
|
+
} finally {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const safeText = (s, limit = 512) => typeof s === "string" && s.length > 0 && s.length <= limit && !/[\x00-\x1f\x7f]/.test(s);
|
|
68
|
+
const validSocketPath = (path) => typeof path === "string" && Buffer.byteLength(path) <= MAX_SOCKET_PATH && isAbsolute(path) && normalize(path) === path &&
|
|
69
|
+
path.endsWith(".sock") && !/[\x00-\x1f\x7f]/.test(path);
|
|
70
|
+
const validPid = (pid) => Number.isInteger(pid) && pid > 0 && pid <= 0x7fffffff;
|
|
71
|
+
export const validThreadId = (id) => typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
72
|
+
|
|
73
|
+
function claudeRecords() {
|
|
74
|
+
const dir = join(claudeHome(), "sessions");
|
|
75
|
+
let files;
|
|
76
|
+
try { safeDirectory(dir); files = readdirSync(dir); } catch { return []; }
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const f of files.filter((f) => /^[1-9]\d*\.json$/.test(f))) {
|
|
79
|
+
try {
|
|
80
|
+
const s = readPeerRecord(join(dir, f));
|
|
81
|
+
if (!validPid(s.pid) || String(s.pid) !== f.slice(0, -5) || !alive(s.pid) || !validSocketPath(s.messagingSocketPath)) continue;
|
|
82
|
+
if (s.name !== undefined && !safeText(s.name, 128)) continue;
|
|
83
|
+
if (s.cwd !== undefined && !safeText(s.cwd, 4096)) continue;
|
|
84
|
+
safeDirectory(dirname(s.messagingSocketPath), true);
|
|
85
|
+
out.push(s);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The preferred directory is only for NEW inboxes. It must never invalidate an address
|
|
92
|
+
// created in another directory: Claude versions and launch environments may coexist.
|
|
93
|
+
function defaultSocketDir() {
|
|
94
|
+
const base = process.env.XDG_RUNTIME_DIR || tmpdir();
|
|
95
|
+
const dir = join(base, "cc-socks");
|
|
96
|
+
return Buffer.byteLength(join(dir, `${process.pid}.sock`)) <= MAX_SOCKET_PATH ? dir : `/tmp/cc-socks-${uid}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function standardSocketDirectories() {
|
|
100
|
+
return new Set([
|
|
101
|
+
defaultSocketDir(), join(tmpdir(), "cc-socks"), "/tmp/cc-socks", `/tmp/cc-socks-${uid}`,
|
|
102
|
+
`/run/user/${uid}/cc-socks`,
|
|
103
|
+
...(process.env.XDG_RUNTIME_DIR ? [join(process.env.XDG_RUNTIME_DIR, "cc-socks")] : []),
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Hermes reply inboxes deliberately have no registry. Remember directories authorized by
|
|
108
|
+
// this profile's Claude records so their replies still work after a Claude registry expires.
|
|
109
|
+
// Keep the cache scoped to the configured registries, and recheck ownership and inode on use.
|
|
110
|
+
let directoryScope;
|
|
111
|
+
const observedDirectories = new Map();
|
|
112
|
+
function refreshDirectoryScope() {
|
|
113
|
+
const scope = JSON.stringify([resolve(claudeHome()), resolve(peersHome()), process.env.XDG_RUNTIME_DIR, tmpdir()]);
|
|
114
|
+
if (directoryScope !== scope) { directoryScope = scope; observedDirectories.clear(); }
|
|
115
|
+
}
|
|
116
|
+
function verifiedSocketDirectories() {
|
|
117
|
+
refreshDirectoryScope();
|
|
118
|
+
const live = new Set(claudeRecords().map((session) => dirname(session.messagingSocketPath)));
|
|
119
|
+
for (const dir of live) {
|
|
120
|
+
try {
|
|
121
|
+
safeDirectory(dir, true);
|
|
122
|
+
const st = lstatSync(dir);
|
|
123
|
+
if (observedDirectories.has(dir) || observedDirectories.size < 256) {
|
|
124
|
+
observedDirectories.set(dir, { dev: st.dev, ino: st.ino });
|
|
125
|
+
}
|
|
126
|
+
} catch { live.delete(dir); }
|
|
127
|
+
}
|
|
128
|
+
return live;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Enumerate only directories verified in this registry scope. Standard host directories
|
|
132
|
+
// remain valid explicit destinations, but must not create cross-profile eager aliases.
|
|
133
|
+
export function socketDirectories() {
|
|
134
|
+
const live = verifiedSocketDirectories();
|
|
135
|
+
return [...new Set([...live, ...observedDirectories.keys()])].filter(permittedSocketDirectory);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function socketDir({ create = true } = {}) {
|
|
139
|
+
const live = verifiedSocketDirectories();
|
|
140
|
+
if (live.size) return live.values().next().value;
|
|
141
|
+
const dir = defaultSocketDir();
|
|
142
|
+
if (create) return ensurePrivateDir(dir);
|
|
143
|
+
try { safeDirectory(dir, true); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
144
|
+
return dir;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function permittedSocketDirectory(dir) {
|
|
148
|
+
const live = verifiedSocketDirectories();
|
|
149
|
+
try {
|
|
150
|
+
safeDirectory(dir, true);
|
|
151
|
+
if (standardSocketDirectories().has(dir) || live.has(dir)) return true;
|
|
152
|
+
const previous = observedDirectories.get(dir), current = lstatSync(dir);
|
|
153
|
+
return previous?.dev === current.dev && previous?.ino === current.ino;
|
|
154
|
+
} catch { return false; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Claude's address encoding: percent-encode everything outside [A-Za-z0-9:_/.\-].
|
|
158
|
+
export const address = (path) =>
|
|
159
|
+
"uds:" + path.replace(/[^A-Za-z0-9:_/.\\-]/gu, (c) =>
|
|
160
|
+
[...Buffer.from(c)].map((b) => "%" + b.toString(16).toUpperCase().padStart(2, "0")).join(""));
|
|
161
|
+
|
|
162
|
+
// Missing paths remain valid reply addresses; a discovered or connected existing path must be a socket.
|
|
163
|
+
export function peerSocket(addr) {
|
|
164
|
+
if (typeof addr !== "string" || !/^uds:[A-Za-z0-9%:_/.\\-]{1,1000}$/.test(addr)) return undefined;
|
|
165
|
+
const path = socketPath(addr);
|
|
166
|
+
if (!validSocketPath(path) || address(path) !== addr || !permittedSocketDirectory(dirname(path))) return undefined;
|
|
167
|
+
try {
|
|
168
|
+
const st = lstatSync(path);
|
|
169
|
+
if (!st.isSocket() || st.isSymbolicLink() || st.uid !== uid) return undefined;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e.code !== "ENOENT") return undefined;
|
|
172
|
+
}
|
|
173
|
+
return path;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function socketPath(addr) {
|
|
177
|
+
if (typeof addr !== "string" || !addr.startsWith("uds:")) return undefined;
|
|
178
|
+
try { return decodeURIComponent(addr.slice(4)); } catch { return undefined; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Live Claude sessions from Claude's public session registry (never its key files).
|
|
182
|
+
export function claudeSessions() {
|
|
183
|
+
return claudeRecords().flatMap((s) => {
|
|
184
|
+
if (!peerSocket(address(s.messagingSocketPath))) return [];
|
|
185
|
+
return [{ kind: "claude", name: `claude:${s.name ?? s.pid}`, address: address(s.messagingSocketPath),
|
|
186
|
+
socket: s.messagingSocketPath, cwd: s.cwd, status: safeText(s.status, 64) ? s.status : undefined, pid: s.pid }];
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function isSafePeerRecord(p) {
|
|
191
|
+
return !!p && validPid(p.pid) && safeText(p.name, 160) && p.name.startsWith("codex:") &&
|
|
192
|
+
validThreadId(p.threadId) && (p.cwd === undefined || safeText(p.cwd, 4096)) && !!peerSocket(p.address);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function codexPeers() {
|
|
196
|
+
const dir = resolve(peersHome());
|
|
197
|
+
let files;
|
|
198
|
+
try { safeDirectory(dir, true); files = readdirSync(dir); } catch { return []; }
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const f of files.filter((f) => /^codex-[A-Za-z0-9_-]+\.json$/.test(f))) {
|
|
201
|
+
try {
|
|
202
|
+
const p = readPeerRecord(join(dir, f));
|
|
203
|
+
if (!isSafePeerRecord(p) || !alive(p.pid)) continue;
|
|
204
|
+
out.push({ kind: "codex", name: p.name, address: p.address, socket: peerSocket(p.address), cwd: p.cwd,
|
|
205
|
+
threadId: p.threadId, pid: p.pid, startedAt: p.startedAt,
|
|
206
|
+
addresses: Array.isArray(p.addresses) ? p.addresses.slice(0, 16).filter((addr) => peerSocket(addr)) : [p.address] });
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const allPeers = () => [...claudeSessions(), ...codexPeers()];
|
|
213
|
+
|
|
214
|
+
// Claude rejects an envelope unless it re-renders byte for byte, and its renderer
|
|
215
|
+
// escapes any closing tag in the body as `<\`. Do the same, tolerating the
|
|
216
|
+
// separators and invisible fillers Claude's matcher tolerates.
|
|
217
|
+
const OPEN = "<<﹤‹〈⟨❮˂";
|
|
218
|
+
const SLASH = "/∕⁄/⧸";
|
|
219
|
+
const FILL = "[\\s\\p{Cf}]*";
|
|
220
|
+
const closing = new RegExp(
|
|
221
|
+
`[${OPEN}](?!\\\\)(?=${FILL}[${SLASH}]${FILL}` +
|
|
222
|
+
[...TAG].map((c) => (c === "-" ? "[-_\\p{Pc}\\u2017\\u02cd\\u07fa\\u0640]" : c)).join(FILL) + ")",
|
|
223
|
+
"giu",
|
|
224
|
+
);
|
|
225
|
+
export const escapeBody = (body) => body.replace(closing, "<\\");
|
|
226
|
+
|
|
227
|
+
const clean = (s) => s.replace(/[^A-Za-z0-9:_-]/g, "-").slice(0, 64);
|
|
228
|
+
|
|
229
|
+
export function envelope({ from, fromName, fromMode, body }) {
|
|
230
|
+
const attrs = [`from="${from}"`];
|
|
231
|
+
if (fromName) attrs.push(`from-name="${clean(fromName)}"`);
|
|
232
|
+
if (fromMode) attrs.push(`from-mode="${fromMode}"`);
|
|
233
|
+
return `<${TAG} ${attrs.join(" ")}>\n${escapeBody(body)}\n</${TAG}>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function parseEnvelope(content) {
|
|
237
|
+
const m = new RegExp(`^<${TAG}((?: [a-z-]+="[^"<>\\n\\r]*")*)>\\n([\\s\\S]*)\\n</${TAG}>$`).exec(content);
|
|
238
|
+
if (!m) return undefined;
|
|
239
|
+
const attrs = Object.fromEntries([...m[1].matchAll(/ ([a-z-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
240
|
+
return { from: attrs.from, fromName: attrs["from-name"], fromMode: attrs["from-mode"], body: m[2] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function messageLine({ from, fromName, fromMode, body }) {
|
|
244
|
+
return {
|
|
245
|
+
msgV: 1,
|
|
246
|
+
msg_id: randomUUID(),
|
|
247
|
+
type: "user",
|
|
248
|
+
message: { role: "user", content: envelope({ from, fromName, fromMode, body }) },
|
|
249
|
+
priority: "next",
|
|
250
|
+
from,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write one line to a peer inbox. Open the connection only once the line is ready:
|
|
255
|
+
// Claude closes connections that send no complete line within 30 seconds.
|
|
256
|
+
export function post(socket, line) {
|
|
257
|
+
if (typeof socket !== "string" || peerSocket(address(socket)) !== socket) {
|
|
258
|
+
return Promise.reject(new Error("invalid peer inbox socket"));
|
|
259
|
+
}
|
|
260
|
+
const data = JSON.stringify(line) + "\n";
|
|
261
|
+
const bytes = Buffer.byteLength(data);
|
|
262
|
+
if (bytes > MAX_LINE) return Promise.reject(new Error(`message too large (${bytes} bytes)`));
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const c = connect(socket);
|
|
265
|
+
let settled = false;
|
|
266
|
+
const finish = (error) => {
|
|
267
|
+
if (settled) return;
|
|
268
|
+
settled = true;
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
c.destroy();
|
|
271
|
+
error ? reject(error) : resolve();
|
|
272
|
+
};
|
|
273
|
+
const timer = setTimeout(() => finish(new Error("peer inbox did not accept the message within 5 s")), 5000);
|
|
274
|
+
c.on("error", finish);
|
|
275
|
+
c.on("connect", () => c.end(data, () => finish()));
|
|
276
|
+
c.on("close", () => finish(new Error("peer inbox closed before accepting the message")));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Count bytes per frame, including its newline, rather than per incoming chunk. Buffering bytes
|
|
281
|
+
// until the complete line also preserves UTF-8 codepoints split across arbitrary socket writes.
|
|
282
|
+
export function readLines(conn, onLine) {
|
|
283
|
+
let buffer = Buffer.allocUnsafe(4096), bytes = 0;
|
|
284
|
+
conn.setTimeout(30_000, () => conn.destroy());
|
|
285
|
+
conn.on("data", (data) => {
|
|
286
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
287
|
+
let offset = 0;
|
|
288
|
+
while (offset < chunk.length) {
|
|
289
|
+
const end = chunk.indexOf(10, offset);
|
|
290
|
+
const stop = end < 0 ? chunk.length : end + 1;
|
|
291
|
+
const part = chunk.subarray(offset, stop);
|
|
292
|
+
const nextBytes = bytes + part.length;
|
|
293
|
+
if (nextBytes > MAX_LINE || (end < 0 && nextBytes >= MAX_LINE)) return conn.destroy();
|
|
294
|
+
if (nextBytes > buffer.length) {
|
|
295
|
+
const grown = Buffer.allocUnsafe(Math.min(MAX_LINE, Math.max(nextBytes, buffer.length * 2)));
|
|
296
|
+
buffer.copy(grown, 0, 0, bytes);
|
|
297
|
+
buffer = grown;
|
|
298
|
+
}
|
|
299
|
+
part.copy(buffer, bytes);
|
|
300
|
+
bytes = nextBytes;
|
|
301
|
+
offset = stop;
|
|
302
|
+
if (end < 0) break;
|
|
303
|
+
const raw = buffer.toString("utf8", 0, bytes);
|
|
304
|
+
bytes = 0;
|
|
305
|
+
if (!raw.trim()) continue;
|
|
306
|
+
try { onLine(JSON.parse(raw)); } catch {}
|
|
307
|
+
if (conn.destroyed) return;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
conn.on("error", () => {});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export const slug = (s) => basename(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|