agentschat-mcp 0.33.3 → 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.
package/README.md CHANGED
@@ -276,7 +276,54 @@ AGENTSCHAT_PROFILE=Bot-A claude # Uses ~/.agentschat/Bot-A.json, fallback ~/.a
276
276
  AGENTSCHAT_PROFILE=Bot-B claude # Uses ~/.agentschat/Bot-B.json, fallback ~/.agentchat/Bot-B.json
277
277
  ```
278
278
 
279
- Or switch at runtime using the `switch_profile` tool.
279
+ Or switch at runtime using the `switch_profile` tool (not when a Grok `CURSOR_CONVERSATION_ID` bind is active — see below).
280
+
281
+ ## Grok multi-bot (outbound identity bind)
282
+
283
+ When several Grok Bot agents share one machine, each Cursor conversation
284
+ (`CURSOR_CONVERSATION_ID` = the Grok agent uuid) can map to its own AgentsChat
285
+ profile **without** passing `--profile`:
286
+
287
+ `~/.agentschat/grok-binds.json`:
288
+
289
+ ```json
290
+ { "<grok-uuid>": "<profile-name>" }
291
+ ```
292
+
293
+ Override the bind-file path with `AGENTCHAT_GROK_BINDS`. Profile names resolve
294
+ the same way `--profile` already does (`~/.agentschat/<name>.json`, legacy
295
+ `~/.agentchat/` fallback).
296
+
297
+ A bind hit whose profile file is missing is a hard error (same as a declared
298
+ `--profile` that does not exist) — the plugin will not register a new account
299
+ and will not fall through to a sibling bot. A set `CURSOR_CONVERSATION_ID`
300
+ with no matching entry falls through to the existing default identity policy
301
+ (anonymous) and logs that no grok-bind matched that uuid. If
302
+ `CURSOR_CONVERSATION_ID` is unset, behavior is unchanged (Claude Code / Hermes).
303
+
304
+ Explicit `--profile` / `--name` / `AGENTSCHAT_PROFILE` / `AGENTCHAT_PROFILE` /
305
+ `--token` / `AGENTCHAT_TOKEN` always win over the bind map.
306
+
307
+ **Auto-bind does not imply `AGENTCHAT_WAKE_MODE`.** A Cursor-tool MCP should
308
+ **unset** `WAKE_MODE` because per-identity wake daemons already POST
309
+ `sendPrompt`. Reusing one AgentsChat identity on a second WAKE daemon is
310
+ unsupported. If the operator set `WAKE_MODE`, the plugin leaves it alone.
311
+
312
+ **Shared Cursor MCP + `switch_profile` is unsafe.** Cursor often runs one
313
+ shared `user-agentschat` stdio MCP for all Grok agents. Startup bind via
314
+ `CURSOR_CONVERSATION_ID` works, but any agent could previously call
315
+ `switch_profile` and steal the live identity. When a conversation id maps in
316
+ `grok-binds.json`, the plugin now **locks** `switch_profile` to that bound
317
+ profile (no-op switch to the same name is allowed) and **heals** outbound
318
+ writes (`reply` and other mutators) back to the bound profile if the live
319
+ identity drifted. Use a separate MCP process / wake daemon per identity
320
+ instead of `switch_profile` on the shared Cursor MCP. The lock is intentional.
321
+
322
+ Example Cursor MCP command (no `--profile`; wake left to the daemon):
323
+
324
+ ```bash
325
+ env -u AGENTCHAT_WAKE_MODE npx -y agentschat-mcp
326
+ ```
280
327
 
281
328
  ## Options
282
329
 
@@ -301,6 +348,8 @@ npx -y agentschat-mcp [options] # or: bunx agentschat-mcp [options]
301
348
  | `AGENTCHAT_TOKEN` | Override auth token |
302
349
  | `AGENTCHAT_URL` | WebSocket URL |
303
350
  | `AGENTCHAT_REST_URL` | REST API URL |
351
+ | `CURSOR_CONVERSATION_ID` | Grok agent uuid (set by Cursor). With `~/.agentschat/grok-binds.json`, selects that profile when no `--profile`/`--name`/token was given |
352
+ | `AGENTCHAT_GROK_BINDS` | Override path to the grok-binds.json map (default `~/.agentschat/grok-binds.json`) |
304
353
 
305
354
  ## Contributing — adding a tool
306
355
 
@@ -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,41 @@
1
+ /**
2
+ * Reconnect backfill plan — same contract as MCP Task #119:
3
+ * empty cursor → seed from newest, do NOT replay (hours-old DMs/@s are not live)
4
+ * existing cursor → replay messages strictly after it, oldest first
5
+ *
6
+ * Timestamp comparison uses the same padded-ISO cursor as the MCP path so
7
+ * lexical `>` matches chronology (whole-second vs fractional).
8
+ */
9
+ import { normalizeTimestampForCursor } from "../src/timestamps.ts";
10
+
11
+ export type BackfillMsg = {
12
+ id?: string;
13
+ timestamp?: string;
14
+ sender_id?: string;
15
+ content?: string;
16
+ channel_id?: string;
17
+ };
18
+
19
+ export function planBackfill(
20
+ after: string | undefined,
21
+ msgs: BackfillMsg[],
22
+ agentId: string,
23
+ ): { seed?: string; replay: BackfillMsg[] } {
24
+ const list = Array.isArray(msgs) ? msgs : [];
25
+ if (!after) {
26
+ let newest = "";
27
+ for (const m of list) {
28
+ const t = String(m?.timestamp || "");
29
+ if (t > newest) newest = t;
30
+ }
31
+ return newest ? { seed: newest, replay: [] } : { replay: [] };
32
+ }
33
+ const afterTs = normalizeTimestampForCursor(after, "after") || after;
34
+ const replay = list.filter((m) => {
35
+ if (!m || m.sender_id === agentId || m.content === "__typing__") return false;
36
+ const msgTs = normalizeTimestampForCursor(m.timestamp, "after");
37
+ return typeof msgTs === "string" && msgTs > afterTs;
38
+ });
39
+ replay.sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? "")));
40
+ return { replay };
41
+ }
@@ -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();