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.
@@ -310,11 +310,18 @@ export declare function buildP2PTools(mesh: MeshNetwork): ({
310
310
  text: string;
311
311
  }[];
312
312
  details: {
313
- knownIdentities: ({
314
- instanceId: any;
315
- agentId: any;
316
- channel: any;
317
- } | null)[];
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
  })[];
@@ -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 current P2P mesh connection status, known peers, and identity mapping.",
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 knownIdentities = connectedPeers
288
- .map((p) => {
289
- const identity = peerIdentityMap?.resolve(p);
290
- if (!identity)
291
- return null;
292
- return {
293
- instanceId: identity.instanceId || p.slice(0, 12) + "...",
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
- .filter(Boolean);
299
- const text = `P2P Mesh Status:\nLocal Peer ID: ${localPeerId}\nConnected Peers: ${connectedPeers.length}\nKnown Identities: ${knownIdentities.length}`;
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: { knownIdentities },
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 (e.g. "alice-mac@AQIDBAU.7a3f9e2b") */
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
- export function createPeerIdentityMap() {
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;
@@ -24,14 +24,17 @@ export function registerLibp2pMesh(api) {
24
24
  id: "libp2p-mesh",
25
25
  start: async () => {
26
26
  await mesh.start();
27
- // Register local identity so remote peers can route messages back to us
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 connectedPeers = mesh.getConnectedPeers();
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: ${mesh.getLocalPeerId()}`);
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2p-mesh",
3
- "version": "2026.5.18",
3
+ "version": "2026.5.20",
4
4
  "description": "OpenClaw libp2p P2P mesh network plugin for cross-instance agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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 current P2P mesh connection status, known peers, and identity mapping.",
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 knownIdentities = connectedPeers
288
- .map((p) => {
289
- const identity = peerIdentityMap?.resolve(p);
290
- if (!identity) return null;
291
- return {
292
- instanceId: identity.instanceId || p.slice(0, 12) + "...",
293
- agentId: identity.agentId,
294
- channel: identity.channel,
295
- };
296
- })
297
- .filter(Boolean);
298
- const text = `P2P Mesh Status:\nLocal Peer ID: ${localPeerId}\nConnected Peers: ${connectedPeers.length}\nKnown Identities: ${knownIdentities.length}`;
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: { knownIdentities },
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,
@@ -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 (e.g. "alice-mac@AQIDBAU.7a3f9e2b") */
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
- export function createPeerIdentityMap(): PeerIdentityMap {
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
- // Register local identity so remote peers can route messages back to us
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 connectedPeers = mesh.getConnectedPeers();
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: ${mesh.getLocalPeerId()}`);
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;