@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.
- package/dist/chunk-KVDYJLTC.js +121 -0
- package/dist/{chunk-SOBTKFSP.js → chunk-S2HO5MLR.js} +5 -0
- package/dist/index.js +40 -19
- package/dist/mcp.js +71 -1
- package/dist/peers-RFQCWVLV.js +82 -0
- package/dist/{platform-server-KHL6ZPPN.js → platform-server-H7Y6Q7O4.js} +1 -1
- package/dist/{serve-JVXSRSUB.js → serve-KKEHE44G.js} +1 -1
- package/dist/{symphony-EYRGGVNE.js → symphony-6K3HD7AW.js} +349 -28
- package/dist/{symphony-QWOEKZMC.js → symphony-YCHBYN3E.js} +19 -1
- package/dist/symphony-peers-APOGJPF4.js +120 -0
- package/dist/symphony-peers-HSY3RI3S.js +34 -0
- package/dist/symphony-relay-GTAJRCVF.js +683 -0
- package/dist/university-content/courses/para-501.json +84 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|