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 +50 -1
- package/connector/README.md +1 -0
- package/connector/backfill.ts +41 -0
- package/connector/ingest.ts +38 -0
- package/connector/run.ts +78 -11
- package/dist/connector.js +151 -11
- package/dist/server.js +229 -34
- package/package.json +5 -2
- package/src/grok-bind.ts +193 -0
- package/src/identity.ts +4 -1
- package/src/server.ts +148 -30
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
|
|
package/connector/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
255
|
-
if (
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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();
|