agentschat-mcp 0.33.5 → 0.34.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/connector.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // connector/run.ts
3
3
  import WS from "ws";
4
+ import { readFileSync as readFileSync2 } from "node:fs";
4
5
 
5
6
  // connector/server.ts
6
7
  import { createServer } from "node:http";
@@ -104,15 +105,36 @@ function toWireEvent(msg, platform = "agentschat") {
104
105
  };
105
106
  }
106
107
 
107
- // src/mentions.ts
108
- function matchesMention(content, agentId) {
109
- if (!content || !agentId)
108
+ // src/dedup.ts
109
+ function messageDedupKey(data) {
110
+ if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
111
+ return null;
112
+ return `${data.channel_id}:${data.id}`;
113
+ }
114
+
115
+ class MessageDedup {
116
+ max;
117
+ dropOnEvict;
118
+ seen = new Set;
119
+ constructor(max = 5000, dropOnEvict = 1000) {
120
+ this.max = max;
121
+ this.dropOnEvict = dropOnEvict;
122
+ }
123
+ recordOrSkip(key) {
124
+ if (this.seen.has(key))
125
+ return true;
126
+ this.seen.add(key);
127
+ if (this.seen.size > this.max) {
128
+ const arr = [...this.seen];
129
+ this.seen.clear();
130
+ for (const item of arr.slice(this.dropOnEvict))
131
+ this.seen.add(item);
132
+ }
110
133
  return false;
111
- if (content.includes(`@${agentId}`))
112
- return true;
113
- const idEsc = agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
- const displayMentionRe = new RegExp(`@[^(\\n]+\\(${idEsc}\\)`);
115
- return displayMentionRe.test(content);
134
+ }
135
+ get size() {
136
+ return this.seen.size;
137
+ }
116
138
  }
117
139
 
118
140
  // connector/identities.ts
@@ -138,25 +160,38 @@ class IdentityTable {
138
160
  all() {
139
161
  return [...this.byBot.values()];
140
162
  }
163
+ replace(identities) {
164
+ const next = new Map;
165
+ for (const id of identities) {
166
+ if (next.has(id.botId)) {
167
+ throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
168
+ }
169
+ next.set(id.botId, id);
170
+ }
171
+ this.byBot.clear();
172
+ for (const [k, v] of next)
173
+ this.byBot.set(k, v);
174
+ }
175
+ }
176
+ function requireIdentity(identities, botId) {
177
+ const id = identities.find((id2) => id2.botId === botId);
178
+ if (!id)
179
+ throw new Error(`unknown identity: ${botId}`);
180
+ return id;
181
+ }
182
+ function matchesExactMention(content, id) {
183
+ if (!id)
184
+ return false;
185
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
141
187
  }
142
- function routeInbound(table, ctx) {
188
+ function routeInboundTargets(table, ctx) {
143
189
  if (ctx.channel_id?.startsWith("dm-")) {
144
- return ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
190
+ const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
191
+ return owner ? [owner] : [];
145
192
  }
146
193
  const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
147
- for (const mid of mentioned) {
148
- const id = table.forBot(mid);
149
- if (id)
150
- return id;
151
- }
152
- const content = ctx.content ?? "";
153
- if (content) {
154
- for (const id of table.all()) {
155
- if (matchesMention(content, id.agentId) || matchesMention(content, id.botId))
156
- return id;
157
- }
158
- }
159
- return null;
194
+ return table.all().filter((id) => mentioned.includes(id.botId) || matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
160
195
  }
161
196
  function hermesSourceProfile(id, frontedCount) {
162
197
  const named = typeof id.profile === "string" ? id.profile.trim() : "";
@@ -168,12 +203,32 @@ function hermesSourceProfile(id, frontedCount) {
168
203
  }
169
204
 
170
205
  // connector/server.ts
206
+ class PlatformHttpError extends Error {
207
+ status;
208
+ constructor(status) {
209
+ super("platform HTTP failure");
210
+ this.status = status;
211
+ }
212
+ }
213
+
214
+ class PlatformNetworkError extends Error {
215
+ constructor() {
216
+ super("platform network failure");
217
+ }
218
+ }
171
219
  function startConnector(config) {
172
220
  const log = config.logger ?? (() => {});
173
221
  const descriptor = buildDescriptor(config.descriptor);
174
222
  const legacy = !config.identities || config.identities.length === 0;
175
223
  const table = new IdentityTable(legacy ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities);
176
- const single = table.isSingle();
224
+ function safeLabel(value) {
225
+ for (const id of table.all())
226
+ for (const secret of [id.token, id.secret]) {
227
+ if (secret)
228
+ value = value.split(secret).join("[redacted]");
229
+ }
230
+ return JSON.stringify(value.slice(0, 128));
231
+ }
177
232
  const hooks = legacy ? {
178
233
  sendMessage: (_b, chatId, content, replyTo) => config.agentschat.sendMessage(chatId, content, replyTo),
179
234
  getChatInfo: (_b, chatId) => config.agentschat.getChatInfo(chatId),
@@ -182,6 +237,8 @@ function startConnector(config) {
182
237
  } : config.agentschat;
183
238
  const sockets = new Set;
184
239
  const lastAddressed = new Map;
240
+ const delivered = new MessageDedup;
241
+ const inboundQueues = new Map;
185
242
  const http = createServer((_req, res) => {
186
243
  res.writeHead(200, { "Content-Type": "application/json" });
187
244
  res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
@@ -197,7 +254,8 @@ function startConnector(config) {
197
254
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
198
255
  const payload = peekPayload(token);
199
256
  const secrets = payload ? config.secrets[payload] : undefined;
200
- const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
257
+ const secret = secrets?.find((s) => verifyUpgradeToken(token, [s]) === payload);
258
+ const gatewayId = secret ? payload : null;
201
259
  if (!gatewayId) {
202
260
  log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
203
261
  wss.handleUpgrade(req, socket, head, (ws) => {
@@ -206,13 +264,13 @@ function startConnector(config) {
206
264
  return;
207
265
  }
208
266
  wss.handleUpgrade(req, socket, head, (ws) => {
209
- onConnection(ws, gatewayId);
267
+ onConnection(ws, gatewayId, secret);
210
268
  });
211
269
  });
212
- function onConnection(ws, gatewayId) {
213
- const conn = { ws, gatewayId, fronted: new Set };
270
+ function onConnection(ws, gatewayId, secret) {
271
+ const conn = { ws, gatewayId, secret, fronted: new Set };
214
272
  sockets.add(conn);
215
- log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
273
+ log(`[connector] gateway connected: ${safeLabel(gatewayId)} (${sockets.size} total)`);
216
274
  ws.on("message", async (data) => {
217
275
  const text = data.toString();
218
276
  for (const line of text.split(`
@@ -230,7 +288,7 @@ function startConnector(config) {
230
288
  });
231
289
  ws.on("close", () => {
232
290
  sockets.delete(conn);
233
- log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
291
+ log(`[connector] gateway disconnected: ${safeLabel(gatewayId)} (${sockets.size} left)`);
234
292
  });
235
293
  ws.on("error", () => sockets.delete(conn));
236
294
  }
@@ -238,17 +296,32 @@ function startConnector(config) {
238
296
  const t = frame?.type;
239
297
  if (t === "hello") {
240
298
  const botId = String(frame.botId ?? "");
241
- const identity = single ? table.all()[0] : botId ? table.forBot(botId) : null;
242
- if (!identity) {
243
- send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
299
+ const identity = legacy ? table.all()[0] : table.forBot(botId);
300
+ const reason = frame.platform !== "agentschat" ? "unsupported_platform" : !identity ? "unknown_identity" : !legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) ? "credential_mismatch" : null;
301
+ const labels = `gatewayId=${safeLabel(conn.gatewayId)} platform=${safeLabel(frame.platform === "agentschat" ? "agentschat" : "(unsupported)")} botId=${safeLabel(identity ? botId : "(unknown)")}`;
302
+ if (reason) {
303
+ log(`[connector] hello rejected ${labels} reason=${reason}`);
304
+ conn.fronted.clear();
305
+ send(conn.ws, { type: "error", error: `hello rejected: ${reason}` });
306
+ conn.ws.close(1002, `hello rejected: ${reason}`);
244
307
  return;
245
308
  }
246
309
  conn.fronted.add(identity.botId);
310
+ if (legacy)
311
+ conn.legacyAlias = botId;
312
+ log(`[connector] hello accepted ${labels}`);
247
313
  send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
248
314
  return;
249
315
  }
250
316
  if (t === "outbound") {
251
- const result = await handleOutbound(conn, frame);
317
+ let result;
318
+ try {
319
+ result = await handleOutbound(conn, frame);
320
+ } catch (error) {
321
+ const status = error instanceof PlatformHttpError && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : undefined;
322
+ result = status !== undefined ? { success: false, error: `agentschat HTTP ${status}`, code: "platform_http_error", status, ambiguous: status >= 500 } : error instanceof PlatformNetworkError ? { success: false, error: "agentschat network failure; outcome unknown", code: "platform_network_error", ambiguous: true } : { success: false, error: "platform operation failed; outcome unknown", code: "platform_operation_failed", ambiguous: true };
323
+ log(`[connector] outbound failed: ${result.code}${status ? ` status=${status}` : ""}`);
324
+ }
252
325
  send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
253
326
  return;
254
327
  }
@@ -257,12 +330,16 @@ function startConnector(config) {
257
330
  const action = frame?.action ?? {};
258
331
  const op = action?.op;
259
332
  const chatId = action?.chat_id ?? "";
260
- const firstFronted = [...conn.fronted][0];
261
- const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
262
- const identity = single ? table.all()[0] : requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
333
+ const firstFronted = conn.fronted.size === 1 ? [...conn.fronted][0] : undefined;
334
+ let requested = frame.botId === undefined ? firstFronted ?? null : typeof frame.botId === "string" && frame.botId ? frame.botId : null;
335
+ if (legacy && requested && requested === conn.legacyAlias)
336
+ requested = "default";
337
+ let identity = requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
338
+ if (identity && (!legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) || frame.platform !== undefined && frame.platform !== "agentschat"))
339
+ identity = null;
263
340
  if (!identity) {
264
- log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
265
- return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
341
+ log(`[connector] outbound failed: no usable identity for botId=${requested ?? "?"}`);
342
+ return { success: false, error: `no usable identity for outbound (botId=${requested ?? "?"})` };
266
343
  }
267
344
  switch (op) {
268
345
  case "send": {
@@ -287,11 +364,17 @@ function startConnector(config) {
287
364
  ws.send(JSON.stringify(obj) + `
288
365
  `);
289
366
  }
367
+ const ready = new Promise((resolve, reject) => {
368
+ http.once("listening", resolve);
369
+ http.once("error", reject);
370
+ });
290
371
  http.listen(config.port, config.host ?? "127.0.0.1");
291
- const address = http.address();
292
- const port = typeof address === "object" && address ? address.port : config.port;
293
372
  return {
294
- port,
373
+ ready,
374
+ get port() {
375
+ const address = http.address();
376
+ return typeof address === "object" && address ? address.port : config.port;
377
+ },
295
378
  stop() {
296
379
  for (const c of sockets) {
297
380
  try {
@@ -303,53 +386,102 @@ function startConnector(config) {
303
386
  },
304
387
  async injectAgentsChatMessage(msg) {
305
388
  const isDm = typeof msg.channel_id === "string" && msg.channel_id.startsWith("dm-");
306
- const bySocket = msg.__botId ? table.forBot(String(msg.__botId)) : null;
307
- const target = isDm ? bySocket ?? routeInbound(table, {
308
- channel_id: msg.channel_id,
309
- dmOwnerBotId: msg.dm_owner
310
- }) ?? (table.isSingle() ? table.all()[0] : null) : routeInbound(table, {
389
+ const owner = msg.__botId ?? msg.dm_owner;
390
+ const targets = isDm ? [owner !== undefined ? typeof owner === "string" ? table.forBot(owner) : null : table.isSingle() ? table.all()[0] : null].filter((id) => !!id) : routeInboundTargets(table, {
311
391
  channel_id: msg.channel_id,
312
392
  mentioned_ids: msg.mentioned_ids,
313
393
  content: msg.content
314
394
  });
315
- if (!target) {
395
+ if (!targets.length) {
316
396
  log(`[connector] inbound unaddressed (channel=${msg.channel_id ?? "?"} mentions=${JSON.stringify(msg.mentioned_ids ?? [])}) — dropped`);
317
397
  return;
318
398
  }
319
- const baseEvent = toWireEvent(msg, "agentschat");
320
- if (!baseEvent)
321
- return;
322
- if (!isDm) {
323
- const key = `${target.botId}:${msg.channel_id}`;
324
- const since = lastAddressed.get(key);
325
- if (hooks.getChannelContext) {
326
- try {
327
- const ctx = await hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id);
328
- if (ctx && ctx.length) {
329
- baseEvent.context = ctx.slice(-10).map((c) => ({
330
- text: String(c?.text ?? "").slice(0, 500),
331
- source: { user_name: c?.user_name, user_id: c?.user_id }
332
- }));
399
+ await Promise.all(targets.map((target) => {
400
+ const key = JSON.stringify([target.botId, msg.channel_id]);
401
+ const previous = inboundQueues.get(key) ?? Promise.resolve();
402
+ const job = previous.catch(() => {}).then(async () => {
403
+ if (msg.sender_id === target.agentId)
404
+ return;
405
+ const baseEvent = toWireEvent(msg, "agentschat");
406
+ if (!baseEvent)
407
+ return;
408
+ if (![...sockets].some((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)))
409
+ return;
410
+ const deliveryKey = typeof msg.id === "string" && msg.id ? JSON.stringify([msg.channel_id, msg.id, target.botId]) : null;
411
+ if (deliveryKey && delivered.recordOrSkip(deliveryKey))
412
+ return;
413
+ if (!isDm) {
414
+ const since = lastAddressed.get(key);
415
+ if (hooks.getChannelContext) {
416
+ const controller = new AbortController;
417
+ let timer;
418
+ try {
419
+ const ctx = await Promise.race([
420
+ hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id, controller.signal),
421
+ new Promise((resolve) => {
422
+ timer = setTimeout(() => {
423
+ controller.abort();
424
+ resolve(null);
425
+ }, 1000);
426
+ })
427
+ ]);
428
+ if (ctx && ctx.length) {
429
+ baseEvent.context = ctx.slice(-10).map((c) => ({
430
+ text: String(c?.text ?? "").slice(0, 500),
431
+ source: { user_name: c?.user_name, user_id: c?.user_id }
432
+ }));
433
+ }
434
+ } catch {
435
+ log(`[connector] context fetch failed (delivering without it)`);
436
+ } finally {
437
+ if (timer)
438
+ clearTimeout(timer);
439
+ }
333
440
  }
334
- } catch (e) {
335
- log(`[connector] context fetch failed for ${key} (delivering without it): ${e}`);
441
+ if (msg.timestamp && Number.isFinite(Date.parse(msg.timestamp)) && (!since || Date.parse(msg.timestamp) > Date.parse(since)))
442
+ lastAddressed.set(key, msg.timestamp);
336
443
  }
337
- }
338
- if (msg.timestamp)
339
- lastAddressed.set(key, msg.timestamp);
340
- }
341
- for (const conn of sockets) {
342
- if (!conn.fronted.has(target.botId))
343
- continue;
344
- const event = { ...baseEvent, source: { ...baseEvent.source } };
345
- const profile = hermesSourceProfile(target, conn.fronted.size);
346
- if (profile)
347
- event.source.profile = profile;
348
- send(conn.ws, { type: "inbound", event });
349
- }
444
+ const deliverTo = [...sockets].filter((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)).slice(0, 1);
445
+ if (deliverTo.length === 0) {
446
+ log(`[connector] inbound dropped for botId=${target.botId}: no agentschat-fronted gateway socket`);
447
+ return;
448
+ }
449
+ for (const conn of deliverTo) {
450
+ const event = { ...baseEvent, source: { ...baseEvent.source } };
451
+ const profile = hermesSourceProfile(target, conn.fronted.size);
452
+ if (profile)
453
+ event.source.profile = profile;
454
+ send(conn.ws, { type: "inbound", event });
455
+ }
456
+ });
457
+ inboundQueues.set(key, job);
458
+ return job.finally(() => {
459
+ if (inboundQueues.get(key) === job)
460
+ inboundQueues.delete(key);
461
+ });
462
+ }));
350
463
  },
351
464
  connections() {
352
465
  return sockets.size;
466
+ },
467
+ reloadIdentities(next) {
468
+ if (!next || next.length === 0) {
469
+ throw new Error("reloadIdentities requires a non-empty identity list");
470
+ }
471
+ table.replace(next);
472
+ for (const conn of sockets) {
473
+ for (const botId of conn.fronted) {
474
+ const id = table.forBot(botId);
475
+ if (!id || id.gatewayId !== conn.gatewayId || id.secret !== conn.secret)
476
+ conn.fronted.delete(botId);
477
+ }
478
+ }
479
+ for (const k of Object.keys(config.secrets))
480
+ delete config.secrets[k];
481
+ for (const id of next) {
482
+ (config.secrets[id.gatewayId] ??= []).push(id.secret);
483
+ }
484
+ log(`[connector] identities reloaded: ${table.size}`);
353
485
  }
354
486
  };
355
487
  }
@@ -424,38 +556,6 @@ function normalizeTimestampForCursor(ts, mode) {
424
556
  return ts;
425
557
  }
426
558
 
427
- // src/dedup.ts
428
- function messageDedupKey(data) {
429
- if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
430
- return null;
431
- return `${data.channel_id}:${data.id}`;
432
- }
433
-
434
- class MessageDedup {
435
- max;
436
- dropOnEvict;
437
- seen = new Set;
438
- constructor(max = 5000, dropOnEvict = 1000) {
439
- this.max = max;
440
- this.dropOnEvict = dropOnEvict;
441
- }
442
- recordOrSkip(key) {
443
- if (this.seen.has(key))
444
- return true;
445
- this.seen.add(key);
446
- if (this.seen.size > this.max) {
447
- const arr = [...this.seen];
448
- this.seen.clear();
449
- for (const item of arr.slice(this.dropOnEvict))
450
- this.seen.add(item);
451
- }
452
- return false;
453
- }
454
- get size() {
455
- return this.seen.size;
456
- }
457
- }
458
-
459
559
  // connector/backfill.ts
460
560
  function planBackfill(after, msgs, agentId) {
461
561
  const list = Array.isArray(msgs) ? msgs : [];
@@ -479,6 +579,93 @@ function planBackfill(after, msgs, agentId) {
479
579
  return { replay };
480
580
  }
481
581
 
582
+ // connector/ingest.ts
583
+ function ingestAgentsChatFrame(id, frame, deps) {
584
+ if (frame.content !== "__typing__") {
585
+ deps.advanceCursor(id, frame.channel_id, frame.timestamp);
586
+ }
587
+ const key = messageDedupKey(frame);
588
+ const isDm = frame.channel_id?.startsWith("dm-");
589
+ const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
590
+ if (scopedKey && deps.dedup.recordOrSkip(scopedKey))
591
+ return false;
592
+ return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
593
+ }
594
+
595
+ // src/heartbeat.ts
596
+ var WS_CONNECTING = 0;
597
+ var WS_OPEN = 1;
598
+ var WS_CLOSING = 2;
599
+ var WS_CLOSED = 3;
600
+
601
+ class HeartbeatMonitor {
602
+ deps;
603
+ pingInterval;
604
+ pongTimeout;
605
+ connectTimeout;
606
+ lastPong;
607
+ timer = null;
608
+ connectingSince = null;
609
+ reconnecting = false;
610
+ constructor(deps, pingInterval = 30000, pongTimeout = 90000, connectTimeout = 30000) {
611
+ this.deps = deps;
612
+ this.pingInterval = pingInterval;
613
+ this.pongTimeout = pongTimeout;
614
+ this.connectTimeout = connectTimeout;
615
+ this.lastPong = Date.now();
616
+ }
617
+ receivedPong() {
618
+ this.lastPong = Date.now();
619
+ this.connectingSince = null;
620
+ this.reconnecting = false;
621
+ }
622
+ start() {
623
+ this.stop();
624
+ this.lastPong = Date.now();
625
+ this.connectingSince = null;
626
+ this.reconnecting = false;
627
+ this.timer = setInterval(() => this.tick(), this.pingInterval);
628
+ }
629
+ stop() {
630
+ if (this.timer) {
631
+ clearInterval(this.timer);
632
+ this.timer = null;
633
+ }
634
+ }
635
+ resetReconnecting() {
636
+ this.reconnecting = false;
637
+ }
638
+ tick() {
639
+ const state = this.deps.getReadyState();
640
+ if (state === WS_OPEN) {
641
+ this.connectingSince = null;
642
+ if (Date.now() - this.lastPong > this.pongTimeout) {
643
+ this.safeReconnect("pong timeout");
644
+ return;
645
+ }
646
+ this.deps.sendPing();
647
+ return;
648
+ }
649
+ if (state === WS_CONNECTING) {
650
+ if (!this.connectingSince) {
651
+ this.connectingSince = Date.now();
652
+ } else if (Date.now() - this.connectingSince > this.connectTimeout) {
653
+ this.connectingSince = null;
654
+ this.safeReconnect("connect timeout");
655
+ }
656
+ return;
657
+ }
658
+ this.connectingSince = null;
659
+ this.safeReconnect(state === WS_CLOSING ? "stuck closing" : "closed");
660
+ }
661
+ safeReconnect(reason) {
662
+ if (this.reconnecting)
663
+ return;
664
+ this.reconnecting = true;
665
+ this.deps.reconnect();
666
+ }
667
+ }
668
+
482
669
  // connector/run.ts
483
670
  var log = (m) => process.stderr.write(`[agentschat-connector] ${m}
484
671
  `);
@@ -494,34 +681,52 @@ var API = (process.env.AGENTCHAT_API_URL || "https://agents-chat.com").replace(/
494
681
  var WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws";
495
682
  var PORT = Number(process.env.RELAY_PORT || 8765);
496
683
  var HOST = process.env.RELAY_HOST || "127.0.0.1";
497
- function resolveIdentities() {
498
- const raw = (process.env.RELAY_IDENTITIES || "").trim();
499
- if (raw) {
500
- let parsed;
501
- try {
502
- parsed = JSON.parse(raw);
503
- } catch (e) {
504
- log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
505
- process.exit(1);
506
- }
507
- if (!Array.isArray(parsed) || parsed.length === 0) {
508
- log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
509
- process.exit(1);
684
+ function parseIdentitiesJson(raw, source) {
685
+ let parsed;
686
+ try {
687
+ parsed = JSON.parse(raw);
688
+ } catch {
689
+ throw new Error(`${source} is not valid JSON`);
690
+ }
691
+ if (!Array.isArray(parsed) || parsed.length === 0) {
692
+ throw new Error(`${source} must be a non-empty JSON array`);
693
+ }
694
+ const seen = new Set;
695
+ for (const [index, it] of parsed.entries()) {
696
+ const invalid = ["botId", "token", "gatewayId", "secret"].filter((k) => typeof it?.[k] !== "string" || !it[k].trim());
697
+ for (const k of ["agentId", "profile"]) {
698
+ if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim()))
699
+ invalid.push(k);
510
700
  }
511
- for (const it of parsed) {
512
- if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
513
- log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
514
- process.exit(1);
515
- }
701
+ if (invalid.length)
702
+ throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
703
+ if (seen.has(it.botId))
704
+ throw new Error(`${source} entry ${index}: duplicate botId`);
705
+ seen.add(it.botId);
706
+ }
707
+ return parsed.map((it) => ({
708
+ botId: String(it.botId),
709
+ agentId: String(it.agentId ?? it.botId),
710
+ token: String(it.token),
711
+ gatewayId: String(it.gatewayId),
712
+ secret: String(it.secret),
713
+ ...it.profile ? { profile: String(it.profile) } : {}
714
+ }));
715
+ }
716
+ function loadIdentities() {
717
+ const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
718
+ if (file) {
719
+ let raw2;
720
+ try {
721
+ raw2 = readFileSync2(file, "utf8");
722
+ } catch {
723
+ throw new Error("RELAY_IDENTITIES_FILE could not be read");
516
724
  }
517
- return parsed.map((it) => ({
518
- botId: String(it.botId),
519
- agentId: String(it.agentId ?? it.botId),
520
- token: String(it.token),
521
- gatewayId: String(it.gatewayId),
522
- secret: String(it.secret),
523
- ...it.profile ? { profile: String(it.profile) } : {}
524
- }));
725
+ return parseIdentitiesJson(raw2, "RELAY_IDENTITIES_FILE");
726
+ }
727
+ const raw = (process.env.RELAY_IDENTITIES || "").trim();
728
+ if (raw) {
729
+ return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
525
730
  }
526
731
  const agentId = need("AGENTCHAT_AGENT_ID");
527
732
  const token = need("AGENTCHAT_TOKEN");
@@ -529,17 +734,56 @@ function resolveIdentities() {
529
734
  const secret = need("RELAY_GATEWAY_SECRET");
530
735
  return [{ botId: agentId, agentId, token, gatewayId, secret }];
531
736
  }
532
- var identities = resolveIdentities();
533
- var single = identities.length === 1;
737
+ function resolveIdentitiesAtStartup() {
738
+ try {
739
+ return loadIdentities();
740
+ } catch (e) {
741
+ log(`ERROR: ${e?.message ?? e}`);
742
+ process.exit(1);
743
+ }
744
+ }
745
+ var identities = resolveIdentitiesAtStartup();
534
746
  var secrets = {};
535
747
  for (const id of identities) {
536
748
  (secrets[id.gatewayId] ??= []).push(id.secret);
537
749
  }
538
750
  var broadcast = null;
539
751
  var socketsByBot = new Map;
752
+ var reconnectTimerByBot = new Map;
753
+ var currentIdentity = (id) => !process.__shutdown && identities.includes(id);
754
+ function cancelReconnect(botId) {
755
+ const timer = reconnectTimerByBot.get(botId);
756
+ if (timer)
757
+ clearTimeout(timer);
758
+ reconnectTimerByBot.delete(botId);
759
+ }
760
+ function scheduleReconnect(id, delay) {
761
+ if (!currentIdentity(id))
762
+ return;
763
+ cancelReconnect(id.botId);
764
+ const timer = setTimeout(() => {
765
+ if (reconnectTimerByBot.get(id.botId) !== timer)
766
+ return;
767
+ reconnectTimerByBot.delete(id.botId);
768
+ if (currentIdentity(id))
769
+ connectIdentity(id);
770
+ }, delay);
771
+ reconnectTimerByBot.set(id.botId, timer);
772
+ }
540
773
  var backoffByBot = new Map;
541
774
  var backfillTimerByBot = new Map;
775
+ var heartbeatsByBot = new Map;
542
776
  var dedup = new MessageDedup;
777
+ var HB_PING_MS = Math.max(5000, Number(process.env.AGENTSCHAT_CONNECTOR_PING_MS || 15000));
778
+ var HB_PONG_MS = Math.max(HB_PING_MS + 5000, Number(process.env.AGENTSCHAT_CONNECTOR_PONG_TIMEOUT_MS || 45000));
779
+ var HB_CONNECT_MS = Math.max(5000, Number(process.env.AGENTSCHAT_CONNECTOR_CONNECT_TIMEOUT_MS || 30000));
780
+ function stopHeartbeat(botId) {
781
+ const hb = heartbeatsByBot.get(botId);
782
+ if (!hb)
783
+ return;
784
+ hb.stop();
785
+ heartbeatsByBot.delete(botId);
786
+ }
543
787
  var CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
544
788
  var cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
545
789
  var cursors = new Map;
@@ -597,6 +841,8 @@ async function joinMemberships(ws, id) {
597
841
  return;
598
842
  }
599
843
  const body = await r.json();
844
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
845
+ return;
600
846
  const channels = Array.isArray(body) ? body : body.channels || [];
601
847
  const joined = [];
602
848
  for (const ch of channels) {
@@ -611,7 +857,7 @@ async function joinMemberships(ws, id) {
611
857
  clearTimeout(prev);
612
858
  const t = setTimeout(() => {
613
859
  backfillTimerByBot.delete(id.botId);
614
- if (process.__shutdown)
860
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
615
861
  return;
616
862
  backfillIdentity(id, joined);
617
863
  }, 2000);
@@ -624,6 +870,8 @@ async function joinMemberships(ws, id) {
624
870
  async function backfillIdentity(id, channelIds) {
625
871
  const s = cursorFor(id);
626
872
  for (const channelId of channelIds) {
873
+ if (!currentIdentity(id))
874
+ return;
627
875
  try {
628
876
  const after = s.map.get(channelId);
629
877
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
@@ -633,6 +881,8 @@ async function backfillIdentity(id, channelIds) {
633
881
  if (!r.ok)
634
882
  continue;
635
883
  const msgs = (await r.json())?.messages ?? [];
884
+ if (!currentIdentity(id))
885
+ return;
636
886
  const plan = planBackfill(after, msgs, id.agentId);
637
887
  if (plan.seed) {
638
888
  s.map.set(channelId, plan.seed);
@@ -644,11 +894,9 @@ async function backfillIdentity(id, channelIds) {
644
894
  log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
645
895
  for (const m of plan.replay) {
646
896
  const frame = { ...m, type: "message", channel_id: m.channel_id ?? channelId, __botId: id.botId, __source: "backfill" };
647
- const key = messageDedupKey(frame);
648
- if (key && dedup.recordOrSkip(key))
649
- continue;
650
- advanceCursor(id, frame.channel_id, frame.timestamp);
651
- broadcast?.(frame);
897
+ if (ingestAgentsChatFrame(id, frame, { advanceCursor, dedup })) {
898
+ broadcast?.(frame);
899
+ }
652
900
  }
653
901
  } catch (e) {
654
902
  log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
@@ -656,49 +904,101 @@ async function backfillIdentity(id, channelIds) {
656
904
  }
657
905
  }
658
906
  function connectIdentity(id) {
907
+ if (!currentIdentity(id))
908
+ return;
909
+ cancelReconnect(id.botId);
910
+ stopHeartbeat(id.botId);
911
+ const prev = socketsByBot.get(id.botId);
912
+ if (prev) {
913
+ try {
914
+ prev.removeAllListeners("close");
915
+ prev.close();
916
+ } catch {}
917
+ socketsByBot.delete(id.botId);
918
+ }
919
+ let heartbeatForced = false;
659
920
  const ws = new WS(WS_URL);
660
921
  socketsByBot.set(id.botId, ws);
922
+ const hb = new HeartbeatMonitor({
923
+ sendPing: () => {
924
+ try {
925
+ if (ws.readyState === WS.OPEN) {
926
+ ws.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() }));
927
+ }
928
+ } catch {}
929
+ },
930
+ reconnect: () => {
931
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced)
932
+ return;
933
+ heartbeatForced = true;
934
+ log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
935
+ backoffByBot.set(id.botId, 1000);
936
+ try {
937
+ ws.removeAllListeners("close");
938
+ ws.close();
939
+ } catch {}
940
+ socketsByBot.delete(id.botId);
941
+ stopHeartbeat(id.botId);
942
+ scheduleReconnect(id, 500);
943
+ },
944
+ getReadyState: () => ws.readyState ?? WS_CLOSED
945
+ }, HB_PING_MS, HB_PONG_MS, HB_CONNECT_MS);
946
+ heartbeatsByBot.set(id.botId, hb);
947
+ hb.start();
661
948
  ws.on("open", () => {
949
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) {
950
+ ws.close();
951
+ return;
952
+ }
662
953
  backoffByBot.set(id.botId, 1000);
663
954
  try {
664
955
  ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
665
956
  } catch {}
666
957
  });
667
958
  ws.on("message", (raw) => {
959
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
960
+ return;
668
961
  let data;
669
962
  try {
670
963
  data = JSON.parse(String(raw));
671
964
  } catch {
672
965
  return;
673
966
  }
967
+ if (data.type === "pong") {
968
+ hb.receivedPong();
969
+ return;
970
+ }
674
971
  if (data.type === "auth_ok") {
972
+ hb.receivedPong();
675
973
  log(`connected to agentschat as ${id.agentId}`);
676
974
  joinMemberships(ws, id);
677
975
  return;
678
976
  }
679
977
  if (data.type === "channel_created" && data.channel_id) {
978
+ hb.receivedPong();
680
979
  joinChannel(ws, id, data.channel_id, data.name);
681
980
  return;
682
981
  }
683
982
  if (data.type === "message") {
983
+ hb.receivedPong();
684
984
  if (!data.__source)
685
985
  data.__source = "live";
686
- const key = messageDedupKey(data);
687
- if (key && dedup.recordOrSkip(key))
688
- return;
689
- if (data.content !== "__typing__")
690
- advanceCursor(id, data.channel_id, data.timestamp);
691
- if (data.sender_id !== id.agentId && data.content !== "__typing__") {
986
+ if (ingestAgentsChatFrame(id, data, { advanceCursor, dedup })) {
692
987
  broadcast?.({ ...data, __botId: id.botId });
693
988
  }
694
989
  }
695
990
  });
696
991
  ws.on("close", () => {
992
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
993
+ return;
994
+ stopHeartbeat(id.botId);
697
995
  if (process.__shutdown)
698
996
  return;
997
+ if (heartbeatForced)
998
+ return;
699
999
  const delay = backoffByBot.get(id.botId) ?? 1000;
700
1000
  log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
701
- setTimeout(() => connectIdentity(id), delay);
1001
+ scheduleReconnect(id, delay);
702
1002
  backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
703
1003
  });
704
1004
  ws.on("error", (e) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
@@ -710,19 +1010,21 @@ var connector = startConnector({
710
1010
  identities,
711
1011
  agentschat: {
712
1012
  async sendMessage(botId, chatId, content, replyTo) {
713
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1013
+ const id = requireIdentity(identities, botId);
714
1014
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
715
1015
  method: "POST",
716
1016
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
717
1017
  body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...replyTo ? { parent_id: replyTo } : {} })
1018
+ }).catch(() => {
1019
+ throw new PlatformNetworkError;
718
1020
  });
719
1021
  if (!res.ok)
720
- throw new Error(`agentschat send failed: ${res.status}`);
1022
+ throw new PlatformHttpError(res.status);
721
1023
  const data = await res.json();
722
1024
  return { id: data?.id };
723
1025
  },
724
1026
  async getChatInfo(botId, chatId) {
725
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1027
+ const id = requireIdentity(identities, botId);
726
1028
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
727
1029
  headers: { Authorization: `Bearer ${id.token}` }
728
1030
  });
@@ -732,7 +1034,7 @@ var connector = startConnector({
732
1034
  return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
733
1035
  },
734
1036
  async sendTyping(botId, chatId) {
735
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1037
+ const id = requireIdentity(identities, botId);
736
1038
  const ws = socketsByBot.get(id.botId);
737
1039
  if (ws && ws.readyState === WS.OPEN) {
738
1040
  try {
@@ -740,9 +1042,10 @@ var connector = startConnector({
740
1042
  } catch {}
741
1043
  }
742
1044
  },
743
- async getChannelContext(botId, chatId, sinceTs, excludeId) {
744
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1045
+ async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
1046
+ const id = requireIdentity(identities, botId);
745
1047
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
1048
+ signal,
746
1049
  headers: { Authorization: `Bearer ${id.token}` }
747
1050
  });
748
1051
  if (!res.ok)
@@ -762,12 +1065,70 @@ var connector = startConnector({
762
1065
  logger: log
763
1066
  });
764
1067
  broadcast = (msg) => connector.injectAgentsChatMessage(msg);
1068
+ await connector.ready;
765
1069
  log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
766
1070
  for (const id of identities)
767
1071
  connectIdentity(id);
1072
+ function reloadIdentitiesFromConfig() {
1073
+ const next = loadIdentities().map((id) => identities.find((prev) => prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token && prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
1074
+ const prevByBot = new Map(identities.map((i) => [i.botId, i]));
1075
+ const nextByBot = new Map(next.map((i) => [i.botId, i]));
1076
+ const added = next.filter((i) => !prevByBot.has(i.botId));
1077
+ const removed = identities.filter((i) => !nextByBot.has(i.botId));
1078
+ const kept = next.filter((i) => prevByBot.has(i.botId));
1079
+ connector.reloadIdentities(next);
1080
+ identities = next;
1081
+ for (const id of removed) {
1082
+ cancelReconnect(id.botId);
1083
+ log(`hot-reload: disconnecting removed identity ${id.botId}`);
1084
+ stopHeartbeat(id.botId);
1085
+ const t = backfillTimerByBot.get(id.botId);
1086
+ if (t) {
1087
+ clearTimeout(t);
1088
+ backfillTimerByBot.delete(id.botId);
1089
+ }
1090
+ const ws = socketsByBot.get(id.botId);
1091
+ if (ws) {
1092
+ try {
1093
+ ws.removeAllListeners("close");
1094
+ ws.close();
1095
+ } catch {}
1096
+ socketsByBot.delete(id.botId);
1097
+ }
1098
+ backoffByBot.delete(id.botId);
1099
+ }
1100
+ for (const id of kept) {
1101
+ const prev = prevByBot.get(id.botId);
1102
+ if (prev !== id) {
1103
+ cancelReconnect(id.botId);
1104
+ const timer = backfillTimerByBot.get(id.botId);
1105
+ if (timer)
1106
+ clearTimeout(timer);
1107
+ backfillTimerByBot.delete(id.botId);
1108
+ log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
1109
+ connectIdentity(id);
1110
+ }
1111
+ }
1112
+ for (const id of added) {
1113
+ log(`hot-reload: connecting new identity ${id.botId}`);
1114
+ connectIdentity(id);
1115
+ }
1116
+ log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
1117
+ }
1118
+ process.on("SIGHUP", () => {
1119
+ try {
1120
+ reloadIdentitiesFromConfig();
1121
+ } catch (e) {
1122
+ log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
1123
+ }
1124
+ });
768
1125
  for (const sig of ["SIGINT", "SIGTERM"]) {
769
1126
  process.on(sig, () => {
770
1127
  process.__shutdown = true;
1128
+ for (const botId of reconnectTimerByBot.keys())
1129
+ cancelReconnect(botId);
1130
+ for (const botId of [...heartbeatsByBot.keys()])
1131
+ stopHeartbeat(botId);
771
1132
  for (const ws of socketsByBot.values()) {
772
1133
  try {
773
1134
  ws.close();