libp2p-mesh 2026.5.18 → 2026.5.20
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/src/agent-tools.d.ts +12 -5
- package/dist/src/agent-tools.js +44 -13
- package/dist/src/mesh.js +22 -0
- package/dist/src/peer-identity.d.ts +10 -2
- package/dist/src/peer-identity.js +61 -1
- package/dist/src/plugin.js +18 -5
- package/dist/src/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/agent-tools.ts +49 -14
- package/src/mesh.ts +16 -0
- package/src/peer-identity.ts +69 -2
- package/src/plugin.ts +28 -5
- package/src/types.ts +2 -0
|
@@ -310,11 +310,18 @@ export declare function buildP2PTools(mesh: MeshNetwork): ({
|
|
|
310
310
|
text: string;
|
|
311
311
|
}[];
|
|
312
312
|
details: {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
localPeerId: string;
|
|
314
|
+
localInstanceId: any;
|
|
315
|
+
connectedPeers: number;
|
|
316
|
+
knownPeers: {
|
|
317
|
+
peerId: string;
|
|
318
|
+
agentId: string;
|
|
319
|
+
channel: string;
|
|
320
|
+
instanceId?: string;
|
|
321
|
+
sessionKey: string;
|
|
322
|
+
status: string;
|
|
323
|
+
lastSeen?: number;
|
|
324
|
+
}[];
|
|
318
325
|
};
|
|
319
326
|
}>;
|
|
320
327
|
})[];
|
package/dist/src/agent-tools.js
CHANGED
|
@@ -278,28 +278,59 @@ export function buildP2PTools(mesh) {
|
|
|
278
278
|
{
|
|
279
279
|
name: "p2p_relay_status",
|
|
280
280
|
label: "P2P Relay Status",
|
|
281
|
-
description: "Get
|
|
281
|
+
description: "Get full P2P mesh identity table: local node info, all known peers (connected or not), their instance IDs, channels, and last seen time.",
|
|
282
282
|
parameters: { type: "object", properties: {} },
|
|
283
283
|
async execute(_toolCallId, _params, _ctx) {
|
|
284
284
|
const peerIdentityMap = this.peerIdentityMap;
|
|
285
285
|
const localPeerId = mesh.getLocalPeerId();
|
|
286
286
|
const connectedPeers = mesh.getConnectedPeers();
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
const connectedSet = new Set(connectedPeers);
|
|
288
|
+
// Build full identity table from all known peers (not just connected)
|
|
289
|
+
const allPeers = [];
|
|
290
|
+
for (const [peerId, identity] of peerIdentityMap?.entries() ?? []) {
|
|
291
|
+
if (peerId === localPeerId)
|
|
292
|
+
continue; // skip local in peer list
|
|
293
|
+
allPeers.push({
|
|
294
|
+
peerId,
|
|
294
295
|
agentId: identity.agentId,
|
|
295
296
|
channel: identity.channel,
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
297
|
+
instanceId: identity.instanceId,
|
|
298
|
+
sessionKey: identity.sessionKey,
|
|
299
|
+
status: connectedSet.has(peerId) ? "connected" : "known",
|
|
300
|
+
lastSeen: identity.lastSeen,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// Local identity
|
|
304
|
+
const localIdentity = peerIdentityMap?.getLocalIdentity();
|
|
305
|
+
const localInstanceId = localIdentity?.instanceId ?? "(not set)";
|
|
306
|
+
// Format output
|
|
307
|
+
const lines = [];
|
|
308
|
+
lines.push(`P2P Mesh Identity Table`);
|
|
309
|
+
lines.push(`Local: ${localPeerId.slice(0, 16)}... | Instance: ${localInstanceId}`);
|
|
310
|
+
lines.push(`Connected: ${connectedPeers.length} | Known total: ${allPeers.length}`);
|
|
311
|
+
lines.push("");
|
|
312
|
+
if (allPeers.length === 0) {
|
|
313
|
+
lines.push("(no known peers yet)");
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
for (const p of allPeers) {
|
|
317
|
+
const shortId = p.peerId.slice(0, 16) + "...";
|
|
318
|
+
const time = p.lastSeen ? new Date(p.lastSeen).toLocaleTimeString() : "unknown";
|
|
319
|
+
lines.push(` [${p.status}] ${shortId}`);
|
|
320
|
+
lines.push(` Agent: ${p.agentId} | Channel: ${p.channel}`);
|
|
321
|
+
lines.push(` Instance: ${p.instanceId ?? "unknown"}`);
|
|
322
|
+
lines.push(` Last seen: ${time}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const text = lines.join("\n");
|
|
300
326
|
return {
|
|
301
327
|
content: [{ type: "text", text }],
|
|
302
|
-
details: {
|
|
328
|
+
details: {
|
|
329
|
+
localPeerId,
|
|
330
|
+
localInstanceId,
|
|
331
|
+
connectedPeers: connectedPeers.length,
|
|
332
|
+
knownPeers: allPeers,
|
|
333
|
+
},
|
|
303
334
|
};
|
|
304
335
|
},
|
|
305
336
|
},
|
package/dist/src/mesh.js
CHANGED
|
@@ -77,6 +77,8 @@ export function createMeshNetwork(options) {
|
|
|
77
77
|
const seenMessages = new Set();
|
|
78
78
|
const messageHandlers = new Set();
|
|
79
79
|
const topicHandlers = new Map();
|
|
80
|
+
const peerConnectHandlers = new Set();
|
|
81
|
+
const peerDisconnectHandlers = new Set();
|
|
80
82
|
function getDHTService() {
|
|
81
83
|
return state.node?.services?.dht;
|
|
82
84
|
}
|
|
@@ -215,10 +217,22 @@ export function createMeshNetwork(options) {
|
|
|
215
217
|
state.node.addEventListener("peer:connect", (evt) => {
|
|
216
218
|
const peerIdStr = evt.detail.toString();
|
|
217
219
|
logger?.info?.(`[libp2p-mesh] Peer connected: ${peerIdStr}`);
|
|
220
|
+
for (const handler of peerConnectHandlers) {
|
|
221
|
+
try {
|
|
222
|
+
handler(peerIdStr);
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}
|
|
218
226
|
});
|
|
219
227
|
state.node.addEventListener("peer:disconnect", (evt) => {
|
|
220
228
|
const peerIdStr = evt.detail.toString();
|
|
221
229
|
logger?.info?.(`[libp2p-mesh] Peer disconnected: ${peerIdStr}`);
|
|
230
|
+
for (const handler of peerDisconnectHandlers) {
|
|
231
|
+
try {
|
|
232
|
+
handler(peerIdStr);
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
}
|
|
222
236
|
});
|
|
223
237
|
await state.node.handle(PROTOCOL, async ({ stream, connection }) => {
|
|
224
238
|
try {
|
|
@@ -580,6 +594,14 @@ export function createMeshNetwork(options) {
|
|
|
580
594
|
stop,
|
|
581
595
|
sendToPeer,
|
|
582
596
|
onMessage,
|
|
597
|
+
onPeerConnect(handler) {
|
|
598
|
+
peerConnectHandlers.add(handler);
|
|
599
|
+
return () => { peerConnectHandlers.delete(handler); };
|
|
600
|
+
},
|
|
601
|
+
onPeerDisconnect(handler) {
|
|
602
|
+
peerDisconnectHandlers.add(handler);
|
|
603
|
+
return () => { peerDisconnectHandlers.delete(handler); };
|
|
604
|
+
},
|
|
583
605
|
publishToTopic,
|
|
584
606
|
subscribeToTopic,
|
|
585
607
|
getLocalPeerId,
|
|
@@ -3,8 +3,12 @@ export interface PeerIdentity {
|
|
|
3
3
|
channel: string;
|
|
4
4
|
accountId: string;
|
|
5
5
|
sessionKey: string;
|
|
6
|
-
/** Human-readable Instance ID
|
|
6
|
+
/** Human-readable Instance ID */
|
|
7
7
|
instanceId?: string;
|
|
8
|
+
/** When this identity was first registered (epoch ms) */
|
|
9
|
+
firstSeen?: number;
|
|
10
|
+
/** Last time we saw this peer (epoch ms) */
|
|
11
|
+
lastSeen?: number;
|
|
8
12
|
}
|
|
9
13
|
export interface PeerIdentityMap {
|
|
10
14
|
register(peerId: string, identity: PeerIdentity): void;
|
|
@@ -19,5 +23,9 @@ export interface PeerIdentityMap {
|
|
|
19
23
|
getLocalIdentity(): PeerIdentity | undefined;
|
|
20
24
|
hasIdentity(peerId: string): boolean;
|
|
21
25
|
entries(): IterableIterator<[string, PeerIdentity]>;
|
|
26
|
+
/** Persist current state to disk */
|
|
27
|
+
save(): Promise<void>;
|
|
22
28
|
}
|
|
23
|
-
export declare function createPeerIdentityMap(): PeerIdentityMap
|
|
29
|
+
export declare function createPeerIdentityMap(storePath?: string): PeerIdentityMap & {
|
|
30
|
+
load(): Promise<void>;
|
|
31
|
+
};
|
|
@@ -1,14 +1,66 @@
|
|
|
1
|
-
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
const DEFAULT_STORE_PATH = path.join(homedir(), ".openclaw", "libp2p", "peer-identities.json");
|
|
5
|
+
export function createPeerIdentityMap(storePath) {
|
|
2
6
|
const peers = new Map();
|
|
3
7
|
const byInstanceId = new Map();
|
|
4
8
|
let localPeerId;
|
|
5
9
|
let localIdentity;
|
|
10
|
+
const filePath = storePath ?? DEFAULT_STORE_PATH;
|
|
11
|
+
async function save() {
|
|
12
|
+
const entries = {};
|
|
13
|
+
for (const [peerId, identity] of peers.entries()) {
|
|
14
|
+
entries[peerId] = identity;
|
|
15
|
+
}
|
|
16
|
+
const payload = {
|
|
17
|
+
localPeerId,
|
|
18
|
+
localIdentity,
|
|
19
|
+
peers: entries,
|
|
20
|
+
savedAt: Date.now(),
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
24
|
+
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Best-effort persistence; non-critical
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function load() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(filePath, "utf-8");
|
|
33
|
+
const data = JSON.parse(raw);
|
|
34
|
+
if (data.localPeerId)
|
|
35
|
+
localPeerId = data.localPeerId;
|
|
36
|
+
if (data.localIdentity)
|
|
37
|
+
localIdentity = data.localIdentity;
|
|
38
|
+
if (data.peers) {
|
|
39
|
+
for (const [peerId, identity] of Object.entries(data.peers)) {
|
|
40
|
+
peers.set(peerId, identity);
|
|
41
|
+
const id = identity.instanceId;
|
|
42
|
+
if (id)
|
|
43
|
+
byInstanceId.set(id, { peerId, identity: identity });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// File doesn't exist yet or is corrupt — start fresh
|
|
49
|
+
}
|
|
50
|
+
}
|
|
6
51
|
return {
|
|
52
|
+
async save() { await save(); },
|
|
53
|
+
async load() { await load(); },
|
|
7
54
|
register(peerId, identity) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const existing = peers.get(peerId);
|
|
57
|
+
identity.firstSeen = existing?.firstSeen ?? now;
|
|
58
|
+
identity.lastSeen = now;
|
|
8
59
|
peers.set(peerId, identity);
|
|
9
60
|
if (identity.instanceId) {
|
|
10
61
|
byInstanceId.set(identity.instanceId, { peerId, identity });
|
|
11
62
|
}
|
|
63
|
+
save().catch(() => { });
|
|
12
64
|
},
|
|
13
65
|
resolve(peerId) {
|
|
14
66
|
return peers.get(peerId);
|
|
@@ -25,14 +77,22 @@ export function createPeerIdentityMap() {
|
|
|
25
77
|
byInstanceId.delete(identity.instanceId);
|
|
26
78
|
}
|
|
27
79
|
peers.delete(peerId);
|
|
80
|
+
// Note: intentionally NOT saving on unregister — peer disconnects are
|
|
81
|
+
// often temporary (NAT timeout, network blip). Persisting the deletion
|
|
82
|
+
// would cause identity re-exchange on every reconnect. The entry will
|
|
83
|
+
// be refreshed when the peer reconnects and sends a new identity.
|
|
28
84
|
},
|
|
29
85
|
setLocalIdentity(peerId, identity) {
|
|
30
86
|
localPeerId = peerId;
|
|
31
87
|
localIdentity = identity;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
identity.firstSeen = now;
|
|
90
|
+
identity.lastSeen = now;
|
|
32
91
|
peers.set(peerId, identity);
|
|
33
92
|
if (identity.instanceId) {
|
|
34
93
|
byInstanceId.set(identity.instanceId, { peerId, identity });
|
|
35
94
|
}
|
|
95
|
+
save().catch(() => { });
|
|
36
96
|
},
|
|
37
97
|
getLocalIdentity() {
|
|
38
98
|
return localIdentity;
|
package/dist/src/plugin.js
CHANGED
|
@@ -24,14 +24,17 @@ export function registerLibp2pMesh(api) {
|
|
|
24
24
|
id: "libp2p-mesh",
|
|
25
25
|
start: async () => {
|
|
26
26
|
await mesh.start();
|
|
27
|
-
//
|
|
27
|
+
// Load persisted peer identities from disk
|
|
28
|
+
await peerIdentityMap.load();
|
|
29
|
+
// Gather local relay identity info
|
|
28
30
|
const config = api.pluginConfig;
|
|
29
31
|
const relayChannel = config?.relayChannel;
|
|
30
32
|
const relayAccountId = config?.relayAccountId;
|
|
31
33
|
const instanceIdentity = mesh.getInstanceIdentity();
|
|
32
34
|
const localInstanceId = instanceIdentity?.id;
|
|
35
|
+
const localPeerId = mesh.getLocalPeerId();
|
|
36
|
+
// Register local identity so remote peers can route messages back to us
|
|
33
37
|
if (relayChannel && relayAccountId) {
|
|
34
|
-
const localPeerId = mesh.getLocalPeerId();
|
|
35
38
|
const { buildAgentSessionKey } = await import("openclaw/plugin-sdk/core");
|
|
36
39
|
const sessionKey = buildAgentSessionKey({
|
|
37
40
|
agentId: api.name,
|
|
@@ -48,8 +51,7 @@ export function registerLibp2pMesh(api) {
|
|
|
48
51
|
api.logger.info?.(`[libp2p-mesh] Local identity registered: agent=${api.name}, channel=${relayChannel}, account=${relayAccountId}, instanceId=${localInstanceId}`);
|
|
49
52
|
// Announce identity to all currently-connected peers
|
|
50
53
|
const identityMsg = buildIdentityMessage(localPeerId, api.name, relayChannel, relayAccountId, localInstanceId);
|
|
51
|
-
const
|
|
52
|
-
for (const peerId of connectedPeers) {
|
|
54
|
+
for (const peerId of mesh.getConnectedPeers()) {
|
|
53
55
|
try {
|
|
54
56
|
await mesh.sendToPeer(peerId, JSON.stringify(identityMsg));
|
|
55
57
|
}
|
|
@@ -57,13 +59,24 @@ export function registerLibp2pMesh(api) {
|
|
|
57
59
|
// Best-effort; peer may be stale in the connection list
|
|
58
60
|
}
|
|
59
61
|
}
|
|
62
|
+
// When a new peer connects, send our identity to them
|
|
63
|
+
mesh.onPeerConnect((peerId) => {
|
|
64
|
+
const msg = buildIdentityMessage(localPeerId, api.name, relayChannel, relayAccountId, localInstanceId);
|
|
65
|
+
mesh.sendToPeer(peerId, JSON.stringify(msg)).catch(() => {
|
|
66
|
+
// Best-effort
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
// When a peer disconnects, clean up the identity map
|
|
70
|
+
mesh.onPeerDisconnect((peerId) => {
|
|
71
|
+
peerIdentityMap.unregister(peerId);
|
|
72
|
+
});
|
|
60
73
|
}
|
|
61
74
|
// Wire up relay-aware message handler
|
|
62
75
|
mesh.onMessage((msg) => {
|
|
63
76
|
handleP2PInbound(msg, buildInboundDeps());
|
|
64
77
|
});
|
|
65
78
|
const identity = mesh.getInstanceIdentity();
|
|
66
|
-
api.logger.info?.(`[libp2p-mesh] Service started. Peer ID: ${
|
|
79
|
+
api.logger.info?.(`[libp2p-mesh] Service started. Peer ID: ${localPeerId}`);
|
|
67
80
|
if (identity) {
|
|
68
81
|
api.logger.info?.(`[libp2p-mesh] Instance Identity: ${identity.id}`);
|
|
69
82
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -106,6 +106,8 @@ export interface MeshNetwork {
|
|
|
106
106
|
stop(): Promise<void>;
|
|
107
107
|
sendToPeer(peerId: string, message: string): Promise<void>;
|
|
108
108
|
onMessage(handler: (msg: P2PMessage) => void): () => void;
|
|
109
|
+
onPeerConnect(handler: (peerId: string) => void): () => void;
|
|
110
|
+
onPeerDisconnect(handler: (peerId: string) => void): () => void;
|
|
109
111
|
publishToTopic(topic: string, message: string): Promise<void>;
|
|
110
112
|
subscribeToTopic(topic: string, handler: (msg: string) => void): Promise<void>;
|
|
111
113
|
getLocalPeerId(): string;
|
package/package.json
CHANGED
package/src/agent-tools.ts
CHANGED
|
@@ -278,27 +278,62 @@ export function buildP2PTools(mesh: MeshNetwork) {
|
|
|
278
278
|
{
|
|
279
279
|
name: "p2p_relay_status",
|
|
280
280
|
label: "P2P Relay Status",
|
|
281
|
-
description: "Get
|
|
281
|
+
description: "Get full P2P mesh identity table: local node info, all known peers (connected or not), their instance IDs, channels, and last seen time.",
|
|
282
282
|
parameters: { type: "object" as const, properties: {} },
|
|
283
283
|
async execute(_toolCallId: string, _params: {}, _ctx: any) {
|
|
284
284
|
const peerIdentityMap = (this as any).peerIdentityMap;
|
|
285
285
|
const localPeerId = mesh.getLocalPeerId();
|
|
286
286
|
const connectedPeers = mesh.getConnectedPeers();
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
287
|
+
const connectedSet = new Set(connectedPeers);
|
|
288
|
+
|
|
289
|
+
// Build full identity table from all known peers (not just connected)
|
|
290
|
+
const allPeers: { peerId: string; agentId: string; channel: string; instanceId?: string; sessionKey: string; status: string; lastSeen?: number }[] = [];
|
|
291
|
+
for (const [peerId, identity] of peerIdentityMap?.entries() ?? []) {
|
|
292
|
+
if (peerId === localPeerId) continue; // skip local in peer list
|
|
293
|
+
allPeers.push({
|
|
294
|
+
peerId,
|
|
295
|
+
agentId: identity.agentId,
|
|
296
|
+
channel: identity.channel,
|
|
297
|
+
instanceId: identity.instanceId,
|
|
298
|
+
sessionKey: identity.sessionKey,
|
|
299
|
+
status: connectedSet.has(peerId) ? "connected" : "known",
|
|
300
|
+
lastSeen: identity.lastSeen,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Local identity
|
|
305
|
+
const localIdentity = peerIdentityMap?.getLocalIdentity();
|
|
306
|
+
const localInstanceId = localIdentity?.instanceId ?? "(not set)";
|
|
307
|
+
|
|
308
|
+
// Format output
|
|
309
|
+
const lines: string[] = [];
|
|
310
|
+
lines.push(`P2P Mesh Identity Table`);
|
|
311
|
+
lines.push(`Local: ${localPeerId.slice(0, 16)}... | Instance: ${localInstanceId}`);
|
|
312
|
+
lines.push(`Connected: ${connectedPeers.length} | Known total: ${allPeers.length}`);
|
|
313
|
+
lines.push("");
|
|
314
|
+
|
|
315
|
+
if (allPeers.length === 0) {
|
|
316
|
+
lines.push("(no known peers yet)");
|
|
317
|
+
} else {
|
|
318
|
+
for (const p of allPeers) {
|
|
319
|
+
const shortId = p.peerId.slice(0, 16) + "...";
|
|
320
|
+
const time = p.lastSeen ? new Date(p.lastSeen).toLocaleTimeString() : "unknown";
|
|
321
|
+
lines.push(` [${p.status}] ${shortId}`);
|
|
322
|
+
lines.push(` Agent: ${p.agentId} | Channel: ${p.channel}`);
|
|
323
|
+
lines.push(` Instance: ${p.instanceId ?? "unknown"}`);
|
|
324
|
+
lines.push(` Last seen: ${time}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const text = lines.join("\n");
|
|
299
329
|
return {
|
|
300
330
|
content: [{ type: "text" as const, text }],
|
|
301
|
-
details: {
|
|
331
|
+
details: {
|
|
332
|
+
localPeerId,
|
|
333
|
+
localInstanceId,
|
|
334
|
+
connectedPeers: connectedPeers.length,
|
|
335
|
+
knownPeers: allPeers,
|
|
336
|
+
},
|
|
302
337
|
};
|
|
303
338
|
},
|
|
304
339
|
},
|
package/src/mesh.ts
CHANGED
|
@@ -94,6 +94,8 @@ export function createMeshNetwork(options: {
|
|
|
94
94
|
const seenMessages = new Set<string>();
|
|
95
95
|
const messageHandlers = new Set<(msg: P2PMessage) => void>();
|
|
96
96
|
const topicHandlers = new Map<string, Set<(msg: string) => void>>();
|
|
97
|
+
const peerConnectHandlers = new Set<(peerId: string) => void>();
|
|
98
|
+
const peerDisconnectHandlers = new Set<(peerId: string) => void>();
|
|
97
99
|
|
|
98
100
|
function getDHTService(): ReturnType<typeof kadDHT> extends (components: infer C) => infer R ? R : never | undefined {
|
|
99
101
|
return (state.node as any)?.services?.dht;
|
|
@@ -258,11 +260,17 @@ export function createMeshNetwork(options: {
|
|
|
258
260
|
state.node.addEventListener("peer:connect", (evt) => {
|
|
259
261
|
const peerIdStr = evt.detail.toString();
|
|
260
262
|
logger?.info?.(`[libp2p-mesh] Peer connected: ${peerIdStr}`);
|
|
263
|
+
for (const handler of peerConnectHandlers) {
|
|
264
|
+
try { handler(peerIdStr); } catch {}
|
|
265
|
+
}
|
|
261
266
|
});
|
|
262
267
|
|
|
263
268
|
state.node.addEventListener("peer:disconnect", (evt) => {
|
|
264
269
|
const peerIdStr = evt.detail.toString();
|
|
265
270
|
logger?.info?.(`[libp2p-mesh] Peer disconnected: ${peerIdStr}`);
|
|
271
|
+
for (const handler of peerDisconnectHandlers) {
|
|
272
|
+
try { handler(peerIdStr); } catch {}
|
|
273
|
+
}
|
|
266
274
|
});
|
|
267
275
|
|
|
268
276
|
await state.node.handle(
|
|
@@ -667,6 +675,14 @@ export function createMeshNetwork(options: {
|
|
|
667
675
|
stop,
|
|
668
676
|
sendToPeer,
|
|
669
677
|
onMessage,
|
|
678
|
+
onPeerConnect(handler: (peerId: string) => void): () => void {
|
|
679
|
+
peerConnectHandlers.add(handler);
|
|
680
|
+
return () => { peerConnectHandlers.delete(handler); };
|
|
681
|
+
},
|
|
682
|
+
onPeerDisconnect(handler: (peerId: string) => void): () => void {
|
|
683
|
+
peerDisconnectHandlers.add(handler);
|
|
684
|
+
return () => { peerDisconnectHandlers.delete(handler); };
|
|
685
|
+
},
|
|
670
686
|
publishToTopic,
|
|
671
687
|
subscribeToTopic,
|
|
672
688
|
getLocalPeerId,
|
package/src/peer-identity.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
|
|
1
5
|
export interface PeerIdentity {
|
|
2
6
|
agentId: string;
|
|
3
7
|
channel: string;
|
|
4
8
|
accountId: string;
|
|
5
9
|
sessionKey: string;
|
|
6
|
-
/** Human-readable Instance ID
|
|
10
|
+
/** Human-readable Instance ID */
|
|
7
11
|
instanceId?: string;
|
|
12
|
+
/** When this identity was first registered (epoch ms) */
|
|
13
|
+
firstSeen?: number;
|
|
14
|
+
/** Last time we saw this peer (epoch ms) */
|
|
15
|
+
lastSeen?: number;
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
export interface PeerIdentityMap {
|
|
@@ -17,20 +25,71 @@ export interface PeerIdentityMap {
|
|
|
17
25
|
getLocalIdentity(): PeerIdentity | undefined;
|
|
18
26
|
hasIdentity(peerId: string): boolean;
|
|
19
27
|
entries(): IterableIterator<[string, PeerIdentity]>;
|
|
28
|
+
/** Persist current state to disk */
|
|
29
|
+
save(): Promise<void>;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
const DEFAULT_STORE_PATH = path.join(homedir(), ".openclaw", "libp2p", "peer-identities.json");
|
|
33
|
+
|
|
34
|
+
export function createPeerIdentityMap(storePath?: string): PeerIdentityMap & { load(): Promise<void> } {
|
|
23
35
|
const peers = new Map<string, PeerIdentity>();
|
|
24
36
|
const byInstanceId = new Map<string, { peerId: string; identity: PeerIdentity }>();
|
|
25
37
|
let localPeerId: string | undefined;
|
|
26
38
|
let localIdentity: PeerIdentity | undefined;
|
|
39
|
+
const filePath = storePath ?? DEFAULT_STORE_PATH;
|
|
40
|
+
|
|
41
|
+
async function save(): Promise<void> {
|
|
42
|
+
const entries: Record<string, PeerIdentity> = {};
|
|
43
|
+
for (const [peerId, identity] of peers.entries()) {
|
|
44
|
+
entries[peerId] = identity;
|
|
45
|
+
}
|
|
46
|
+
const payload = {
|
|
47
|
+
localPeerId,
|
|
48
|
+
localIdentity,
|
|
49
|
+
peers: entries,
|
|
50
|
+
savedAt: Date.now(),
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
54
|
+
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
// Best-effort persistence; non-critical
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function load(): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(filePath, "utf-8");
|
|
63
|
+
const data = JSON.parse(raw);
|
|
64
|
+
if (data.localPeerId) localPeerId = data.localPeerId;
|
|
65
|
+
if (data.localIdentity) localIdentity = data.localIdentity;
|
|
66
|
+
if (data.peers) {
|
|
67
|
+
for (const [peerId, identity] of Object.entries(data.peers)) {
|
|
68
|
+
peers.set(peerId, identity as PeerIdentity);
|
|
69
|
+
const id = (identity as PeerIdentity).instanceId;
|
|
70
|
+
if (id) byInstanceId.set(id, { peerId, identity: identity as PeerIdentity });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// File doesn't exist yet or is corrupt — start fresh
|
|
75
|
+
}
|
|
76
|
+
}
|
|
27
77
|
|
|
28
78
|
return {
|
|
79
|
+
async save() { await save(); },
|
|
80
|
+
|
|
81
|
+
async load() { await load(); },
|
|
82
|
+
|
|
29
83
|
register(peerId: string, identity: PeerIdentity): void {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const existing = peers.get(peerId);
|
|
86
|
+
identity.firstSeen = existing?.firstSeen ?? now;
|
|
87
|
+
identity.lastSeen = now;
|
|
30
88
|
peers.set(peerId, identity);
|
|
31
89
|
if (identity.instanceId) {
|
|
32
90
|
byInstanceId.set(identity.instanceId, { peerId, identity });
|
|
33
91
|
}
|
|
92
|
+
save().catch(() => {});
|
|
34
93
|
},
|
|
35
94
|
|
|
36
95
|
resolve(peerId: string): PeerIdentity | undefined {
|
|
@@ -51,15 +110,23 @@ export function createPeerIdentityMap(): PeerIdentityMap {
|
|
|
51
110
|
byInstanceId.delete(identity.instanceId);
|
|
52
111
|
}
|
|
53
112
|
peers.delete(peerId);
|
|
113
|
+
// Note: intentionally NOT saving on unregister — peer disconnects are
|
|
114
|
+
// often temporary (NAT timeout, network blip). Persisting the deletion
|
|
115
|
+
// would cause identity re-exchange on every reconnect. The entry will
|
|
116
|
+
// be refreshed when the peer reconnects and sends a new identity.
|
|
54
117
|
},
|
|
55
118
|
|
|
56
119
|
setLocalIdentity(peerId: string, identity: PeerIdentity): void {
|
|
57
120
|
localPeerId = peerId;
|
|
58
121
|
localIdentity = identity;
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
identity.firstSeen = now;
|
|
124
|
+
identity.lastSeen = now;
|
|
59
125
|
peers.set(peerId, identity);
|
|
60
126
|
if (identity.instanceId) {
|
|
61
127
|
byInstanceId.set(identity.instanceId, { peerId, identity });
|
|
62
128
|
}
|
|
129
|
+
save().catch(() => {});
|
|
63
130
|
},
|
|
64
131
|
|
|
65
132
|
getLocalIdentity(): PeerIdentity | undefined {
|
package/src/plugin.ts
CHANGED
|
@@ -33,14 +33,19 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
|
|
|
33
33
|
start: async () => {
|
|
34
34
|
await mesh.start();
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// Load persisted peer identities from disk
|
|
37
|
+
await peerIdentityMap.load();
|
|
38
|
+
|
|
39
|
+
// Gather local relay identity info
|
|
37
40
|
const config = api.pluginConfig as MeshConfig | undefined;
|
|
38
41
|
const relayChannel = config?.relayChannel;
|
|
39
42
|
const relayAccountId = config?.relayAccountId;
|
|
40
43
|
const instanceIdentity = mesh.getInstanceIdentity();
|
|
41
44
|
const localInstanceId = instanceIdentity?.id;
|
|
45
|
+
const localPeerId = mesh.getLocalPeerId();
|
|
46
|
+
|
|
47
|
+
// Register local identity so remote peers can route messages back to us
|
|
42
48
|
if (relayChannel && relayAccountId) {
|
|
43
|
-
const localPeerId = mesh.getLocalPeerId();
|
|
44
49
|
const { buildAgentSessionKey } = await import("openclaw/plugin-sdk/core");
|
|
45
50
|
const sessionKey = buildAgentSessionKey({
|
|
46
51
|
agentId: api.name,
|
|
@@ -66,14 +71,32 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
|
|
|
66
71
|
relayAccountId,
|
|
67
72
|
localInstanceId,
|
|
68
73
|
);
|
|
69
|
-
const
|
|
70
|
-
for (const peerId of connectedPeers) {
|
|
74
|
+
for (const peerId of mesh.getConnectedPeers()) {
|
|
71
75
|
try {
|
|
72
76
|
await mesh.sendToPeer(peerId, JSON.stringify(identityMsg));
|
|
73
77
|
} catch {
|
|
74
78
|
// Best-effort; peer may be stale in the connection list
|
|
75
79
|
}
|
|
76
80
|
}
|
|
81
|
+
|
|
82
|
+
// When a new peer connects, send our identity to them
|
|
83
|
+
mesh.onPeerConnect((peerId: string) => {
|
|
84
|
+
const msg = buildIdentityMessage(
|
|
85
|
+
localPeerId,
|
|
86
|
+
api.name,
|
|
87
|
+
relayChannel,
|
|
88
|
+
relayAccountId,
|
|
89
|
+
localInstanceId,
|
|
90
|
+
);
|
|
91
|
+
mesh.sendToPeer(peerId, JSON.stringify(msg)).catch(() => {
|
|
92
|
+
// Best-effort
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// When a peer disconnects, clean up the identity map
|
|
97
|
+
mesh.onPeerDisconnect((peerId: string) => {
|
|
98
|
+
peerIdentityMap.unregister(peerId);
|
|
99
|
+
});
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
// Wire up relay-aware message handler
|
|
@@ -82,7 +105,7 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
|
|
|
82
105
|
});
|
|
83
106
|
|
|
84
107
|
const identity = mesh.getInstanceIdentity();
|
|
85
|
-
api.logger.info?.(`[libp2p-mesh] Service started. Peer ID: ${
|
|
108
|
+
api.logger.info?.(`[libp2p-mesh] Service started. Peer ID: ${localPeerId}`);
|
|
86
109
|
if (identity) {
|
|
87
110
|
api.logger.info?.(`[libp2p-mesh] Instance Identity: ${identity.id}`);
|
|
88
111
|
}
|
package/src/types.ts
CHANGED
|
@@ -116,6 +116,8 @@ export interface MeshNetwork {
|
|
|
116
116
|
stop(): Promise<void>;
|
|
117
117
|
sendToPeer(peerId: string, message: string): Promise<void>;
|
|
118
118
|
onMessage(handler: (msg: P2PMessage) => void): () => void;
|
|
119
|
+
onPeerConnect(handler: (peerId: string) => void): () => void;
|
|
120
|
+
onPeerDisconnect(handler: (peerId: string) => void): () => void;
|
|
119
121
|
publishToTopic(topic: string, message: string): Promise<void>;
|
|
120
122
|
subscribeToTopic(topic: string, handler: (msg: string) => void): Promise<void>;
|
|
121
123
|
getLocalPeerId(): string;
|