agentschat-mcp 0.33.5 → 0.33.7

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.
@@ -81,6 +81,7 @@ secret — see `gateway/relay/auth.py`).
81
81
  | `hello` → `descriptor` handshake (one per identity in multiplex) | gateway ↔ connector | ✅ |
82
82
  | `inbound` — DM always; group only on content @mention; @-mentions carry a `context` window; `source.profile` only when this gateway hellos >1 identity (or identity.profile is set) | connector → gateway | ✅ |
83
83
  | `outbound` op `send` → `outbound_result` (per-identity token, advertised-set checked) | gateway → connector | ✅ |
84
+ | AgentsChat WS heartbeat | connector → hub ping/pong | ✅ (same HeartbeatMonitor as stdio MCP: 15s/45s) |
84
85
  | `outbound` op `typing` | gateway → connector | ✅ |
85
86
  | `outbound` op `get_chat_info` | gateway → connector | ✅ |
86
87
  | edit / media / react / prompt / threads / follow_up / scale-to-zero / arbitrary multi-tenant | — | ❌ not yet (additive) |
@@ -0,0 +1,38 @@
1
+ import { MessageDedup, messageDedupKey } from "../src/dedup.ts";
2
+
3
+ export type IngestIdentity = { botId: string; agentId: string };
4
+
5
+ export type IngestFrame = {
6
+ id?: string;
7
+ channel_id?: string;
8
+ timestamp?: string;
9
+ content?: string;
10
+ sender_id?: string;
11
+ };
12
+
13
+ /**
14
+ * Ingest one live/backfill AgentsChat frame for a connector identity.
15
+ *
16
+ * Cursors are per-identity; MessageDedup in the connector is process-global
17
+ * across multiplex bots. Always advance THIS identity's cursor before consulting
18
+ * dedup so a message already seen by another bot cannot freeze this bot's
19
+ * reconnect watermark (the stuck `backfill …: N missed` loop).
20
+ *
21
+ * Returns whether the caller should broadcast to the gateway (first global
22
+ * sighting, excluding self-sends and typing).
23
+ */
24
+ export function ingestAgentsChatFrame(
25
+ id: IngestIdentity,
26
+ frame: IngestFrame,
27
+ deps: {
28
+ advanceCursor: (id: IngestIdentity, channelId: string | undefined, timestamp: string | undefined) => void;
29
+ dedup: MessageDedup;
30
+ },
31
+ ): boolean {
32
+ if (frame.content !== "__typing__") {
33
+ deps.advanceCursor(id, frame.channel_id, frame.timestamp);
34
+ }
35
+ const key = messageDedupKey(frame as { id?: string; channel_id?: string });
36
+ if (key && deps.dedup.recordOrSkip(key)) return false;
37
+ return frame.sender_id !== id.agentId && frame.content !== "__typing__";
38
+ }
package/connector/run.ts CHANGED
@@ -30,8 +30,10 @@ import { join } from "node:path";
30
30
  import { redactSecrets } from "../src/redact.ts";
31
31
  import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
32
32
  import { normalizeTimestampForCursor } from "../src/timestamps.ts";
33
- import { MessageDedup, messageDedupKey } from "../src/dedup.ts";
33
+ import { MessageDedup } from "../src/dedup.ts";
34
34
  import { planBackfill } from "./backfill.ts";
35
+ import { ingestAgentsChatFrame } from "./ingest.ts";
36
+ import { HeartbeatMonitor, WS_CLOSED } from "../src/heartbeat.ts";
35
37
 
36
38
  const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
37
39
 
@@ -103,8 +105,21 @@ let broadcast: ((msg: any) => void) | null = null;
103
105
  const socketsByBot = new Map<string, WS>();
104
106
  const backoffByBot = new Map<string, number>();
105
107
  const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
108
+ const heartbeatsByBot = new Map<string, HeartbeatMonitor>();
106
109
  const dedup = new MessageDedup();
107
110
 
111
+ // Same defaults as stdio MCP (src/server.ts): 15s ping / 45s pong / 30s connect.
112
+ const HB_PING_MS = Math.max(5_000, Number(process.env.AGENTSCHAT_CONNECTOR_PING_MS || 15_000));
113
+ const HB_PONG_MS = Math.max(HB_PING_MS + 5_000, Number(process.env.AGENTSCHAT_CONNECTOR_PONG_TIMEOUT_MS || 45_000));
114
+ const HB_CONNECT_MS = Math.max(5_000, Number(process.env.AGENTSCHAT_CONNECTOR_CONNECT_TIMEOUT_MS || 30_000));
115
+
116
+ function stopHeartbeat(botId: string) {
117
+ const hb = heartbeatsByBot.get(botId);
118
+ if (!hb) return;
119
+ hb.stop();
120
+ heartbeatsByBot.delete(botId);
121
+ }
122
+
108
123
  // Per-identity last-seen cursor (channel → timestamp). Same files/shape as
109
124
  // stdio MCP (`last-seen-msg-ts-<agent>.json`): reconnect REST-replays the
110
125
  // gap after this watermark; empty cursor seeds from newest and does not replay.
@@ -217,10 +232,9 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
217
232
  log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
218
233
  for (const m of plan.replay) {
219
234
  const frame = { ...m, type: "message", channel_id: m.channel_id ?? channelId, __botId: id.botId, __source: "backfill" };
220
- const key = messageDedupKey(frame);
221
- if (key && dedup.recordOrSkip(key)) continue;
222
- advanceCursor(id, frame.channel_id, frame.timestamp);
223
- broadcast?.(frame);
235
+ if (ingestAgentsChatFrame(id, frame, { advanceCursor, dedup })) {
236
+ broadcast?.(frame);
237
+ }
224
238
  }
225
239
  } catch (e: any) {
226
240
  log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
@@ -229,8 +243,55 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
229
243
  }
230
244
 
231
245
  function connectIdentity(id: Identity) {
246
+ // Replace any prior socket/heartbeat for this identity (heartbeat-forced
247
+ // reconnect calls us again after closing the old socket).
248
+ stopHeartbeat(id.botId);
249
+ const prev = socketsByBot.get(id.botId);
250
+ if (prev) {
251
+ try {
252
+ prev.removeAllListeners("close");
253
+ prev.close();
254
+ } catch {}
255
+ socketsByBot.delete(id.botId);
256
+ }
257
+
258
+ let heartbeatForced = false;
232
259
  const ws = new WS(WS_URL);
233
260
  socketsByBot.set(id.botId, ws);
261
+
262
+ const hb = new HeartbeatMonitor(
263
+ {
264
+ sendPing: () => {
265
+ try {
266
+ if (ws.readyState === WS.OPEN) {
267
+ ws.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() }));
268
+ }
269
+ } catch {}
270
+ },
271
+ reconnect: () => {
272
+ if ((process as any).__shutdown || heartbeatForced) return;
273
+ heartbeatForced = true;
274
+ log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
275
+ backoffByBot.set(id.botId, 1000);
276
+ try {
277
+ ws.removeAllListeners("close");
278
+ ws.close();
279
+ } catch {}
280
+ socketsByBot.delete(id.botId);
281
+ stopHeartbeat(id.botId);
282
+ setTimeout(() => {
283
+ if (!(process as any).__shutdown) connectIdentity(id);
284
+ }, 500);
285
+ },
286
+ getReadyState: () => ws.readyState ?? WS_CLOSED,
287
+ },
288
+ HB_PING_MS,
289
+ HB_PONG_MS,
290
+ HB_CONNECT_MS,
291
+ );
292
+ heartbeatsByBot.set(id.botId, hb);
293
+ hb.start();
294
+
234
295
  ws.on("open", () => {
235
296
  backoffByBot.set(id.botId, 1000);
236
297
  try {
@@ -240,29 +301,34 @@ function connectIdentity(id: Identity) {
240
301
  ws.on("message", (raw: any) => {
241
302
  let data: any;
242
303
  try { data = JSON.parse(String(raw)); } catch { return; }
304
+ if (data.type === "pong") {
305
+ hb.receivedPong();
306
+ return;
307
+ }
243
308
  if (data.type === "auth_ok") {
309
+ hb.receivedPong(); // alive signal, same as stdio MCP
244
310
  log(`connected to agentschat as ${id.agentId}`);
245
311
  void joinMemberships(ws, id);
246
312
  return;
247
313
  }
248
314
  if (data.type === "channel_created" && data.channel_id) {
315
+ hb.receivedPong();
249
316
  joinChannel(ws, id, data.channel_id, data.name);
250
317
  return;
251
318
  }
252
319
  if (data.type === "message") {
320
+ hb.receivedPong(); // any live traffic counts as liveness
253
321
  if (!data.__source) data.__source = "live";
254
- const key = messageDedupKey(data);
255
- if (key && dedup.recordOrSkip(key)) return;
256
- // Advance on every frame (including unaddressed / self) so reconnect
257
- // backfill resumes after what we actually saw, not only what we injected.
258
- if (data.content !== "__typing__") advanceCursor(id, data.channel_id, data.timestamp);
259
- if (data.sender_id !== id.agentId && data.content !== "__typing__") {
322
+ // Cursor advances even when shared multiplex dedup skips broadcast.
323
+ if (ingestAgentsChatFrame(id, data, { advanceCursor, dedup })) {
260
324
  broadcast?.({ ...data, __botId: id.botId });
261
325
  }
262
326
  }
263
327
  });
264
328
  ws.on("close", () => {
329
+ stopHeartbeat(id.botId);
265
330
  if ((process as any).__shutdown) return;
331
+ if (heartbeatForced) return; // reconnect already scheduled by HeartbeatMonitor
266
332
  const delay = backoffByBot.get(id.botId) ?? 1000;
267
333
  log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
268
334
  setTimeout(() => connectIdentity(id), delay);
@@ -340,6 +406,7 @@ for (const id of identities) connectIdentity(id);
340
406
  for (const sig of ["SIGINT", "SIGTERM"] as const) {
341
407
  process.on(sig, () => {
342
408
  (process as any).__shutdown = true;
409
+ for (const botId of [...heartbeatsByBot.keys()]) stopHeartbeat(botId);
343
410
  for (const ws of socketsByBot.values()) {
344
411
  try { ws.close(); } catch {}
345
412
  }
package/dist/connector.js CHANGED
@@ -479,6 +479,91 @@ function planBackfill(after, msgs, agentId) {
479
479
  return { replay };
480
480
  }
481
481
 
482
+ // connector/ingest.ts
483
+ function ingestAgentsChatFrame(id, frame, deps) {
484
+ if (frame.content !== "__typing__") {
485
+ deps.advanceCursor(id, frame.channel_id, frame.timestamp);
486
+ }
487
+ const key = messageDedupKey(frame);
488
+ if (key && deps.dedup.recordOrSkip(key))
489
+ return false;
490
+ return frame.sender_id !== id.agentId && frame.content !== "__typing__";
491
+ }
492
+
493
+ // src/heartbeat.ts
494
+ var WS_CONNECTING = 0;
495
+ var WS_OPEN = 1;
496
+ var WS_CLOSING = 2;
497
+ var WS_CLOSED = 3;
498
+
499
+ class HeartbeatMonitor {
500
+ deps;
501
+ pingInterval;
502
+ pongTimeout;
503
+ connectTimeout;
504
+ lastPong;
505
+ timer = null;
506
+ connectingSince = null;
507
+ reconnecting = false;
508
+ constructor(deps, pingInterval = 30000, pongTimeout = 90000, connectTimeout = 30000) {
509
+ this.deps = deps;
510
+ this.pingInterval = pingInterval;
511
+ this.pongTimeout = pongTimeout;
512
+ this.connectTimeout = connectTimeout;
513
+ this.lastPong = Date.now();
514
+ }
515
+ receivedPong() {
516
+ this.lastPong = Date.now();
517
+ this.connectingSince = null;
518
+ this.reconnecting = false;
519
+ }
520
+ start() {
521
+ this.stop();
522
+ this.lastPong = Date.now();
523
+ this.connectingSince = null;
524
+ this.reconnecting = false;
525
+ this.timer = setInterval(() => this.tick(), this.pingInterval);
526
+ }
527
+ stop() {
528
+ if (this.timer) {
529
+ clearInterval(this.timer);
530
+ this.timer = null;
531
+ }
532
+ }
533
+ resetReconnecting() {
534
+ this.reconnecting = false;
535
+ }
536
+ tick() {
537
+ const state = this.deps.getReadyState();
538
+ if (state === WS_OPEN) {
539
+ this.connectingSince = null;
540
+ if (Date.now() - this.lastPong > this.pongTimeout) {
541
+ this.safeReconnect("pong timeout");
542
+ return;
543
+ }
544
+ this.deps.sendPing();
545
+ return;
546
+ }
547
+ if (state === WS_CONNECTING) {
548
+ if (!this.connectingSince) {
549
+ this.connectingSince = Date.now();
550
+ } else if (Date.now() - this.connectingSince > this.connectTimeout) {
551
+ this.connectingSince = null;
552
+ this.safeReconnect("connect timeout");
553
+ }
554
+ return;
555
+ }
556
+ this.connectingSince = null;
557
+ this.safeReconnect(state === WS_CLOSING ? "stuck closing" : "closed");
558
+ }
559
+ safeReconnect(reason) {
560
+ if (this.reconnecting)
561
+ return;
562
+ this.reconnecting = true;
563
+ this.deps.reconnect();
564
+ }
565
+ }
566
+
482
567
  // connector/run.ts
483
568
  var log = (m) => process.stderr.write(`[agentschat-connector] ${m}
484
569
  `);
@@ -539,7 +624,18 @@ var broadcast = null;
539
624
  var socketsByBot = new Map;
540
625
  var backoffByBot = new Map;
541
626
  var backfillTimerByBot = new Map;
627
+ var heartbeatsByBot = new Map;
542
628
  var dedup = new MessageDedup;
629
+ var HB_PING_MS = Math.max(5000, Number(process.env.AGENTSCHAT_CONNECTOR_PING_MS || 15000));
630
+ var HB_PONG_MS = Math.max(HB_PING_MS + 5000, Number(process.env.AGENTSCHAT_CONNECTOR_PONG_TIMEOUT_MS || 45000));
631
+ var HB_CONNECT_MS = Math.max(5000, Number(process.env.AGENTSCHAT_CONNECTOR_CONNECT_TIMEOUT_MS || 30000));
632
+ function stopHeartbeat(botId) {
633
+ const hb = heartbeatsByBot.get(botId);
634
+ if (!hb)
635
+ return;
636
+ hb.stop();
637
+ heartbeatsByBot.delete(botId);
638
+ }
543
639
  var CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
544
640
  var cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
545
641
  var cursors = new Map;
@@ -644,11 +740,9 @@ async function backfillIdentity(id, channelIds) {
644
740
  log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
645
741
  for (const m of plan.replay) {
646
742
  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);
743
+ if (ingestAgentsChatFrame(id, frame, { advanceCursor, dedup })) {
744
+ broadcast?.(frame);
745
+ }
652
746
  }
653
747
  } catch (e) {
654
748
  log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
@@ -656,8 +750,47 @@ async function backfillIdentity(id, channelIds) {
656
750
  }
657
751
  }
658
752
  function connectIdentity(id) {
753
+ stopHeartbeat(id.botId);
754
+ const prev = socketsByBot.get(id.botId);
755
+ if (prev) {
756
+ try {
757
+ prev.removeAllListeners("close");
758
+ prev.close();
759
+ } catch {}
760
+ socketsByBot.delete(id.botId);
761
+ }
762
+ let heartbeatForced = false;
659
763
  const ws = new WS(WS_URL);
660
764
  socketsByBot.set(id.botId, ws);
765
+ const hb = new HeartbeatMonitor({
766
+ sendPing: () => {
767
+ try {
768
+ if (ws.readyState === WS.OPEN) {
769
+ ws.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() }));
770
+ }
771
+ } catch {}
772
+ },
773
+ reconnect: () => {
774
+ if (process.__shutdown || heartbeatForced)
775
+ return;
776
+ heartbeatForced = true;
777
+ log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
778
+ backoffByBot.set(id.botId, 1000);
779
+ try {
780
+ ws.removeAllListeners("close");
781
+ ws.close();
782
+ } catch {}
783
+ socketsByBot.delete(id.botId);
784
+ stopHeartbeat(id.botId);
785
+ setTimeout(() => {
786
+ if (!process.__shutdown)
787
+ connectIdentity(id);
788
+ }, 500);
789
+ },
790
+ getReadyState: () => ws.readyState ?? WS_CLOSED
791
+ }, HB_PING_MS, HB_PONG_MS, HB_CONNECT_MS);
792
+ heartbeatsByBot.set(id.botId, hb);
793
+ hb.start();
661
794
  ws.on("open", () => {
662
795
  backoffByBot.set(id.botId, 1000);
663
796
  try {
@@ -671,31 +804,36 @@ function connectIdentity(id) {
671
804
  } catch {
672
805
  return;
673
806
  }
807
+ if (data.type === "pong") {
808
+ hb.receivedPong();
809
+ return;
810
+ }
674
811
  if (data.type === "auth_ok") {
812
+ hb.receivedPong();
675
813
  log(`connected to agentschat as ${id.agentId}`);
676
814
  joinMemberships(ws, id);
677
815
  return;
678
816
  }
679
817
  if (data.type === "channel_created" && data.channel_id) {
818
+ hb.receivedPong();
680
819
  joinChannel(ws, id, data.channel_id, data.name);
681
820
  return;
682
821
  }
683
822
  if (data.type === "message") {
823
+ hb.receivedPong();
684
824
  if (!data.__source)
685
825
  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__") {
826
+ if (ingestAgentsChatFrame(id, data, { advanceCursor, dedup })) {
692
827
  broadcast?.({ ...data, __botId: id.botId });
693
828
  }
694
829
  }
695
830
  });
696
831
  ws.on("close", () => {
832
+ stopHeartbeat(id.botId);
697
833
  if (process.__shutdown)
698
834
  return;
835
+ if (heartbeatForced)
836
+ return;
699
837
  const delay = backoffByBot.get(id.botId) ?? 1000;
700
838
  log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
701
839
  setTimeout(() => connectIdentity(id), delay);
@@ -768,6 +906,8 @@ for (const id of identities)
768
906
  for (const sig of ["SIGINT", "SIGTERM"]) {
769
907
  process.on(sig, () => {
770
908
  process.__shutdown = true;
909
+ for (const botId of [...heartbeatsByBot.keys()])
910
+ stopHeartbeat(botId);
771
911
  for (const ws of socketsByBot.values()) {
772
912
  try {
773
913
  ws.close();
package/dist/server.js CHANGED
@@ -462,7 +462,7 @@ async function fireGrokWake(msg, cfg) {
462
462
  var package_default = {
463
463
  name: "agentschat-mcp",
464
464
  mcpName: "io.github.swswordholy-tech/agentschat-mcp",
465
- version: "0.33.5",
465
+ version: "0.33.7",
466
466
  description: "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
467
467
  type: "module",
468
468
  bin: {
@@ -540,6 +540,7 @@ var package_default = {
540
540
  "connector/server.ts",
541
541
  "connector/run.ts",
542
542
  "connector/backfill.ts",
543
+ "connector/ingest.ts",
543
544
  "connector/README.md",
544
545
  "skills/onboarding.md",
545
546
  "dist/server.js",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.33.5",
5
- "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
4
+ "version": "0.33.7",
5
+ "description": "Connect Claude Code to AgentsChat \u2014 AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "agentschat-mcp": "src/cli.mjs",
@@ -79,6 +79,7 @@
79
79
  "connector/server.ts",
80
80
  "connector/run.ts",
81
81
  "connector/backfill.ts",
82
+ "connector/ingest.ts",
82
83
  "connector/README.md",
83
84
  "skills/onboarding.md",
84
85
  "dist/server.js",