agentschat-mcp 0.33.2 → 0.33.5

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
 
@@ -54,6 +54,13 @@ After `auth_ok` the connector GETs `/api/channels/mine` and sends `join_channel`
54
54
  for each membership, and again on `channel_created`. The server only pushes
55
55
  DM/@ frames to sockets that have joined; auth alone is not enough.
56
56
 
57
+ **Read cursor (same as stdio MCP):** each identity persists
58
+ `last-seen-msg-ts-<botId>.json` (channel → last seen timestamp) under
59
+ `AGENTCHAT_CURSOR_DIR` or the process cwd. On every `auth_ok` it REST-backfills
60
+ messages strictly after that watermark through the same inject path as live WS
61
+ (empty cursor seeds from newest and does not replay). Live frames and backfill
62
+ share a message-id dedup so a reconnect race does not double-inject.
63
+
57
64
  **Inbound gating (all modes):** the AgentsChat WS pushes every message of every
58
65
  joined channel. The connector injects into the gateway only what is ADDRESSED to
59
66
  an identity — DMs always, group messages only when the body @mentions it (same
@@ -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
+ }
package/connector/run.ts CHANGED
@@ -26,7 +26,12 @@
26
26
  import WS from "ws";
27
27
  import { startConnector } from "./server.ts";
28
28
  import type { Identity } from "./identities.ts";
29
+ import { join } from "node:path";
29
30
  import { redactSecrets } from "../src/redact.ts";
31
+ import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
32
+ import { normalizeTimestampForCursor } from "../src/timestamps.ts";
33
+ import { MessageDedup, messageDedupKey } from "../src/dedup.ts";
34
+ import { planBackfill } from "./backfill.ts";
30
35
 
31
36
  const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
32
37
 
@@ -97,6 +102,55 @@ for (const id of identities) {
97
102
  let broadcast: ((msg: any) => void) | null = null;
98
103
  const socketsByBot = new Map<string, WS>();
99
104
  const backoffByBot = new Map<string, number>();
105
+ const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
106
+ const dedup = new MessageDedup();
107
+
108
+ // Per-identity last-seen cursor (channel → timestamp). Same files/shape as
109
+ // stdio MCP (`last-seen-msg-ts-<agent>.json`): reconnect REST-replays the
110
+ // gap after this watermark; empty cursor seeds from newest and does not replay.
111
+ const CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
112
+ const cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
113
+
114
+ type CursorStore = {
115
+ file: string;
116
+ map: Map<string, string>;
117
+ state: { dirty: boolean };
118
+ timer: ReturnType<typeof setTimeout> | null;
119
+ };
120
+ const cursors = new Map<string, CursorStore>();
121
+
122
+ function cursorFor(id: Identity): CursorStore {
123
+ let s = cursors.get(id.botId);
124
+ if (s) return s;
125
+ const file = join(CURSOR_DIR, `last-seen-msg-ts-${id.botId}.json`);
126
+ s = { file, map: loadCursor(file, (m) => log(m.trimEnd())), state: { dirty: false }, timer: null };
127
+ cursors.set(id.botId, s);
128
+ return s;
129
+ }
130
+
131
+ function flushOne(s: CursorStore) {
132
+ if (s.timer) { clearTimeout(s.timer); s.timer = null; }
133
+ flushCursor(s.state, () => persistCursor(s.file, s.map, (m) => log(m.trimEnd())));
134
+ }
135
+
136
+ function scheduleSave(s: CursorStore) {
137
+ s.state.dirty = true;
138
+ if (s.timer) return;
139
+ s.timer = setTimeout(() => { s.timer = null; flushOne(s); }, cursorFlushMs);
140
+ (s.timer as any).unref?.();
141
+ }
142
+
143
+ function advanceCursor(id: Identity, channelId: string | undefined, timestamp: string | undefined) {
144
+ if (typeof channelId !== "string" || typeof timestamp !== "string" || !timestamp) return;
145
+ const s = cursorFor(id);
146
+ const prev = s.map.get(channelId) || "";
147
+ const currentTs = normalizeTimestampForCursor(timestamp, "after") || timestamp;
148
+ const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
149
+ if (currentTs > prevTs) {
150
+ s.map.set(channelId, timestamp);
151
+ scheduleSave(s);
152
+ }
153
+ }
100
154
 
101
155
  function joinChannel(ws: WS, id: Identity, channelId: string, name?: string) {
102
156
  try {
@@ -119,16 +173,61 @@ async function joinMemberships(ws: WS, id: Identity) {
119
173
  }
120
174
  const body = await r.json() as any;
121
175
  const channels = Array.isArray(body) ? body : (body.channels || []);
176
+ const joined: string[] = [];
122
177
  for (const ch of channels) {
123
178
  const channelId = ch?.id || ch?.channel_id;
124
179
  if (!channelId) continue;
125
180
  joinChannel(ws, id, channelId, ch?.name);
181
+ joined.push(channelId);
126
182
  }
183
+ // Same delay as MCP Task #119: let channel_created + gateway hello settle,
184
+ // then REST-replay anything after the persisted last-seen cursor.
185
+ const prev = backfillTimerByBot.get(id.botId);
186
+ if (prev) clearTimeout(prev);
187
+ const t = setTimeout(() => {
188
+ backfillTimerByBot.delete(id.botId);
189
+ if ((process as any).__shutdown) return;
190
+ void backfillIdentity(id, joined);
191
+ }, 2000);
192
+ (t as any).unref?.();
193
+ backfillTimerByBot.set(id.botId, t);
127
194
  } catch (e: any) {
128
195
  log(`join-on-auth failed: ${e?.message ?? e}`);
129
196
  }
130
197
  }
131
198
 
199
+ async function backfillIdentity(id: Identity, channelIds: string[]) {
200
+ const s = cursorFor(id);
201
+ for (const channelId of channelIds) {
202
+ try {
203
+ const after = s.map.get(channelId);
204
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
205
+ const r = await fetch(`${API}/api/channels/${encodeURIComponent(channelId)}/messages${params}`, {
206
+ headers: { Authorization: `Bearer ${id.token}` },
207
+ });
208
+ if (!r.ok) continue;
209
+ const msgs = (((await r.json()) as any)?.messages ?? []) as any[];
210
+ const plan = planBackfill(after, msgs, id.agentId);
211
+ if (plan.seed) {
212
+ s.map.set(channelId, plan.seed);
213
+ scheduleSave(s);
214
+ continue;
215
+ }
216
+ if (!plan.replay.length) continue;
217
+ log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
218
+ for (const m of plan.replay) {
219
+ 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);
224
+ }
225
+ } catch (e: any) {
226
+ log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
227
+ }
228
+ }
229
+ }
230
+
132
231
  function connectIdentity(id: Identity) {
133
232
  const ws = new WS(WS_URL);
134
233
  socketsByBot.set(id.botId, ws);
@@ -150,10 +249,16 @@ function connectIdentity(id: Identity) {
150
249
  joinChannel(ws, id, data.channel_id, data.name);
151
250
  return;
152
251
  }
153
- if (data.type === "message" && data.sender_id !== id.agentId) {
154
- // Tag which identity's socket this arrived on, so the connector routes it to
155
- // the gateway fronting that identity (and only that one).
156
- broadcast?.({ ...data, __botId: id.botId });
252
+ if (data.type === "message") {
253
+ 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__") {
260
+ broadcast?.({ ...data, __botId: id.botId });
261
+ }
157
262
  }
158
263
  });
159
264
  ws.on("close", () => {
@@ -238,6 +343,7 @@ for (const sig of ["SIGINT", "SIGTERM"] as const) {
238
343
  for (const ws of socketsByBot.values()) {
239
344
  try { ws.close(); } catch {}
240
345
  }
346
+ for (const s of cursors.values()) flushOne(s);
241
347
  connector.stop();
242
348
  process.exit(0);
243
349
  });
package/dist/connector.js CHANGED
@@ -365,11 +365,120 @@ function peekPayload(token) {
365
365
  }
366
366
  }
367
367
 
368
+ // connector/run.ts
369
+ import { join } from "node:path";
370
+
368
371
  // src/redact.ts
369
372
  function redactSecrets(text) {
370
373
  return text.replace(/ac_[A-Za-z0-9_-]{16,}/g, "ac_***REDACTED***").replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "***JWT_REDACTED***");
371
374
  }
372
375
 
376
+ // src/read-cursor.ts
377
+ import { readFileSync, writeFileSync } from "fs";
378
+ function loadCursor(file, warn) {
379
+ try {
380
+ return new Map(Object.entries(JSON.parse(readFileSync(file, "utf-8"))));
381
+ } catch (e) {
382
+ if (e?.code !== "ENOENT") {
383
+ warn(`[agentchat] WARNING: could not read ${file} — resetting that state: ${e}
384
+ `);
385
+ }
386
+ return new Map;
387
+ }
388
+ }
389
+ function persistCursor(file, cursor, warn) {
390
+ try {
391
+ writeFileSync(file, JSON.stringify(Object.fromEntries(cursor)));
392
+ return true;
393
+ } catch (e) {
394
+ warn(`[agentchat] WARNING: failed to persist read cursor to ${file}: ${e}
395
+ `);
396
+ return false;
397
+ }
398
+ }
399
+ function flushCursor(state, persist) {
400
+ if (!state.dirty)
401
+ return false;
402
+ const ok = persist();
403
+ if (ok)
404
+ state.dirty = false;
405
+ return ok;
406
+ }
407
+
408
+ // src/timestamps.ts
409
+ function normalizeTimestampForCursor(ts, mode) {
410
+ if (!ts || typeof ts !== "string")
411
+ return ts;
412
+ const padChar = mode === "before" ? "9" : "0";
413
+ const withFrac = ts.match(/^(.*\.)(\d+)(Z)$/);
414
+ if (withFrac) {
415
+ const frac = withFrac[2];
416
+ if (frac.length >= 9)
417
+ return ts;
418
+ return withFrac[1] + frac + padChar.repeat(9 - frac.length) + withFrac[3];
419
+ }
420
+ const noFrac = ts.match(/^(.*\d)(Z)$/);
421
+ if (noFrac) {
422
+ return noFrac[1] + "." + padChar.repeat(9) + noFrac[2];
423
+ }
424
+ return ts;
425
+ }
426
+
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
+ // connector/backfill.ts
460
+ function planBackfill(after, msgs, agentId) {
461
+ const list = Array.isArray(msgs) ? msgs : [];
462
+ if (!after) {
463
+ let newest = "";
464
+ for (const m of list) {
465
+ const t = String(m?.timestamp || "");
466
+ if (t > newest)
467
+ newest = t;
468
+ }
469
+ return newest ? { seed: newest, replay: [] } : { replay: [] };
470
+ }
471
+ const afterTs = normalizeTimestampForCursor(after, "after") || after;
472
+ const replay = list.filter((m) => {
473
+ if (!m || m.sender_id === agentId || m.content === "__typing__")
474
+ return false;
475
+ const msgTs = normalizeTimestampForCursor(m.timestamp, "after");
476
+ return typeof msgTs === "string" && msgTs > afterTs;
477
+ });
478
+ replay.sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? "")));
479
+ return { replay };
480
+ }
481
+
373
482
  // connector/run.ts
374
483
  var log = (m) => process.stderr.write(`[agentschat-connector] ${m}
375
484
  `);
@@ -429,6 +538,49 @@ for (const id of identities) {
429
538
  var broadcast = null;
430
539
  var socketsByBot = new Map;
431
540
  var backoffByBot = new Map;
541
+ var backfillTimerByBot = new Map;
542
+ var dedup = new MessageDedup;
543
+ var CURSOR_DIR = process.env.AGENTCHAT_CURSOR_DIR || process.cwd();
544
+ var cursorFlushMs = Math.max(500, Number(process.env.AGENTSCHAT_MCP_CURSOR_FLUSH_MS || 5000));
545
+ var cursors = new Map;
546
+ function cursorFor(id) {
547
+ let s = cursors.get(id.botId);
548
+ if (s)
549
+ return s;
550
+ const file = join(CURSOR_DIR, `last-seen-msg-ts-${id.botId}.json`);
551
+ s = { file, map: loadCursor(file, (m) => log(m.trimEnd())), state: { dirty: false }, timer: null };
552
+ cursors.set(id.botId, s);
553
+ return s;
554
+ }
555
+ function flushOne(s) {
556
+ if (s.timer) {
557
+ clearTimeout(s.timer);
558
+ s.timer = null;
559
+ }
560
+ flushCursor(s.state, () => persistCursor(s.file, s.map, (m) => log(m.trimEnd())));
561
+ }
562
+ function scheduleSave(s) {
563
+ s.state.dirty = true;
564
+ if (s.timer)
565
+ return;
566
+ s.timer = setTimeout(() => {
567
+ s.timer = null;
568
+ flushOne(s);
569
+ }, cursorFlushMs);
570
+ s.timer.unref?.();
571
+ }
572
+ function advanceCursor(id, channelId, timestamp) {
573
+ if (typeof channelId !== "string" || typeof timestamp !== "string" || !timestamp)
574
+ return;
575
+ const s = cursorFor(id);
576
+ const prev = s.map.get(channelId) || "";
577
+ const currentTs = normalizeTimestampForCursor(timestamp, "after") || timestamp;
578
+ const prevTs = normalizeTimestampForCursor(prev, "after") || prev;
579
+ if (currentTs > prevTs) {
580
+ s.map.set(channelId, timestamp);
581
+ scheduleSave(s);
582
+ }
583
+ }
432
584
  function joinChannel(ws, id, channelId, name) {
433
585
  try {
434
586
  ws.send(JSON.stringify({ type: "join_channel", channel_id: channelId, agent_id: id.agentId }));
@@ -446,16 +598,63 @@ async function joinMemberships(ws, id) {
446
598
  }
447
599
  const body = await r.json();
448
600
  const channels = Array.isArray(body) ? body : body.channels || [];
601
+ const joined = [];
449
602
  for (const ch of channels) {
450
603
  const channelId = ch?.id || ch?.channel_id;
451
604
  if (!channelId)
452
605
  continue;
453
606
  joinChannel(ws, id, channelId, ch?.name);
607
+ joined.push(channelId);
454
608
  }
609
+ const prev = backfillTimerByBot.get(id.botId);
610
+ if (prev)
611
+ clearTimeout(prev);
612
+ const t = setTimeout(() => {
613
+ backfillTimerByBot.delete(id.botId);
614
+ if (process.__shutdown)
615
+ return;
616
+ backfillIdentity(id, joined);
617
+ }, 2000);
618
+ t.unref?.();
619
+ backfillTimerByBot.set(id.botId, t);
455
620
  } catch (e) {
456
621
  log(`join-on-auth failed: ${e?.message ?? e}`);
457
622
  }
458
623
  }
624
+ async function backfillIdentity(id, channelIds) {
625
+ const s = cursorFor(id);
626
+ for (const channelId of channelIds) {
627
+ try {
628
+ const after = s.map.get(channelId);
629
+ const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
630
+ const r = await fetch(`${API}/api/channels/${encodeURIComponent(channelId)}/messages${params}`, {
631
+ headers: { Authorization: `Bearer ${id.token}` }
632
+ });
633
+ if (!r.ok)
634
+ continue;
635
+ const msgs = (await r.json())?.messages ?? [];
636
+ const plan = planBackfill(after, msgs, id.agentId);
637
+ if (plan.seed) {
638
+ s.map.set(channelId, plan.seed);
639
+ scheduleSave(s);
640
+ continue;
641
+ }
642
+ if (!plan.replay.length)
643
+ continue;
644
+ log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
645
+ for (const m of plan.replay) {
646
+ 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);
652
+ }
653
+ } catch (e) {
654
+ log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
655
+ }
656
+ }
657
+ }
459
658
  function connectIdentity(id) {
460
659
  const ws = new WS(WS_URL);
461
660
  socketsByBot.set(id.botId, ws);
@@ -481,8 +680,17 @@ function connectIdentity(id) {
481
680
  joinChannel(ws, id, data.channel_id, data.name);
482
681
  return;
483
682
  }
484
- if (data.type === "message" && data.sender_id !== id.agentId) {
485
- broadcast?.({ ...data, __botId: id.botId });
683
+ if (data.type === "message") {
684
+ if (!data.__source)
685
+ 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__") {
692
+ broadcast?.({ ...data, __botId: id.botId });
693
+ }
486
694
  }
487
695
  });
488
696
  ws.on("close", () => {
@@ -565,6 +773,8 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
565
773
  ws.close();
566
774
  } catch {}
567
775
  }
776
+ for (const s of cursors.values())
777
+ flushOne(s);
568
778
  connector.stop();
569
779
  process.exit(0);
570
780
  });