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/CHANGELOG.md +81 -0
- package/README.md +93 -52
- package/connector/README.md +181 -120
- package/connector/identities.ts +40 -0
- package/connector/ingest.ts +42 -0
- package/connector/run.ts +245 -52
- package/connector/server.ts +174 -104
- package/dist/connector.js +517 -156
- package/dist/server.js +92 -35
- package/package.json +5 -3
- package/skills/onboarding.md +236 -39
- package/src/cli.mjs +67 -23
- package/src/identity.ts +12 -0
- package/src/server.ts +75 -31
package/connector/identities.ts
CHANGED
|
@@ -63,6 +63,29 @@ export class IdentityTable {
|
|
|
63
63
|
all(): Identity[] {
|
|
64
64
|
return [...this.byBot.values()];
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hot-replace the identity set (RELAY_IDENTITIES reload). Fails closed on
|
|
69
|
+
* duplicate botIds the same way the constructor does.
|
|
70
|
+
*/
|
|
71
|
+
replace(identities: Identity[]): void {
|
|
72
|
+
const next = new Map<string, Identity>();
|
|
73
|
+
for (const id of identities) {
|
|
74
|
+
if (next.has(id.botId)) {
|
|
75
|
+
throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
|
|
76
|
+
}
|
|
77
|
+
next.set(id.botId, id);
|
|
78
|
+
}
|
|
79
|
+
this.byBot.clear();
|
|
80
|
+
for (const [k, v] of next) this.byBot.set(k, v);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Platform hooks must never substitute another identity's credentials. */
|
|
85
|
+
export function requireIdentity(identities: readonly Identity[], botId: string): Identity {
|
|
86
|
+
const id = identities.find(id => id.botId === botId);
|
|
87
|
+
if (!id) throw new Error(`unknown identity: ${botId}`);
|
|
88
|
+
return id;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
export interface InboundContext {
|
|
@@ -90,6 +113,23 @@ export interface InboundContext {
|
|
|
90
113
|
* the agent's session and burn its tokens on chatter not addressed to it.
|
|
91
114
|
* `mentioned_ids` is still honored when a host provides it (explicit signal wins).
|
|
92
115
|
*/
|
|
116
|
+
function matchesExactMention(content: string, id: string): boolean {
|
|
117
|
+
if (!id) return false;
|
|
118
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** All exact addressed targets, once each, in identity-table order. */
|
|
123
|
+
export function routeInboundTargets(table: IdentityTable, ctx: InboundContext): Identity[] {
|
|
124
|
+
if (ctx.channel_id?.startsWith("dm-")) {
|
|
125
|
+
const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
|
|
126
|
+
return owner ? [owner] : [];
|
|
127
|
+
}
|
|
128
|
+
const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
|
|
129
|
+
return table.all().filter(id => mentioned.includes(id.botId) ||
|
|
130
|
+
matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
export function routeInbound(table: IdentityTable, ctx: InboundContext): Identity | null {
|
|
94
134
|
// DM: route to the identity that owns the DM channel.
|
|
95
135
|
if (ctx.channel_id?.startsWith("dm-")) {
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
const isDm = frame.channel_id?.startsWith("dm-");
|
|
37
|
+
const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
|
|
38
|
+
if (scopedKey && deps.dedup.recordOrSkip(scopedKey)) return false;
|
|
39
|
+
// Group fanout must filter self per TARGET, not per arrival socket: A can
|
|
40
|
+
// mention B, and A's self echo may be the first copy of the shared message.
|
|
41
|
+
return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
|
|
42
|
+
}
|
package/connector/run.ts
CHANGED
|
@@ -10,28 +10,39 @@
|
|
|
10
10
|
* Multiplex (N identities, one per Hermes profile/agent):
|
|
11
11
|
* RELAY_IDENTITIES = JSON array, one entry per identity:
|
|
12
12
|
* [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"...","profile":"<hermes profile>"}, ...]
|
|
13
|
+
* OR RELAY_IDENTITIES_FILE = path to that JSON array (preferred for hot-reload).
|
|
13
14
|
* Each botId is an agentschat agent_id. Optional `profile` is the Hermes profile
|
|
14
15
|
* name (for gateway.multiplex_profiles). Do NOT put the AgentsChat agent_id in
|
|
15
16
|
* profile — that splits Hermes session keys and breaks clarify. The connector
|
|
16
17
|
* holds all identities, opens one agentschat WS per identity, and routes by
|
|
17
|
-
* identity
|
|
18
|
+
* identity. Each gateway must hello its own authorized botId. Prefer distinct
|
|
19
|
+
* gatewayId/secret pairs per bot with separate single-identity gateways.
|
|
20
|
+
* Shared multi-hello connections require explicit, correct outbound botIds.
|
|
21
|
+
*
|
|
22
|
+
* Hot-reload (no process rewrite):
|
|
23
|
+
* SIGHUP re-reads RELAY_IDENTITIES_FILE (or RELAY_IDENTITIES env), opens WS for
|
|
24
|
+
* new botIds, disconnects removed ones. New identities need a gateway hello.
|
|
18
25
|
*
|
|
19
26
|
* Common:
|
|
20
27
|
* AGENTCHAT_API_URL REST base (default https://agents-chat.com)
|
|
21
28
|
* AGENTCHAT_WS_URL WebSocket (default wss://agents-chat.com/ws)
|
|
22
29
|
* RELAY_PORT port to listen on (default 8765)
|
|
23
30
|
* RELAY_HOST bind host (default 127.0.0.1)
|
|
31
|
+
* RELAY_IDENTITIES_FILE path to JSON identities array (SIGHUP reloads it)
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
34
|
import WS from "ws";
|
|
27
|
-
import {
|
|
28
|
-
import
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { startConnector, PlatformHttpError, PlatformNetworkError } from "./server.ts";
|
|
37
|
+
import { requireIdentity, type Identity } from "./identities.ts";
|
|
29
38
|
import { join } from "node:path";
|
|
30
39
|
import { redactSecrets } from "../src/redact.ts";
|
|
31
40
|
import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
|
|
32
41
|
import { normalizeTimestampForCursor } from "../src/timestamps.ts";
|
|
33
|
-
import { MessageDedup
|
|
42
|
+
import { MessageDedup } from "../src/dedup.ts";
|
|
34
43
|
import { planBackfill } from "./backfill.ts";
|
|
44
|
+
import { ingestAgentsChatFrame } from "./ingest.ts";
|
|
45
|
+
import { HeartbeatMonitor, WS_CLOSED } from "../src/heartbeat.ts";
|
|
35
46
|
|
|
36
47
|
const log = (m: string) => process.stderr.write(`[agentschat-connector] ${m}\n`);
|
|
37
48
|
|
|
@@ -49,36 +60,50 @@ const WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws
|
|
|
49
60
|
const PORT = Number(process.env.RELAY_PORT || 8765);
|
|
50
61
|
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
51
62
|
|
|
52
|
-
// ── Resolve identities: multiplex (RELAY_IDENTITIES) or single-tenant (legacy env) ──
|
|
63
|
+
// ── Resolve identities: multiplex (RELAY_IDENTITIES[_FILE]) or single-tenant (legacy env) ──
|
|
53
64
|
|
|
54
|
-
function
|
|
65
|
+
function parseIdentitiesJson(raw: string, source: string): Identity[] {
|
|
66
|
+
let parsed: any;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`${source} is not valid JSON`);
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
73
|
+
throw new Error(`${source} must be a non-empty JSON array`);
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
for (const [index, it] of parsed.entries()) {
|
|
77
|
+
const invalid = ["botId", "token", "gatewayId", "secret"].filter(k => typeof it?.[k] !== "string" || !it[k].trim());
|
|
78
|
+
for (const k of ["agentId", "profile"]) {
|
|
79
|
+
if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim())) invalid.push(k);
|
|
80
|
+
}
|
|
81
|
+
if (invalid.length) throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
|
|
82
|
+
if (seen.has(it.botId)) throw new Error(`${source} entry ${index}: duplicate botId`);
|
|
83
|
+
seen.add(it.botId);
|
|
84
|
+
}
|
|
85
|
+
return parsed.map((it: any) => ({
|
|
86
|
+
botId: String(it.botId),
|
|
87
|
+
agentId: String(it.agentId ?? it.botId),
|
|
88
|
+
token: String(it.token),
|
|
89
|
+
gatewayId: String(it.gatewayId),
|
|
90
|
+
secret: String(it.secret),
|
|
91
|
+
...(it.profile ? { profile: String(it.profile) } : {}),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Load identities; throws on bad JSON/shape (SIGHUP keeps prior set). */
|
|
96
|
+
function loadIdentities(): Identity[] {
|
|
97
|
+
const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
|
|
98
|
+
if (file) {
|
|
99
|
+
let raw: string;
|
|
100
|
+
try { raw = readFileSync(file, "utf8"); }
|
|
101
|
+
catch { throw new Error("RELAY_IDENTITIES_FILE could not be read"); }
|
|
102
|
+
return parseIdentitiesJson(raw, "RELAY_IDENTITIES_FILE");
|
|
103
|
+
}
|
|
55
104
|
const raw = (process.env.RELAY_IDENTITIES || "").trim();
|
|
56
105
|
if (raw) {
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
parsed = JSON.parse(raw);
|
|
60
|
-
} catch (e) {
|
|
61
|
-
log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
65
|
-
log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
for (const it of parsed) {
|
|
69
|
-
if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
|
|
70
|
-
log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
|
|
71
|
-
process.exit(1);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return parsed.map((it: any) => ({
|
|
75
|
-
botId: String(it.botId),
|
|
76
|
-
agentId: String(it.agentId ?? it.botId),
|
|
77
|
-
token: String(it.token),
|
|
78
|
-
gatewayId: String(it.gatewayId),
|
|
79
|
-
secret: String(it.secret),
|
|
80
|
-
...(it.profile ? { profile: String(it.profile) } : {}),
|
|
81
|
-
}));
|
|
106
|
+
return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
|
|
82
107
|
}
|
|
83
108
|
// Single-tenant legacy env.
|
|
84
109
|
const agentId = need("AGENTCHAT_AGENT_ID");
|
|
@@ -88,10 +113,20 @@ function resolveIdentities(): Identity[] {
|
|
|
88
113
|
return [{ botId: agentId, agentId, token, gatewayId, secret }];
|
|
89
114
|
}
|
|
90
115
|
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
function resolveIdentitiesAtStartup(): Identity[] {
|
|
117
|
+
try {
|
|
118
|
+
return loadIdentities();
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
log(`ERROR: ${e?.message ?? e}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Mutable identity list — hooks and reload close over this binding. */
|
|
126
|
+
let identities = resolveIdentitiesAtStartup();
|
|
93
127
|
|
|
94
128
|
// The per-gateway secret table for the relay upgrade auth (gatewayId → secrets).
|
|
129
|
+
// Mutated in place on hot-reload so startConnector's closed-over object stays valid.
|
|
95
130
|
const secrets: Record<string, string[]> = {};
|
|
96
131
|
for (const id of identities) {
|
|
97
132
|
(secrets[id.gatewayId] ??= []).push(id.secret);
|
|
@@ -101,10 +136,41 @@ for (const id of identities) {
|
|
|
101
136
|
|
|
102
137
|
let broadcast: ((msg: any) => void) | null = null;
|
|
103
138
|
const socketsByBot = new Map<string, WS>();
|
|
139
|
+
const reconnectTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
|
|
140
|
+
// Identity object is the generation token; unchanged reload rows retain it.
|
|
141
|
+
const currentIdentity = (id: Identity) => !(process as any).__shutdown && identities.includes(id);
|
|
142
|
+
function cancelReconnect(botId: string) {
|
|
143
|
+
const timer = reconnectTimerByBot.get(botId);
|
|
144
|
+
if (timer) clearTimeout(timer);
|
|
145
|
+
reconnectTimerByBot.delete(botId);
|
|
146
|
+
}
|
|
147
|
+
function scheduleReconnect(id: Identity, delay: number) {
|
|
148
|
+
if (!currentIdentity(id)) return;
|
|
149
|
+
cancelReconnect(id.botId);
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
if (reconnectTimerByBot.get(id.botId) !== timer) return;
|
|
152
|
+
reconnectTimerByBot.delete(id.botId);
|
|
153
|
+
if (currentIdentity(id)) connectIdentity(id);
|
|
154
|
+
}, delay);
|
|
155
|
+
reconnectTimerByBot.set(id.botId, timer);
|
|
156
|
+
}
|
|
104
157
|
const backoffByBot = new Map<string, number>();
|
|
105
158
|
const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
|
|
159
|
+
const heartbeatsByBot = new Map<string, HeartbeatMonitor>();
|
|
106
160
|
const dedup = new MessageDedup();
|
|
107
161
|
|
|
162
|
+
// Same defaults as stdio MCP (src/server.ts): 15s ping / 45s pong / 30s connect.
|
|
163
|
+
const HB_PING_MS = Math.max(5_000, Number(process.env.AGENTSCHAT_CONNECTOR_PING_MS || 15_000));
|
|
164
|
+
const HB_PONG_MS = Math.max(HB_PING_MS + 5_000, Number(process.env.AGENTSCHAT_CONNECTOR_PONG_TIMEOUT_MS || 45_000));
|
|
165
|
+
const HB_CONNECT_MS = Math.max(5_000, Number(process.env.AGENTSCHAT_CONNECTOR_CONNECT_TIMEOUT_MS || 30_000));
|
|
166
|
+
|
|
167
|
+
function stopHeartbeat(botId: string) {
|
|
168
|
+
const hb = heartbeatsByBot.get(botId);
|
|
169
|
+
if (!hb) return;
|
|
170
|
+
hb.stop();
|
|
171
|
+
heartbeatsByBot.delete(botId);
|
|
172
|
+
}
|
|
173
|
+
|
|
108
174
|
// Per-identity last-seen cursor (channel → timestamp). Same files/shape as
|
|
109
175
|
// stdio MCP (`last-seen-msg-ts-<agent>.json`): reconnect REST-replays the
|
|
110
176
|
// gap after this watermark; empty cursor seeds from newest and does not replay.
|
|
@@ -172,6 +238,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
172
238
|
return;
|
|
173
239
|
}
|
|
174
240
|
const body = await r.json() as any;
|
|
241
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
175
242
|
const channels = Array.isArray(body) ? body : (body.channels || []);
|
|
176
243
|
const joined: string[] = [];
|
|
177
244
|
for (const ch of channels) {
|
|
@@ -186,7 +253,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
186
253
|
if (prev) clearTimeout(prev);
|
|
187
254
|
const t = setTimeout(() => {
|
|
188
255
|
backfillTimerByBot.delete(id.botId);
|
|
189
|
-
if ((
|
|
256
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
190
257
|
void backfillIdentity(id, joined);
|
|
191
258
|
}, 2000);
|
|
192
259
|
(t as any).unref?.();
|
|
@@ -199,6 +266,7 @@ async function joinMemberships(ws: WS, id: Identity) {
|
|
|
199
266
|
async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
200
267
|
const s = cursorFor(id);
|
|
201
268
|
for (const channelId of channelIds) {
|
|
269
|
+
if (!currentIdentity(id)) return;
|
|
202
270
|
try {
|
|
203
271
|
const after = s.map.get(channelId);
|
|
204
272
|
const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
|
|
@@ -207,6 +275,7 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
|
207
275
|
});
|
|
208
276
|
if (!r.ok) continue;
|
|
209
277
|
const msgs = (((await r.json()) as any)?.messages ?? []) as any[];
|
|
278
|
+
if (!currentIdentity(id)) return;
|
|
210
279
|
const plan = planBackfill(after, msgs, id.agentId);
|
|
211
280
|
if (plan.seed) {
|
|
212
281
|
s.map.set(channelId, plan.seed);
|
|
@@ -217,10 +286,9 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
|
217
286
|
log(`backfill ${id.botId} ${channelId}: ${plan.replay.length} missed msg(s)`);
|
|
218
287
|
for (const m of plan.replay) {
|
|
219
288
|
const frame = { ...m, type: "message", channel_id: m.channel_id ?? channelId, __botId: id.botId, __source: "backfill" };
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
broadcast?.(frame);
|
|
289
|
+
if (ingestAgentsChatFrame(id, frame, { advanceCursor, dedup })) {
|
|
290
|
+
broadcast?.(frame);
|
|
291
|
+
}
|
|
224
292
|
}
|
|
225
293
|
} catch (e: any) {
|
|
226
294
|
log(`backfill failed for ${id.botId} ${channelId}: ${e?.message ?? e}`);
|
|
@@ -229,43 +297,98 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
|
|
|
229
297
|
}
|
|
230
298
|
|
|
231
299
|
function connectIdentity(id: Identity) {
|
|
300
|
+
if (!currentIdentity(id)) return;
|
|
301
|
+
cancelReconnect(id.botId);
|
|
302
|
+
// Replace any prior socket/heartbeat for this identity (heartbeat-forced
|
|
303
|
+
// reconnect calls us again after closing the old socket).
|
|
304
|
+
stopHeartbeat(id.botId);
|
|
305
|
+
const prev = socketsByBot.get(id.botId);
|
|
306
|
+
if (prev) {
|
|
307
|
+
try {
|
|
308
|
+
prev.removeAllListeners("close");
|
|
309
|
+
prev.close();
|
|
310
|
+
} catch {}
|
|
311
|
+
socketsByBot.delete(id.botId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let heartbeatForced = false;
|
|
232
315
|
const ws = new WS(WS_URL);
|
|
233
316
|
socketsByBot.set(id.botId, ws);
|
|
317
|
+
|
|
318
|
+
const hb = new HeartbeatMonitor(
|
|
319
|
+
{
|
|
320
|
+
sendPing: () => {
|
|
321
|
+
try {
|
|
322
|
+
if (ws.readyState === WS.OPEN) {
|
|
323
|
+
ws.send(JSON.stringify({ type: "ping", timestamp: new Date().toISOString() }));
|
|
324
|
+
}
|
|
325
|
+
} catch {}
|
|
326
|
+
},
|
|
327
|
+
reconnect: () => {
|
|
328
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced) return;
|
|
329
|
+
heartbeatForced = true;
|
|
330
|
+
log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
|
|
331
|
+
backoffByBot.set(id.botId, 1000);
|
|
332
|
+
try {
|
|
333
|
+
ws.removeAllListeners("close");
|
|
334
|
+
ws.close();
|
|
335
|
+
} catch {}
|
|
336
|
+
socketsByBot.delete(id.botId);
|
|
337
|
+
stopHeartbeat(id.botId);
|
|
338
|
+
scheduleReconnect(id, 500);
|
|
339
|
+
},
|
|
340
|
+
getReadyState: () => ws.readyState ?? WS_CLOSED,
|
|
341
|
+
},
|
|
342
|
+
HB_PING_MS,
|
|
343
|
+
HB_PONG_MS,
|
|
344
|
+
HB_CONNECT_MS,
|
|
345
|
+
);
|
|
346
|
+
heartbeatsByBot.set(id.botId, hb);
|
|
347
|
+
hb.start();
|
|
348
|
+
|
|
234
349
|
ws.on("open", () => {
|
|
350
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) { ws.close(); return; }
|
|
235
351
|
backoffByBot.set(id.botId, 1000);
|
|
236
352
|
try {
|
|
237
353
|
ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
|
|
238
354
|
} catch {}
|
|
239
355
|
});
|
|
240
356
|
ws.on("message", (raw: any) => {
|
|
357
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
241
358
|
let data: any;
|
|
242
359
|
try { data = JSON.parse(String(raw)); } catch { return; }
|
|
360
|
+
if (data.type === "pong") {
|
|
361
|
+
hb.receivedPong();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
243
364
|
if (data.type === "auth_ok") {
|
|
365
|
+
hb.receivedPong(); // alive signal, same as stdio MCP
|
|
244
366
|
log(`connected to agentschat as ${id.agentId}`);
|
|
245
367
|
void joinMemberships(ws, id);
|
|
246
368
|
return;
|
|
247
369
|
}
|
|
248
370
|
if (data.type === "channel_created" && data.channel_id) {
|
|
371
|
+
hb.receivedPong();
|
|
249
372
|
joinChannel(ws, id, data.channel_id, data.name);
|
|
250
373
|
return;
|
|
251
374
|
}
|
|
252
375
|
if (data.type === "message") {
|
|
376
|
+
hb.receivedPong(); // any live traffic counts as liveness
|
|
253
377
|
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__") {
|
|
378
|
+
// Cursor advances even when shared multiplex dedup skips broadcast.
|
|
379
|
+
if (ingestAgentsChatFrame(id, data, { advanceCursor, dedup })) {
|
|
260
380
|
broadcast?.({ ...data, __botId: id.botId });
|
|
261
381
|
}
|
|
262
382
|
}
|
|
263
383
|
});
|
|
264
384
|
ws.on("close", () => {
|
|
385
|
+
if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
|
|
386
|
+
stopHeartbeat(id.botId);
|
|
265
387
|
if ((process as any).__shutdown) return;
|
|
388
|
+
if (heartbeatForced) return; // reconnect already scheduled by HeartbeatMonitor
|
|
266
389
|
const delay = backoffByBot.get(id.botId) ?? 1000;
|
|
267
390
|
log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
|
|
268
|
-
|
|
391
|
+
scheduleReconnect(id, delay);
|
|
269
392
|
backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
|
|
270
393
|
});
|
|
271
394
|
ws.on("error", (e: any) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
|
|
@@ -280,18 +403,18 @@ const connector = startConnector({
|
|
|
280
403
|
identities,
|
|
281
404
|
agentschat: {
|
|
282
405
|
async sendMessage(botId, chatId, content, replyTo) {
|
|
283
|
-
const id = identities
|
|
406
|
+
const id = requireIdentity(identities, botId);
|
|
284
407
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
|
|
285
408
|
method: "POST",
|
|
286
409
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
|
|
287
410
|
body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...(replyTo ? { parent_id: replyTo } : {}) }),
|
|
288
|
-
});
|
|
289
|
-
if (!res.ok) throw new
|
|
411
|
+
}).catch(() => { throw new PlatformNetworkError(); });
|
|
412
|
+
if (!res.ok) throw new PlatformHttpError(res.status);
|
|
290
413
|
const data = (await res.json()) as any;
|
|
291
414
|
return { id: data?.id };
|
|
292
415
|
},
|
|
293
416
|
async getChatInfo(botId, chatId) {
|
|
294
|
-
const id = identities
|
|
417
|
+
const id = requireIdentity(identities, botId);
|
|
295
418
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
|
|
296
419
|
headers: { Authorization: `Bearer ${id.token}` },
|
|
297
420
|
});
|
|
@@ -300,7 +423,7 @@ const connector = startConnector({
|
|
|
300
423
|
return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
|
|
301
424
|
},
|
|
302
425
|
async sendTyping(botId, chatId) {
|
|
303
|
-
const id = identities
|
|
426
|
+
const id = requireIdentity(identities, botId);
|
|
304
427
|
const ws = socketsByBot.get(id.botId);
|
|
305
428
|
if (ws && ws.readyState === WS.OPEN) {
|
|
306
429
|
try { ws.send(JSON.stringify({ type: "typing", channel_id: chatId, sender_id: id.agentId })); } catch {}
|
|
@@ -310,9 +433,10 @@ const connector = startConnector({
|
|
|
310
433
|
// oldest→newest, trigger message excluded, secrets redacted (a group channel is
|
|
311
434
|
// untrusted content — never forward a leaked key downstream). Best-effort: any
|
|
312
435
|
// failure returns null and the addressed message still delivers without it.
|
|
313
|
-
async getChannelContext(botId, chatId, sinceTs, excludeId) {
|
|
314
|
-
const id = identities
|
|
436
|
+
async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
|
|
437
|
+
const id = requireIdentity(identities, botId);
|
|
315
438
|
const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
|
|
439
|
+
signal,
|
|
316
440
|
headers: { Authorization: `Bearer ${id.token}` },
|
|
317
441
|
});
|
|
318
442
|
if (!res.ok) return null;
|
|
@@ -333,13 +457,82 @@ const connector = startConnector({
|
|
|
333
457
|
});
|
|
334
458
|
|
|
335
459
|
broadcast = (msg) => connector.injectAgentsChatMessage(msg);
|
|
460
|
+
await connector.ready;
|
|
336
461
|
log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
|
|
337
462
|
|
|
338
463
|
for (const id of identities) connectIdentity(id);
|
|
339
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Hot-reload RELAY_IDENTITIES without rewriting the connector process.
|
|
467
|
+
* Prefer RELAY_IDENTITIES_FILE so SIGHUP re-reads disk; env-only deployments
|
|
468
|
+
* re-parse RELAY_IDENTITIES (unchanged unless the supervisor rewrote the env).
|
|
469
|
+
* Opens AgentsChat WS for new botIds, disconnects removed ones. Gateway hello
|
|
470
|
+
* must include each new identity before it receives any inbound traffic.
|
|
471
|
+
*/
|
|
472
|
+
function reloadIdentitiesFromConfig() {
|
|
473
|
+
const next = loadIdentities().map(id => identities.find(prev =>
|
|
474
|
+
prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token &&
|
|
475
|
+
prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
|
|
476
|
+
const prevByBot = new Map(identities.map((i) => [i.botId, i]));
|
|
477
|
+
const nextByBot = new Map(next.map((i) => [i.botId, i]));
|
|
478
|
+
const added = next.filter((i) => !prevByBot.has(i.botId));
|
|
479
|
+
const removed = identities.filter((i) => !nextByBot.has(i.botId));
|
|
480
|
+
const kept = next.filter((i) => prevByBot.has(i.botId));
|
|
481
|
+
|
|
482
|
+
// Table first — if replace throws, leave AgentsChat sockets / `identities` untouched.
|
|
483
|
+
connector.reloadIdentities(next);
|
|
484
|
+
identities = next;
|
|
485
|
+
|
|
486
|
+
for (const id of removed) {
|
|
487
|
+
cancelReconnect(id.botId);
|
|
488
|
+
log(`hot-reload: disconnecting removed identity ${id.botId}`);
|
|
489
|
+
stopHeartbeat(id.botId);
|
|
490
|
+
const t = backfillTimerByBot.get(id.botId);
|
|
491
|
+
if (t) { clearTimeout(t); backfillTimerByBot.delete(id.botId); }
|
|
492
|
+
const ws = socketsByBot.get(id.botId);
|
|
493
|
+
if (ws) {
|
|
494
|
+
try {
|
|
495
|
+
ws.removeAllListeners("close");
|
|
496
|
+
ws.close();
|
|
497
|
+
} catch {}
|
|
498
|
+
socketsByBot.delete(id.botId);
|
|
499
|
+
}
|
|
500
|
+
backoffByBot.delete(id.botId);
|
|
501
|
+
// Keep cursor files on disk (reconnect watermark survives a temporary remove).
|
|
502
|
+
}
|
|
503
|
+
for (const id of kept) {
|
|
504
|
+
// Token/gateway rotation: update in-memory row already done via `identities = next`.
|
|
505
|
+
// If the agentschat token changed, force reconnect so auth uses the new key.
|
|
506
|
+
const prev = prevByBot.get(id.botId)!;
|
|
507
|
+
if (prev !== id) {
|
|
508
|
+
cancelReconnect(id.botId);
|
|
509
|
+
const timer = backfillTimerByBot.get(id.botId);
|
|
510
|
+
if (timer) clearTimeout(timer);
|
|
511
|
+
backfillTimerByBot.delete(id.botId);
|
|
512
|
+
log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
|
|
513
|
+
connectIdentity(id);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const id of added) {
|
|
517
|
+
log(`hot-reload: connecting new identity ${id.botId}`);
|
|
518
|
+
connectIdentity(id);
|
|
519
|
+
}
|
|
520
|
+
log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
process.on("SIGHUP", () => {
|
|
524
|
+
try {
|
|
525
|
+
reloadIdentitiesFromConfig();
|
|
526
|
+
} catch (e: any) {
|
|
527
|
+
log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
340
531
|
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
341
532
|
process.on(sig, () => {
|
|
342
533
|
(process as any).__shutdown = true;
|
|
534
|
+
for (const botId of reconnectTimerByBot.keys()) cancelReconnect(botId);
|
|
535
|
+
for (const botId of [...heartbeatsByBot.keys()]) stopHeartbeat(botId);
|
|
343
536
|
for (const ws of socketsByBot.values()) {
|
|
344
537
|
try { ws.close(); } catch {}
|
|
345
538
|
}
|