@a-company/paradigm 3.44.0 → 3.46.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.
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ addPeer,
4
+ computeHmacProof,
5
+ generatePairing,
6
+ loadPeers,
7
+ updatePeerAgents,
8
+ updatePeerLastSeen,
9
+ verifyHmacProof,
10
+ verifyPairingCode
11
+ } from "./chunk-KVDYJLTC.js";
12
+ import {
13
+ appendToInbox,
14
+ isAgentAsleep,
15
+ listAgents,
16
+ readOutbox
17
+ } from "./chunk-S2HO5MLR.js";
18
+ import "./chunk-ZXMDA7VB.js";
19
+
20
+ // ../paradigm-mcp/src/utils/symphony-relay.ts
21
+ import * as path from "path";
22
+ import * as os from "os";
23
+ import * as crypto from "crypto";
24
+ import { WebSocketServer, WebSocket } from "ws";
25
+ var SCORE_DIR = path.join(os.homedir(), ".paradigm", "score");
26
+ var OUTBOX_POLL_INTERVAL_MS = 2e3;
27
+ var KEEPALIVE_INTERVAL_MS = 3e4;
28
+ var PONG_TIMEOUT_MS = 1e4;
29
+ var MAX_AUTH_ATTEMPTS = 3;
30
+ var AUTH_COOLDOWN_MS = 6e4;
31
+ var RECONNECT_MIN_MS = 1e3;
32
+ var RECONNECT_MAX_MS = 3e4;
33
+ function sendFrame(ws, frame) {
34
+ if (ws.readyState === WebSocket.OPEN) {
35
+ ws.send(JSON.stringify(frame));
36
+ }
37
+ }
38
+ function parseFrame(data) {
39
+ try {
40
+ const text = typeof data === "string" ? data : String(data);
41
+ return JSON.parse(text);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ var SymphonyRelay = class _SymphonyRelay {
47
+ wss = null;
48
+ wsClient = null;
49
+ mode;
50
+ pairingState = null;
51
+ /** peerId → WebSocket for authenticated connections. */
52
+ connectedPeers = /* @__PURE__ */ new Map();
53
+ /** Bounded dedup set of message IDs already processed. */
54
+ seenMessageIds = /* @__PURE__ */ new Set();
55
+ outboxWatchInterval = null;
56
+ keepaliveInterval = null;
57
+ reconnectTimer = null;
58
+ reconnectDelay = RECONNECT_MIN_MS;
59
+ /** agentId → count of outbox lines already forwarded. */
60
+ outboxPositions = /* @__PURE__ */ new Map();
61
+ events;
62
+ myPeerId;
63
+ port;
64
+ stopped = false;
65
+ /** IP/address → { count, cooldownUntil } for rate limiting. */
66
+ failedAuthAttempts = /* @__PURE__ */ new Map();
67
+ /** Per-connection pong tracking: peerId → pending timeout handle. */
68
+ pongTimers = /* @__PURE__ */ new Map();
69
+ /** Server-side address stored for client reconnect. */
70
+ serverAddress = null;
71
+ /** Pairing code stored for client reconnect. */
72
+ serverCode = null;
73
+ /** Maximum number of message IDs to keep for dedup. */
74
+ static MAX_SEEN_IDS = 1e4;
75
+ constructor(options) {
76
+ this.mode = options.mode;
77
+ this.myPeerId = options.peerId;
78
+ this.port = options.port ?? 3939;
79
+ this.events = options.events ?? {};
80
+ }
81
+ // ────────────────────────────────────────────────────────
82
+ // Server Mode
83
+ // ────────────────────────────────────────────────────────
84
+ /**
85
+ * Start a WebSocket relay server (hub mode).
86
+ *
87
+ * Generates a fresh pairing state, binds to `this.port`, and begins
88
+ * accepting peer connections. Returns the pairing state so the caller
89
+ * can display the code to the user.
90
+ */
91
+ async startServer() {
92
+ if (this.mode !== "server") {
93
+ throw new Error('startServer() requires mode "server"');
94
+ }
95
+ this.pairingState = generatePairing();
96
+ this.wss = new WebSocketServer({ port: this.port });
97
+ this.wss.on("connection", (ws, req) => {
98
+ const remoteAddress = req.socket.remoteAddress ?? "unknown";
99
+ if (this.isRateLimited(remoteAddress)) {
100
+ sendFrame(ws, { type: "auth_fail", reason: "Too many failed attempts \u2014 try again later" });
101
+ ws.close();
102
+ return;
103
+ }
104
+ const challenge = crypto.randomBytes(32).toString("hex");
105
+ sendFrame(ws, {
106
+ type: "hello",
107
+ version: "1.0",
108
+ peerId: this.myPeerId,
109
+ challenge
110
+ });
111
+ let authenticated = false;
112
+ ws.on("message", (raw) => {
113
+ const frame = parseFrame(raw);
114
+ if (!frame) return;
115
+ if (!authenticated) {
116
+ this.handleServerAuth(ws, frame, challenge, remoteAddress).then((peerId) => {
117
+ if (peerId) {
118
+ authenticated = true;
119
+ this.registerPeerConnection(peerId, ws);
120
+ }
121
+ }).catch((err) => {
122
+ this.events.onError?.(err instanceof Error ? err : new Error(String(err)));
123
+ });
124
+ return;
125
+ }
126
+ this.handleAuthenticatedFrame(ws, frame);
127
+ });
128
+ ws.on("close", () => {
129
+ if (authenticated) {
130
+ this.handlePeerDisconnect(ws);
131
+ }
132
+ });
133
+ ws.on("error", (err) => {
134
+ this.events.onError?.(err);
135
+ });
136
+ });
137
+ this.wss.on("error", (err) => {
138
+ this.events.onError?.(err);
139
+ });
140
+ await new Promise((resolve, reject) => {
141
+ this.wss.on("listening", resolve);
142
+ this.wss.on("error", reject);
143
+ });
144
+ this.startOutboxWatcher();
145
+ this.startKeepalive();
146
+ return this.pairingState;
147
+ }
148
+ /**
149
+ * Process an auth frame from an incoming client connection.
150
+ * Returns the authenticated peerId on success, null on failure.
151
+ */
152
+ async handleServerAuth(ws, frame, challenge, remoteAddress) {
153
+ if (frame.type !== "auth") {
154
+ sendFrame(ws, { type: "auth_fail", reason: "Expected auth frame" });
155
+ ws.close();
156
+ return null;
157
+ }
158
+ if (!this.pairingState || !verifyPairingCode(this.pairingState, frame.code)) {
159
+ this.recordFailedAuth(remoteAddress);
160
+ const reason = "Invalid or expired pairing code";
161
+ sendFrame(ws, { type: "auth_fail", reason });
162
+ this.events.onPeerAuthFailed?.(remoteAddress, reason);
163
+ ws.close();
164
+ return null;
165
+ }
166
+ const codeHash = this.pairingState.codeHash;
167
+ if (!verifyHmacProof(challenge, codeHash, frame.proof)) {
168
+ this.recordFailedAuth(remoteAddress);
169
+ const reason = "HMAC proof verification failed";
170
+ sendFrame(ws, { type: "auth_fail", reason });
171
+ this.events.onPeerAuthFailed?.(remoteAddress, reason);
172
+ ws.close();
173
+ return null;
174
+ }
175
+ const localAgents = this.getLocalAgentSummaries();
176
+ const displayName = this.myPeerId;
177
+ sendFrame(ws, {
178
+ type: "auth_ok",
179
+ peerId: this.myPeerId,
180
+ displayName,
181
+ agents: localAgents
182
+ });
183
+ addPeer({
184
+ id: frame.peerId,
185
+ displayName: frame.peerId,
186
+ address: remoteAddress,
187
+ sharedSecret: this.pairingState.sharedSecret,
188
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
189
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
190
+ revoked: false,
191
+ agents: []
192
+ });
193
+ return frame.peerId;
194
+ }
195
+ // ────────────────────────────────────────────────────────
196
+ // Client Mode
197
+ // ────────────────────────────────────────────────────────
198
+ /**
199
+ * Connect to a remote relay server as a spoke.
200
+ *
201
+ * Resolves once authentication completes successfully.
202
+ * Rejects if the connection fails or auth is denied.
203
+ */
204
+ async connectToServer(address, code) {
205
+ if (this.mode !== "client") {
206
+ throw new Error('connectToServer() requires mode "client"');
207
+ }
208
+ this.serverAddress = address;
209
+ this.serverCode = code;
210
+ await this.attemptConnection(address, code);
211
+ }
212
+ /**
213
+ * Inner connection attempt — used for both initial connect and reconnects.
214
+ */
215
+ attemptConnection(address, code) {
216
+ return new Promise((resolve, reject) => {
217
+ if (this.stopped) {
218
+ reject(new Error("Relay has been stopped"));
219
+ return;
220
+ }
221
+ const wsUrl = address.includes("://") ? address : `ws://${address}`;
222
+ const ws = new WebSocket(wsUrl);
223
+ let settled = false;
224
+ ws.on("open", () => {
225
+ this.wsClient = ws;
226
+ });
227
+ ws.on("message", (raw) => {
228
+ const frame = parseFrame(raw);
229
+ if (!frame) return;
230
+ switch (frame.type) {
231
+ case "hello": {
232
+ const codeHash = crypto.createHash("sha256").update(code).digest("hex");
233
+ const proof = computeHmacProof(frame.challenge, codeHash);
234
+ sendFrame(ws, {
235
+ type: "auth",
236
+ peerId: this.myPeerId,
237
+ code,
238
+ proof
239
+ });
240
+ break;
241
+ }
242
+ case "auth_ok": {
243
+ addPeer({
244
+ id: frame.peerId,
245
+ displayName: frame.displayName,
246
+ address,
247
+ sharedSecret: code,
248
+ // Store code for reconnect
249
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
250
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
251
+ revoked: false,
252
+ agents: frame.agents
253
+ });
254
+ sendFrame(ws, {
255
+ type: "agents_sync",
256
+ agents: this.getLocalAgentSummaries()
257
+ });
258
+ this.registerPeerConnection(frame.peerId, ws);
259
+ this.startOutboxWatcher();
260
+ this.startKeepalive();
261
+ this.reconnectDelay = RECONNECT_MIN_MS;
262
+ if (!settled) {
263
+ settled = true;
264
+ resolve();
265
+ }
266
+ break;
267
+ }
268
+ case "auth_fail": {
269
+ if (!settled) {
270
+ settled = true;
271
+ reject(new Error(`Auth failed: ${frame.reason}`));
272
+ }
273
+ ws.close();
274
+ break;
275
+ }
276
+ default:
277
+ this.handleAuthenticatedFrame(ws, frame);
278
+ break;
279
+ }
280
+ });
281
+ ws.on("close", () => {
282
+ this.handlePeerDisconnect(ws);
283
+ if (!settled) {
284
+ settled = true;
285
+ reject(new Error("Connection closed before auth completed"));
286
+ } else if (!this.stopped) {
287
+ this.scheduleReconnect();
288
+ }
289
+ });
290
+ ws.on("error", (err) => {
291
+ this.events.onError?.(err);
292
+ if (!settled) {
293
+ settled = true;
294
+ reject(err);
295
+ }
296
+ });
297
+ });
298
+ }
299
+ // ────────────────────────────────────────────────────────
300
+ // Authenticated Frame Handler
301
+ // ────────────────────────────────────────────────────────
302
+ /**
303
+ * Dispatch a frame received on an authenticated connection.
304
+ */
305
+ handleAuthenticatedFrame(ws, frame) {
306
+ switch (frame.type) {
307
+ case "message":
308
+ this.handleIncomingMessage(ws, frame.message, frame.origin);
309
+ break;
310
+ case "message_ack":
311
+ break;
312
+ case "agents_sync":
313
+ this.handleAgentsSync(ws, frame.agents);
314
+ break;
315
+ case "agent_joined": {
316
+ const peerId = this.peerIdForSocket(ws);
317
+ if (peerId) {
318
+ const peers = loadPeers();
319
+ const peer = peers.find((p) => p.id === peerId);
320
+ if (peer) {
321
+ const agents = [...peer.agents || [], frame.agent];
322
+ updatePeerAgents(peerId, agents);
323
+ }
324
+ }
325
+ break;
326
+ }
327
+ case "agent_left": {
328
+ const peerId = this.peerIdForSocket(ws);
329
+ if (peerId) {
330
+ const peers = loadPeers();
331
+ const peer = peers.find((p) => p.id === peerId);
332
+ if (peer) {
333
+ const agents = (peer.agents || []).filter((a) => a.id !== frame.agentId);
334
+ updatePeerAgents(peerId, agents);
335
+ }
336
+ }
337
+ break;
338
+ }
339
+ case "peer_leaving":
340
+ this.handlePeerDisconnect(ws);
341
+ ws.close();
342
+ break;
343
+ case "ping":
344
+ sendFrame(ws, { type: "pong" });
345
+ break;
346
+ case "pong":
347
+ this.handlePong(ws);
348
+ break;
349
+ default:
350
+ break;
351
+ }
352
+ }
353
+ // ────────────────────────────────────────────────────────
354
+ // Message Handling
355
+ // ────────────────────────────────────────────────────────
356
+ /**
357
+ * Process an incoming relayed message.
358
+ *
359
+ * 1. Dedup check — skip if already seen
360
+ * 2. Deliver to matching local agents
361
+ * 3. In server mode, relay to other connected peers (not the sender)
362
+ * 4. Send ack back to the sender
363
+ */
364
+ handleIncomingMessage(senderWs, message, origin) {
365
+ if (this.seenMessageIds.has(message.id)) {
366
+ sendFrame(senderWs, { type: "message_ack", messageId: message.id });
367
+ return;
368
+ }
369
+ this.addToSeenIds(message.id);
370
+ const localAgents = listAgents();
371
+ if (message.recipients && message.recipients.length > 0) {
372
+ for (const recipient of message.recipients) {
373
+ const localMatch = localAgents.find((a) => a.id === recipient.id);
374
+ if (localMatch) {
375
+ appendToInbox(localMatch.id, message);
376
+ this.events.onMessageRelayed?.(message.id, origin, localMatch.id);
377
+ }
378
+ }
379
+ } else {
380
+ for (const agent of localAgents) {
381
+ appendToInbox(agent.id, message);
382
+ this.events.onMessageRelayed?.(message.id, origin, agent.id);
383
+ }
384
+ }
385
+ if (this.mode === "server") {
386
+ const senderPeerId = this.peerIdForSocket(senderWs);
387
+ for (const [peerId, peerWs] of this.connectedPeers) {
388
+ if (peerId !== senderPeerId && peerId !== origin) {
389
+ sendFrame(peerWs, { type: "message", message, origin });
390
+ }
391
+ }
392
+ }
393
+ sendFrame(senderWs, { type: "message_ack", messageId: message.id });
394
+ }
395
+ /**
396
+ * Update stored agent list for a peer after receiving agents_sync.
397
+ */
398
+ handleAgentsSync(ws, agents) {
399
+ const peerId = this.peerIdForSocket(ws);
400
+ if (peerId) {
401
+ updatePeerAgents(peerId, agents);
402
+ updatePeerLastSeen(peerId);
403
+ }
404
+ }
405
+ // ────────────────────────────────────────────────────────
406
+ // Outbox Watcher
407
+ // ────────────────────────────────────────────────────────
408
+ /**
409
+ * Start polling local agent outboxes for new messages to relay.
410
+ *
411
+ * Reads each outbox as an array, compares length against the stored
412
+ * position, and forwards any new entries to all connected peers.
413
+ */
414
+ startOutboxWatcher() {
415
+ if (this.outboxWatchInterval) return;
416
+ this.outboxWatchInterval = setInterval(() => {
417
+ if (this.connectedPeers.size === 0) return;
418
+ try {
419
+ const agents = listAgents();
420
+ for (const agent of agents) {
421
+ const messages = readOutbox(agent.id);
422
+ const lastPosition = this.outboxPositions.get(agent.id) ?? 0;
423
+ if (messages.length <= lastPosition) continue;
424
+ const newMessages = messages.slice(lastPosition);
425
+ for (const msg of newMessages) {
426
+ if (this.seenMessageIds.has(msg.id)) continue;
427
+ this.addToSeenIds(msg.id);
428
+ const frame = {
429
+ type: "message",
430
+ message: msg,
431
+ origin: this.myPeerId
432
+ };
433
+ for (const [_peerId, peerWs] of this.connectedPeers) {
434
+ sendFrame(peerWs, frame);
435
+ }
436
+ }
437
+ this.outboxPositions.set(agent.id, messages.length);
438
+ }
439
+ } catch (err) {
440
+ this.events.onError?.(err instanceof Error ? err : new Error(String(err)));
441
+ }
442
+ }, OUTBOX_POLL_INTERVAL_MS);
443
+ }
444
+ /**
445
+ * Stop the outbox watcher.
446
+ */
447
+ stopOutboxWatcher() {
448
+ if (this.outboxWatchInterval) {
449
+ clearInterval(this.outboxWatchInterval);
450
+ this.outboxWatchInterval = null;
451
+ }
452
+ }
453
+ // ────────────────────────────────────────────────────────
454
+ // Keepalive
455
+ // ────────────────────────────────────────────────────────
456
+ /**
457
+ * Start periodic ping/pong keepalive for all connected peers.
458
+ */
459
+ startKeepalive() {
460
+ if (this.keepaliveInterval) return;
461
+ this.keepaliveInterval = setInterval(() => {
462
+ for (const [peerId, ws] of this.connectedPeers) {
463
+ sendFrame(ws, { type: "ping" });
464
+ const timer = setTimeout(() => {
465
+ this.handlePeerDisconnect(ws);
466
+ ws.terminate();
467
+ }, PONG_TIMEOUT_MS);
468
+ this.pongTimers.set(peerId, timer);
469
+ }
470
+ }, KEEPALIVE_INTERVAL_MS);
471
+ }
472
+ /**
473
+ * Stop keepalive pings.
474
+ */
475
+ stopKeepalive() {
476
+ if (this.keepaliveInterval) {
477
+ clearInterval(this.keepaliveInterval);
478
+ this.keepaliveInterval = null;
479
+ }
480
+ for (const timer of this.pongTimers.values()) {
481
+ clearTimeout(timer);
482
+ }
483
+ this.pongTimers.clear();
484
+ }
485
+ /**
486
+ * Handle a pong response — clear the dead-connection timer.
487
+ */
488
+ handlePong(ws) {
489
+ const peerId = this.peerIdForSocket(ws);
490
+ if (peerId) {
491
+ const timer = this.pongTimers.get(peerId);
492
+ if (timer) {
493
+ clearTimeout(timer);
494
+ this.pongTimers.delete(peerId);
495
+ }
496
+ updatePeerLastSeen(peerId);
497
+ }
498
+ }
499
+ // ────────────────────────────────────────────────────────
500
+ // Peer Lifecycle
501
+ // ────────────────────────────────────────────────────────
502
+ /**
503
+ * Register an authenticated peer connection.
504
+ */
505
+ registerPeerConnection(peerId, ws) {
506
+ const existing = this.connectedPeers.get(peerId);
507
+ if (existing && existing !== ws) {
508
+ existing.close();
509
+ }
510
+ this.connectedPeers.set(peerId, ws);
511
+ updatePeerLastSeen(peerId);
512
+ this.events.onPeerConnected?.(peerId, peerId);
513
+ }
514
+ /**
515
+ * Clean up after a peer disconnects (or is terminated).
516
+ */
517
+ handlePeerDisconnect(ws) {
518
+ const peerId = this.peerIdForSocket(ws);
519
+ if (!peerId) return;
520
+ this.connectedPeers.delete(peerId);
521
+ const timer = this.pongTimers.get(peerId);
522
+ if (timer) {
523
+ clearTimeout(timer);
524
+ this.pongTimers.delete(peerId);
525
+ }
526
+ this.events.onPeerDisconnected?.(peerId);
527
+ }
528
+ /**
529
+ * Find the peerId associated with a WebSocket connection.
530
+ */
531
+ peerIdForSocket(ws) {
532
+ for (const [peerId, peerWs] of this.connectedPeers) {
533
+ if (peerWs === ws) return peerId;
534
+ }
535
+ return null;
536
+ }
537
+ // ────────────────────────────────────────────────────────
538
+ // Reconnect (Client Mode)
539
+ // ────────────────────────────────────────────────────────
540
+ /**
541
+ * Schedule an automatic reconnect with exponential backoff.
542
+ */
543
+ scheduleReconnect() {
544
+ if (this.stopped || this.mode !== "client") return;
545
+ if (!this.serverAddress || !this.serverCode) return;
546
+ this.stopOutboxWatcher();
547
+ this.stopKeepalive();
548
+ this.wsClient = null;
549
+ const delay = this.reconnectDelay;
550
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
551
+ this.reconnectTimer = setTimeout(() => {
552
+ if (this.stopped) return;
553
+ this.attemptConnection(this.serverAddress, this.serverCode).catch((err) => {
554
+ this.events.onError?.(err instanceof Error ? err : new Error(String(err)));
555
+ });
556
+ }, delay);
557
+ }
558
+ // ────────────────────────────────────────────────────────
559
+ // Rate Limiting
560
+ // ────────────────────────────────────────────────────────
561
+ /**
562
+ * Check whether an address is currently rate-limited.
563
+ */
564
+ isRateLimited(address) {
565
+ const entry = this.failedAuthAttempts.get(address);
566
+ if (!entry) return false;
567
+ if (Date.now() < entry.cooldownUntil) return true;
568
+ if (entry.count >= MAX_AUTH_ATTEMPTS) {
569
+ entry.cooldownUntil = Date.now() + AUTH_COOLDOWN_MS;
570
+ return true;
571
+ }
572
+ return false;
573
+ }
574
+ /**
575
+ * Record a failed auth attempt from a given address.
576
+ */
577
+ recordFailedAuth(address) {
578
+ const entry = this.failedAuthAttempts.get(address);
579
+ if (entry) {
580
+ entry.count++;
581
+ } else {
582
+ this.failedAuthAttempts.set(address, { count: 1, cooldownUntil: 0 });
583
+ }
584
+ }
585
+ // ────────────────────────────────────────────────────────
586
+ // Dedup
587
+ // ────────────────────────────────────────────────────────
588
+ /**
589
+ * Add a message ID to the dedup set, evicting the oldest half when
590
+ * the set exceeds {@link MAX_SEEN_IDS}.
591
+ */
592
+ addToSeenIds(messageId) {
593
+ this.seenMessageIds.add(messageId);
594
+ if (this.seenMessageIds.size > _SymphonyRelay.MAX_SEEN_IDS) {
595
+ const entries = Array.from(this.seenMessageIds);
596
+ const keepFrom = Math.floor(entries.length / 2);
597
+ this.seenMessageIds = new Set(entries.slice(keepFrom));
598
+ }
599
+ }
600
+ // ────────────────────────────────────────────────────────
601
+ // Local Agent Helpers
602
+ // ────────────────────────────────────────────────────────
603
+ /**
604
+ * Build a summary list of all locally registered agents.
605
+ */
606
+ getLocalAgentSummaries() {
607
+ return listAgents().map((a) => ({
608
+ id: a.id,
609
+ project: a.project,
610
+ role: a.role,
611
+ status: isAgentAsleep(a) ? "asleep" : "awake"
612
+ }));
613
+ }
614
+ // ────────────────────────────────────────────────────────
615
+ // Public API
616
+ // ────────────────────────────────────────────────────────
617
+ /**
618
+ * Gracefully shut down the relay.
619
+ *
620
+ * Sends `peer_leaving` to all connected peers, closes every WebSocket,
621
+ * clears all timers, and shuts down the server (if running).
622
+ */
623
+ stop() {
624
+ this.stopped = true;
625
+ for (const [_peerId, ws] of this.connectedPeers) {
626
+ sendFrame(ws, { type: "peer_leaving" });
627
+ ws.close();
628
+ }
629
+ this.connectedPeers.clear();
630
+ if (this.wsClient) {
631
+ this.wsClient.close();
632
+ this.wsClient = null;
633
+ }
634
+ this.stopOutboxWatcher();
635
+ this.stopKeepalive();
636
+ if (this.reconnectTimer) {
637
+ clearTimeout(this.reconnectTimer);
638
+ this.reconnectTimer = null;
639
+ }
640
+ if (this.wss) {
641
+ this.wss.close();
642
+ this.wss = null;
643
+ }
644
+ }
645
+ /**
646
+ * Return the list of currently connected peer IDs.
647
+ */
648
+ getConnectedPeers() {
649
+ return Array.from(this.connectedPeers.keys());
650
+ }
651
+ /**
652
+ * Return the aggregated agent summaries from all connected peers.
653
+ */
654
+ getRemoteAgents() {
655
+ const result = [];
656
+ const peers = loadPeers();
657
+ for (const peerId of this.connectedPeers.keys()) {
658
+ const peer = peers.find((p) => p.id === peerId);
659
+ if (peer?.agents) {
660
+ for (const agent of peer.agents) {
661
+ result.push({ ...agent, peerId });
662
+ }
663
+ }
664
+ }
665
+ return result;
666
+ }
667
+ /**
668
+ * Generate a new pairing code, invalidating the previous one.
669
+ *
670
+ * Only meaningful in server mode — clients don't generate pairing codes.
671
+ */
672
+ rotatePairingCode() {
673
+ if (this.mode !== "server") {
674
+ throw new Error('rotatePairingCode() requires mode "server"');
675
+ }
676
+ this.pairingState = generatePairing();
677
+ return this.pairingState;
678
+ }
679
+ };
680
+ export {
681
+ SCORE_DIR,
682
+ SymphonyRelay
683
+ };