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/plugin.yaml ADDED
@@ -0,0 +1,20 @@
1
+ name: agent-peers
2
+ version: "0.1.0"
3
+ description: "Discover and message local Claude Code and Codex sessions with private conversation reply inboxes."
4
+ manifest_version: 1
5
+ license: MIT
6
+ homepage: https://github.com/ThomasMarcelis/agent-peers
7
+ tags: [agents, coordination, local]
8
+ provides_tools:
9
+ - list_peers
10
+ - send_peer
11
+ # Optional host extension: registered only when both conversation APIs exist.
12
+ # Automatic replies require session_message_route + inject_session_message and
13
+ # the explicit plugins.entries.agent-peers.allow_gateway_injection grant.
14
+ # Hermes does not currently define a declarable capability for this grant.
15
+ provides_hooks:
16
+ - on_session_message_route_closed
17
+ config_schema:
18
+ bridge_command:
19
+ type: list
20
+ description: "Optional executable/argument list overriding the bundled Node bridge."
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env python3
2
+ """Compatibility launcher for the native Hermes plugin installer.
3
+
4
+ Delegates installation and enablement to Hermes without granting injection permission.
5
+ Use the native manager directly for updates, uninstall, and pinned revisions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import shutil
12
+ import subprocess
13
+
14
+
15
+ def main(argv=None):
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ parser.add_argument("source", nargs="?", default="https://github.com/ThomasMarcelis/agent-peers")
18
+ parser.add_argument("--profile", help="Explicit Hermes profile selected by the native manager")
19
+ parser.add_argument("--ref", help="Exact release commit to pin with the native manager")
20
+ args = parser.parse_args(argv)
21
+ hermes = shutil.which("hermes")
22
+ if not hermes:
23
+ parser.error("Hermes is not on PATH; install Hermes, then use its native plugins install command")
24
+ command = [hermes]
25
+ if args.profile:
26
+ command += ["--profile", args.profile]
27
+ command += ["plugins", "install", args.source, "--enable"]
28
+ if args.ref:
29
+ command += ["--ref", args.ref]
30
+ return subprocess.call(command)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ raise SystemExit(main())
@@ -0,0 +1,137 @@
1
+ // Minimal JSON-RPC client for the Codex app-server that hosts interactive Codex threads.
2
+ // Connections may reconnect after failure, but requests are never replayed: turn/start may
3
+ // already have taken effect even when the acknowledgement is lost.
4
+ import { VERSION } from "./version.mjs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import WebSocket from "ws";
8
+
9
+ export const daemonSocket = () =>
10
+ process.env.AGENT_PEERS_CODEX_APP_SERVER ||
11
+ join(process.env.CODEX_HOME || join(homedir(), ".codex"), "app-server-control", "app-server-control.sock");
12
+
13
+ export class AppServer {
14
+ #connection;
15
+ #closed = false;
16
+ #next = 0;
17
+ #onNotification;
18
+ #options;
19
+
20
+ constructor(onNotification, options = {}) {
21
+ this.#onNotification = onNotification;
22
+ this.#options = { handshakeTimeoutMs: 10_000, connectTimeoutMs: 5000, ...options };
23
+ }
24
+
25
+ async request(method, params, timeoutMs = 15_000) {
26
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("invalid app-server request timeout");
27
+ const connection = this.#connect();
28
+ await connection.ready;
29
+ if (connection.failed || this.#closed) throw new Error("app-server connection closed");
30
+ if (connection.pending.size >= 256) throw new Error("too many pending app-server requests");
31
+ return this.#request(connection, method, params, timeoutMs);
32
+ }
33
+
34
+ #request(connection, method, params, timeoutMs) {
35
+ return new Promise((resolve, reject) => {
36
+ const id = ++this.#next;
37
+ const settle = (error, result) => {
38
+ clearTimeout(timer);
39
+ connection.pending.delete(id);
40
+ error ? reject(error) : resolve(result);
41
+ };
42
+ const timer = setTimeout(() => settle(new Error(`app-server ${method} timed out`)), timeoutMs);
43
+ connection.pending.set(id, { resolve: (value) => settle(null, value), reject: (error) => settle(error) });
44
+ try {
45
+ connection.ws.send(JSON.stringify({ id, method, params }), (error) => {
46
+ if (error) connection.fail(error);
47
+ });
48
+ } catch (error) { connection.fail(error); }
49
+ });
50
+ }
51
+
52
+ #connect() {
53
+ if (this.#closed) throw new Error("app-server client is closed");
54
+ if (this.#connection) return this.#connection;
55
+ const connection = { pending: new Map(), failed: false };
56
+ this.#connection = connection;
57
+ let resolveReady, rejectReady;
58
+ connection.ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
59
+ connection.fail = (error) => {
60
+ if (connection.failed) return;
61
+ connection.failed = true;
62
+ if (this.#connection === connection) this.#connection = undefined;
63
+ for (const pending of connection.pending.values()) pending.reject(error);
64
+ connection.pending.clear();
65
+ rejectReady(error);
66
+ connection.ws?.terminate();
67
+ };
68
+ try {
69
+ const ws = connection.ws = new WebSocket(`ws+unix://${daemonSocket()}:/`, {
70
+ handshakeTimeout: this.#options.connectTimeoutMs, maxPayload: 4 * 1024 * 1024,
71
+ });
72
+ ws.on("open", async () => {
73
+ try {
74
+ await this.#request(connection, "initialize", {
75
+ clientInfo: { name: "agent_peers", title: "agent-peers", version: VERSION },
76
+ }, this.#options.handshakeTimeoutMs);
77
+ if (connection.failed || this.#closed) return;
78
+ ws.send(JSON.stringify({ method: "initialized" }), (error) => {
79
+ if (error) connection.fail(error);
80
+ else resolveReady();
81
+ });
82
+ } catch (error) { connection.fail(error); }
83
+ });
84
+ ws.on("message", (data) => {
85
+ let message;
86
+ try {
87
+ message = JSON.parse(data.toString());
88
+ if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error();
89
+ } catch { connection.fail(new Error("invalid app-server JSON response")); return; }
90
+ if (typeof message.method === "string") {
91
+ try { this.#onNotification?.(message); } catch { /* Consumer callbacks cannot break RPC cleanup. */ }
92
+ return;
93
+ }
94
+ const pending = connection.pending.get(message.id);
95
+ if (!pending) return;
96
+ if (message.error && typeof message.error === "object") {
97
+ pending.reject(Object.assign(new Error(String(message.error.message ?? "app-server request failed")), { code: message.error.code }));
98
+ } else if (Object.hasOwn(message, "result")) pending.resolve(message.result);
99
+ else connection.fail(new Error("invalid app-server RPC response"));
100
+ });
101
+ ws.on("error", connection.fail);
102
+ ws.on("close", () => connection.fail(new Error("app-server connection closed")));
103
+ } catch (error) { connection.fail(error); }
104
+ return connection;
105
+ }
106
+
107
+ close() {
108
+ this.#closed = true;
109
+ this.#connection?.fail(new Error("app-server client is closed"));
110
+ }
111
+
112
+ async loadedThreads() {
113
+ const ids = [], cursors = new Set();
114
+ let cursor;
115
+ do {
116
+ const result = await this.request("thread/loaded/list", cursor ? { cursor } : {});
117
+ if (!result || !Array.isArray(result.data) || result.data.some((id) => typeof id !== "string")) {
118
+ throw new Error("invalid app-server loaded-thread list");
119
+ }
120
+ ids.push(...result.data);
121
+ cursor = result.nextCursor;
122
+ if (ids.length > 10_000 || cursors.size >= 1000 || (cursor && (typeof cursor !== "string" || cursors.has(cursor)))) {
123
+ throw new Error("invalid app-server loaded-thread pagination");
124
+ }
125
+ if (cursor) cursors.add(cursor);
126
+ } while (cursor);
127
+ return [...new Set(ids)];
128
+ }
129
+
130
+ async readThread(threadId) {
131
+ const result = await this.request("thread/read", { threadId });
132
+ if (!result?.thread || typeof result.thread !== "object" || Array.isArray(result.thread)) {
133
+ throw new Error("invalid app-server thread response");
134
+ }
135
+ return result.thread;
136
+ }
137
+ }