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.
@@ -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 — identity A's messages never cross to identity B.
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 { startConnector } from "./server.ts";
28
- import type { Identity } from "./identities.ts";
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, messageDedupKey } from "../src/dedup.ts";
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 resolveIdentities(): Identity[] {
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
- let parsed: any;
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
- const identities = resolveIdentities();
92
- const single = identities.length === 1;
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 ((process as any).__shutdown) return;
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
- const key = messageDedupKey(frame);
221
- if (key && dedup.recordOrSkip(key)) continue;
222
- advanceCursor(id, frame.channel_id, frame.timestamp);
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
- 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__") {
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
- setTimeout(() => connectIdentity(id), delay);
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.find((i) => i.botId === botId) ?? identities[0];
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 Error(`agentschat send failed: ${res.status}`);
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.find((i) => i.botId === botId) ?? identities[0];
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.find((i) => i.botId === botId) ?? identities[0];
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.find((i) => i.botId === botId) ?? identities[0];
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
  }