agentschat-mcp 0.33.7 → 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/dist/connector.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // connector/run.ts
3
3
  import WS from "ws";
4
+ import { readFileSync as readFileSync2 } from "node:fs";
4
5
 
5
6
  // connector/server.ts
6
7
  import { createServer } from "node:http";
@@ -104,15 +105,36 @@ function toWireEvent(msg, platform = "agentschat") {
104
105
  };
105
106
  }
106
107
 
107
- // src/mentions.ts
108
- function matchesMention(content, agentId) {
109
- if (!content || !agentId)
108
+ // src/dedup.ts
109
+ function messageDedupKey(data) {
110
+ if (!data || typeof data.id !== "string" || typeof data.channel_id !== "string")
111
+ return null;
112
+ return `${data.channel_id}:${data.id}`;
113
+ }
114
+
115
+ class MessageDedup {
116
+ max;
117
+ dropOnEvict;
118
+ seen = new Set;
119
+ constructor(max = 5000, dropOnEvict = 1000) {
120
+ this.max = max;
121
+ this.dropOnEvict = dropOnEvict;
122
+ }
123
+ recordOrSkip(key) {
124
+ if (this.seen.has(key))
125
+ return true;
126
+ this.seen.add(key);
127
+ if (this.seen.size > this.max) {
128
+ const arr = [...this.seen];
129
+ this.seen.clear();
130
+ for (const item of arr.slice(this.dropOnEvict))
131
+ this.seen.add(item);
132
+ }
110
133
  return false;
111
- if (content.includes(`@${agentId}`))
112
- return true;
113
- const idEsc = agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
- const displayMentionRe = new RegExp(`@[^(\\n]+\\(${idEsc}\\)`);
115
- return displayMentionRe.test(content);
134
+ }
135
+ get size() {
136
+ return this.seen.size;
137
+ }
116
138
  }
117
139
 
118
140
  // connector/identities.ts
@@ -138,25 +160,38 @@ class IdentityTable {
138
160
  all() {
139
161
  return [...this.byBot.values()];
140
162
  }
163
+ replace(identities) {
164
+ const next = new Map;
165
+ for (const id of identities) {
166
+ if (next.has(id.botId)) {
167
+ throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
168
+ }
169
+ next.set(id.botId, id);
170
+ }
171
+ this.byBot.clear();
172
+ for (const [k, v] of next)
173
+ this.byBot.set(k, v);
174
+ }
141
175
  }
142
- function routeInbound(table, ctx) {
176
+ function requireIdentity(identities, botId) {
177
+ const id = identities.find((id2) => id2.botId === botId);
178
+ if (!id)
179
+ throw new Error(`unknown identity: ${botId}`);
180
+ return id;
181
+ }
182
+ function matchesExactMention(content, id) {
183
+ if (!id)
184
+ return false;
185
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
187
+ }
188
+ function routeInboundTargets(table, ctx) {
143
189
  if (ctx.channel_id?.startsWith("dm-")) {
144
- return ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
190
+ const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
191
+ return owner ? [owner] : [];
145
192
  }
146
193
  const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
147
- for (const mid of mentioned) {
148
- const id = table.forBot(mid);
149
- if (id)
150
- return id;
151
- }
152
- const content = ctx.content ?? "";
153
- if (content) {
154
- for (const id of table.all()) {
155
- if (matchesMention(content, id.agentId) || matchesMention(content, id.botId))
156
- return id;
157
- }
158
- }
159
- return null;
194
+ return table.all().filter((id) => mentioned.includes(id.botId) || matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
160
195
  }
161
196
  function hermesSourceProfile(id, frontedCount) {
162
197
  const named = typeof id.profile === "string" ? id.profile.trim() : "";
@@ -168,12 +203,32 @@ function hermesSourceProfile(id, frontedCount) {
168
203
  }
169
204
 
170
205
  // connector/server.ts
206
+ class PlatformHttpError extends Error {
207
+ status;
208
+ constructor(status) {
209
+ super("platform HTTP failure");
210
+ this.status = status;
211
+ }
212
+ }
213
+
214
+ class PlatformNetworkError extends Error {
215
+ constructor() {
216
+ super("platform network failure");
217
+ }
218
+ }
171
219
  function startConnector(config) {
172
220
  const log = config.logger ?? (() => {});
173
221
  const descriptor = buildDescriptor(config.descriptor);
174
222
  const legacy = !config.identities || config.identities.length === 0;
175
223
  const table = new IdentityTable(legacy ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities);
176
- const single = table.isSingle();
224
+ function safeLabel(value) {
225
+ for (const id of table.all())
226
+ for (const secret of [id.token, id.secret]) {
227
+ if (secret)
228
+ value = value.split(secret).join("[redacted]");
229
+ }
230
+ return JSON.stringify(value.slice(0, 128));
231
+ }
177
232
  const hooks = legacy ? {
178
233
  sendMessage: (_b, chatId, content, replyTo) => config.agentschat.sendMessage(chatId, content, replyTo),
179
234
  getChatInfo: (_b, chatId) => config.agentschat.getChatInfo(chatId),
@@ -182,6 +237,8 @@ function startConnector(config) {
182
237
  } : config.agentschat;
183
238
  const sockets = new Set;
184
239
  const lastAddressed = new Map;
240
+ const delivered = new MessageDedup;
241
+ const inboundQueues = new Map;
185
242
  const http = createServer((_req, res) => {
186
243
  res.writeHead(200, { "Content-Type": "application/json" });
187
244
  res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
@@ -197,7 +254,8 @@ function startConnector(config) {
197
254
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
198
255
  const payload = peekPayload(token);
199
256
  const secrets = payload ? config.secrets[payload] : undefined;
200
- const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
257
+ const secret = secrets?.find((s) => verifyUpgradeToken(token, [s]) === payload);
258
+ const gatewayId = secret ? payload : null;
201
259
  if (!gatewayId) {
202
260
  log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
203
261
  wss.handleUpgrade(req, socket, head, (ws) => {
@@ -206,13 +264,13 @@ function startConnector(config) {
206
264
  return;
207
265
  }
208
266
  wss.handleUpgrade(req, socket, head, (ws) => {
209
- onConnection(ws, gatewayId);
267
+ onConnection(ws, gatewayId, secret);
210
268
  });
211
269
  });
212
- function onConnection(ws, gatewayId) {
213
- const conn = { ws, gatewayId, fronted: new Set };
270
+ function onConnection(ws, gatewayId, secret) {
271
+ const conn = { ws, gatewayId, secret, fronted: new Set };
214
272
  sockets.add(conn);
215
- log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
273
+ log(`[connector] gateway connected: ${safeLabel(gatewayId)} (${sockets.size} total)`);
216
274
  ws.on("message", async (data) => {
217
275
  const text = data.toString();
218
276
  for (const line of text.split(`
@@ -230,7 +288,7 @@ function startConnector(config) {
230
288
  });
231
289
  ws.on("close", () => {
232
290
  sockets.delete(conn);
233
- log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
291
+ log(`[connector] gateway disconnected: ${safeLabel(gatewayId)} (${sockets.size} left)`);
234
292
  });
235
293
  ws.on("error", () => sockets.delete(conn));
236
294
  }
@@ -238,17 +296,32 @@ function startConnector(config) {
238
296
  const t = frame?.type;
239
297
  if (t === "hello") {
240
298
  const botId = String(frame.botId ?? "");
241
- const identity = single ? table.all()[0] : botId ? table.forBot(botId) : null;
242
- if (!identity) {
243
- send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
299
+ const identity = legacy ? table.all()[0] : table.forBot(botId);
300
+ const reason = frame.platform !== "agentschat" ? "unsupported_platform" : !identity ? "unknown_identity" : !legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) ? "credential_mismatch" : null;
301
+ const labels = `gatewayId=${safeLabel(conn.gatewayId)} platform=${safeLabel(frame.platform === "agentschat" ? "agentschat" : "(unsupported)")} botId=${safeLabel(identity ? botId : "(unknown)")}`;
302
+ if (reason) {
303
+ log(`[connector] hello rejected ${labels} reason=${reason}`);
304
+ conn.fronted.clear();
305
+ send(conn.ws, { type: "error", error: `hello rejected: ${reason}` });
306
+ conn.ws.close(1002, `hello rejected: ${reason}`);
244
307
  return;
245
308
  }
246
309
  conn.fronted.add(identity.botId);
310
+ if (legacy)
311
+ conn.legacyAlias = botId;
312
+ log(`[connector] hello accepted ${labels}`);
247
313
  send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
248
314
  return;
249
315
  }
250
316
  if (t === "outbound") {
251
- const result = await handleOutbound(conn, frame);
317
+ let result;
318
+ try {
319
+ result = await handleOutbound(conn, frame);
320
+ } catch (error) {
321
+ const status = error instanceof PlatformHttpError && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : undefined;
322
+ result = status !== undefined ? { success: false, error: `agentschat HTTP ${status}`, code: "platform_http_error", status, ambiguous: status >= 500 } : error instanceof PlatformNetworkError ? { success: false, error: "agentschat network failure; outcome unknown", code: "platform_network_error", ambiguous: true } : { success: false, error: "platform operation failed; outcome unknown", code: "platform_operation_failed", ambiguous: true };
323
+ log(`[connector] outbound failed: ${result.code}${status ? ` status=${status}` : ""}`);
324
+ }
252
325
  send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
253
326
  return;
254
327
  }
@@ -257,12 +330,16 @@ function startConnector(config) {
257
330
  const action = frame?.action ?? {};
258
331
  const op = action?.op;
259
332
  const chatId = action?.chat_id ?? "";
260
- const firstFronted = [...conn.fronted][0];
261
- const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
262
- const identity = single ? table.all()[0] : requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
333
+ const firstFronted = conn.fronted.size === 1 ? [...conn.fronted][0] : undefined;
334
+ let requested = frame.botId === undefined ? firstFronted ?? null : typeof frame.botId === "string" && frame.botId ? frame.botId : null;
335
+ if (legacy && requested && requested === conn.legacyAlias)
336
+ requested = "default";
337
+ let identity = requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
338
+ if (identity && (!legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) || frame.platform !== undefined && frame.platform !== "agentschat"))
339
+ identity = null;
263
340
  if (!identity) {
264
- log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
265
- return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
341
+ log(`[connector] outbound failed: no usable identity for botId=${requested ?? "?"}`);
342
+ return { success: false, error: `no usable identity for outbound (botId=${requested ?? "?"})` };
266
343
  }
267
344
  switch (op) {
268
345
  case "send": {
@@ -287,11 +364,17 @@ function startConnector(config) {
287
364
  ws.send(JSON.stringify(obj) + `
288
365
  `);
289
366
  }
367
+ const ready = new Promise((resolve, reject) => {
368
+ http.once("listening", resolve);
369
+ http.once("error", reject);
370
+ });
290
371
  http.listen(config.port, config.host ?? "127.0.0.1");
291
- const address = http.address();
292
- const port = typeof address === "object" && address ? address.port : config.port;
293
372
  return {
294
- port,
373
+ ready,
374
+ get port() {
375
+ const address = http.address();
376
+ return typeof address === "object" && address ? address.port : config.port;
377
+ },
295
378
  stop() {
296
379
  for (const c of sockets) {
297
380
  try {
@@ -303,53 +386,102 @@ function startConnector(config) {
303
386
  },
304
387
  async injectAgentsChatMessage(msg) {
305
388
  const isDm = typeof msg.channel_id === "string" && msg.channel_id.startsWith("dm-");
306
- const bySocket = msg.__botId ? table.forBot(String(msg.__botId)) : null;
307
- const target = isDm ? bySocket ?? routeInbound(table, {
308
- channel_id: msg.channel_id,
309
- dmOwnerBotId: msg.dm_owner
310
- }) ?? (table.isSingle() ? table.all()[0] : null) : routeInbound(table, {
389
+ const owner = msg.__botId ?? msg.dm_owner;
390
+ const targets = isDm ? [owner !== undefined ? typeof owner === "string" ? table.forBot(owner) : null : table.isSingle() ? table.all()[0] : null].filter((id) => !!id) : routeInboundTargets(table, {
311
391
  channel_id: msg.channel_id,
312
392
  mentioned_ids: msg.mentioned_ids,
313
393
  content: msg.content
314
394
  });
315
- if (!target) {
395
+ if (!targets.length) {
316
396
  log(`[connector] inbound unaddressed (channel=${msg.channel_id ?? "?"} mentions=${JSON.stringify(msg.mentioned_ids ?? [])}) — dropped`);
317
397
  return;
318
398
  }
319
- const baseEvent = toWireEvent(msg, "agentschat");
320
- if (!baseEvent)
321
- return;
322
- if (!isDm) {
323
- const key = `${target.botId}:${msg.channel_id}`;
324
- const since = lastAddressed.get(key);
325
- if (hooks.getChannelContext) {
326
- try {
327
- const ctx = await hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id);
328
- if (ctx && ctx.length) {
329
- baseEvent.context = ctx.slice(-10).map((c) => ({
330
- text: String(c?.text ?? "").slice(0, 500),
331
- source: { user_name: c?.user_name, user_id: c?.user_id }
332
- }));
399
+ await Promise.all(targets.map((target) => {
400
+ const key = JSON.stringify([target.botId, msg.channel_id]);
401
+ const previous = inboundQueues.get(key) ?? Promise.resolve();
402
+ const job = previous.catch(() => {}).then(async () => {
403
+ if (msg.sender_id === target.agentId)
404
+ return;
405
+ const baseEvent = toWireEvent(msg, "agentschat");
406
+ if (!baseEvent)
407
+ return;
408
+ if (![...sockets].some((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)))
409
+ return;
410
+ const deliveryKey = typeof msg.id === "string" && msg.id ? JSON.stringify([msg.channel_id, msg.id, target.botId]) : null;
411
+ if (deliveryKey && delivered.recordOrSkip(deliveryKey))
412
+ return;
413
+ if (!isDm) {
414
+ const since = lastAddressed.get(key);
415
+ if (hooks.getChannelContext) {
416
+ const controller = new AbortController;
417
+ let timer;
418
+ try {
419
+ const ctx = await Promise.race([
420
+ hooks.getChannelContext(target.botId, msg.channel_id, since, msg.id, controller.signal),
421
+ new Promise((resolve) => {
422
+ timer = setTimeout(() => {
423
+ controller.abort();
424
+ resolve(null);
425
+ }, 1000);
426
+ })
427
+ ]);
428
+ if (ctx && ctx.length) {
429
+ baseEvent.context = ctx.slice(-10).map((c) => ({
430
+ text: String(c?.text ?? "").slice(0, 500),
431
+ source: { user_name: c?.user_name, user_id: c?.user_id }
432
+ }));
433
+ }
434
+ } catch {
435
+ log(`[connector] context fetch failed (delivering without it)`);
436
+ } finally {
437
+ if (timer)
438
+ clearTimeout(timer);
439
+ }
333
440
  }
334
- } catch (e) {
335
- log(`[connector] context fetch failed for ${key} (delivering without it): ${e}`);
441
+ if (msg.timestamp && Number.isFinite(Date.parse(msg.timestamp)) && (!since || Date.parse(msg.timestamp) > Date.parse(since)))
442
+ lastAddressed.set(key, msg.timestamp);
336
443
  }
337
- }
338
- if (msg.timestamp)
339
- lastAddressed.set(key, msg.timestamp);
340
- }
341
- for (const conn of sockets) {
342
- if (!conn.fronted.has(target.botId))
343
- continue;
344
- const event = { ...baseEvent, source: { ...baseEvent.source } };
345
- const profile = hermesSourceProfile(target, conn.fronted.size);
346
- if (profile)
347
- event.source.profile = profile;
348
- send(conn.ws, { type: "inbound", event });
349
- }
444
+ const deliverTo = [...sockets].filter((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)).slice(0, 1);
445
+ if (deliverTo.length === 0) {
446
+ log(`[connector] inbound dropped for botId=${target.botId}: no agentschat-fronted gateway socket`);
447
+ return;
448
+ }
449
+ for (const conn of deliverTo) {
450
+ const event = { ...baseEvent, source: { ...baseEvent.source } };
451
+ const profile = hermesSourceProfile(target, conn.fronted.size);
452
+ if (profile)
453
+ event.source.profile = profile;
454
+ send(conn.ws, { type: "inbound", event });
455
+ }
456
+ });
457
+ inboundQueues.set(key, job);
458
+ return job.finally(() => {
459
+ if (inboundQueues.get(key) === job)
460
+ inboundQueues.delete(key);
461
+ });
462
+ }));
350
463
  },
351
464
  connections() {
352
465
  return sockets.size;
466
+ },
467
+ reloadIdentities(next) {
468
+ if (!next || next.length === 0) {
469
+ throw new Error("reloadIdentities requires a non-empty identity list");
470
+ }
471
+ table.replace(next);
472
+ for (const conn of sockets) {
473
+ for (const botId of conn.fronted) {
474
+ const id = table.forBot(botId);
475
+ if (!id || id.gatewayId !== conn.gatewayId || id.secret !== conn.secret)
476
+ conn.fronted.delete(botId);
477
+ }
478
+ }
479
+ for (const k of Object.keys(config.secrets))
480
+ delete config.secrets[k];
481
+ for (const id of next) {
482
+ (config.secrets[id.gatewayId] ??= []).push(id.secret);
483
+ }
484
+ log(`[connector] identities reloaded: ${table.size}`);
353
485
  }
354
486
  };
355
487
  }
@@ -424,38 +556,6 @@ function normalizeTimestampForCursor(ts, mode) {
424
556
  return ts;
425
557
  }
426
558
 
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
559
  // connector/backfill.ts
460
560
  function planBackfill(after, msgs, agentId) {
461
561
  const list = Array.isArray(msgs) ? msgs : [];
@@ -485,9 +585,11 @@ function ingestAgentsChatFrame(id, frame, deps) {
485
585
  deps.advanceCursor(id, frame.channel_id, frame.timestamp);
486
586
  }
487
587
  const key = messageDedupKey(frame);
488
- if (key && deps.dedup.recordOrSkip(key))
588
+ const isDm = frame.channel_id?.startsWith("dm-");
589
+ const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
590
+ if (scopedKey && deps.dedup.recordOrSkip(scopedKey))
489
591
  return false;
490
- return frame.sender_id !== id.agentId && frame.content !== "__typing__";
592
+ return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
491
593
  }
492
594
 
493
595
  // src/heartbeat.ts
@@ -579,34 +681,52 @@ var API = (process.env.AGENTCHAT_API_URL || "https://agents-chat.com").replace(/
579
681
  var WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws";
580
682
  var PORT = Number(process.env.RELAY_PORT || 8765);
581
683
  var HOST = process.env.RELAY_HOST || "127.0.0.1";
582
- function resolveIdentities() {
583
- const raw = (process.env.RELAY_IDENTITIES || "").trim();
584
- if (raw) {
585
- let parsed;
586
- try {
587
- parsed = JSON.parse(raw);
588
- } catch (e) {
589
- log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
590
- process.exit(1);
591
- }
592
- if (!Array.isArray(parsed) || parsed.length === 0) {
593
- log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
594
- process.exit(1);
684
+ function parseIdentitiesJson(raw, source) {
685
+ let parsed;
686
+ try {
687
+ parsed = JSON.parse(raw);
688
+ } catch {
689
+ throw new Error(`${source} is not valid JSON`);
690
+ }
691
+ if (!Array.isArray(parsed) || parsed.length === 0) {
692
+ throw new Error(`${source} must be a non-empty JSON array`);
693
+ }
694
+ const seen = new Set;
695
+ for (const [index, it] of parsed.entries()) {
696
+ const invalid = ["botId", "token", "gatewayId", "secret"].filter((k) => typeof it?.[k] !== "string" || !it[k].trim());
697
+ for (const k of ["agentId", "profile"]) {
698
+ if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim()))
699
+ invalid.push(k);
595
700
  }
596
- for (const it of parsed) {
597
- if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
598
- log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
599
- process.exit(1);
600
- }
701
+ if (invalid.length)
702
+ throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
703
+ if (seen.has(it.botId))
704
+ throw new Error(`${source} entry ${index}: duplicate botId`);
705
+ seen.add(it.botId);
706
+ }
707
+ return parsed.map((it) => ({
708
+ botId: String(it.botId),
709
+ agentId: String(it.agentId ?? it.botId),
710
+ token: String(it.token),
711
+ gatewayId: String(it.gatewayId),
712
+ secret: String(it.secret),
713
+ ...it.profile ? { profile: String(it.profile) } : {}
714
+ }));
715
+ }
716
+ function loadIdentities() {
717
+ const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
718
+ if (file) {
719
+ let raw2;
720
+ try {
721
+ raw2 = readFileSync2(file, "utf8");
722
+ } catch {
723
+ throw new Error("RELAY_IDENTITIES_FILE could not be read");
601
724
  }
602
- return parsed.map((it) => ({
603
- botId: String(it.botId),
604
- agentId: String(it.agentId ?? it.botId),
605
- token: String(it.token),
606
- gatewayId: String(it.gatewayId),
607
- secret: String(it.secret),
608
- ...it.profile ? { profile: String(it.profile) } : {}
609
- }));
725
+ return parseIdentitiesJson(raw2, "RELAY_IDENTITIES_FILE");
726
+ }
727
+ const raw = (process.env.RELAY_IDENTITIES || "").trim();
728
+ if (raw) {
729
+ return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
610
730
  }
611
731
  const agentId = need("AGENTCHAT_AGENT_ID");
612
732
  const token = need("AGENTCHAT_TOKEN");
@@ -614,14 +734,42 @@ function resolveIdentities() {
614
734
  const secret = need("RELAY_GATEWAY_SECRET");
615
735
  return [{ botId: agentId, agentId, token, gatewayId, secret }];
616
736
  }
617
- var identities = resolveIdentities();
618
- var single = identities.length === 1;
737
+ function resolveIdentitiesAtStartup() {
738
+ try {
739
+ return loadIdentities();
740
+ } catch (e) {
741
+ log(`ERROR: ${e?.message ?? e}`);
742
+ process.exit(1);
743
+ }
744
+ }
745
+ var identities = resolveIdentitiesAtStartup();
619
746
  var secrets = {};
620
747
  for (const id of identities) {
621
748
  (secrets[id.gatewayId] ??= []).push(id.secret);
622
749
  }
623
750
  var broadcast = null;
624
751
  var socketsByBot = new Map;
752
+ var reconnectTimerByBot = new Map;
753
+ var currentIdentity = (id) => !process.__shutdown && identities.includes(id);
754
+ function cancelReconnect(botId) {
755
+ const timer = reconnectTimerByBot.get(botId);
756
+ if (timer)
757
+ clearTimeout(timer);
758
+ reconnectTimerByBot.delete(botId);
759
+ }
760
+ function scheduleReconnect(id, delay) {
761
+ if (!currentIdentity(id))
762
+ return;
763
+ cancelReconnect(id.botId);
764
+ const timer = setTimeout(() => {
765
+ if (reconnectTimerByBot.get(id.botId) !== timer)
766
+ return;
767
+ reconnectTimerByBot.delete(id.botId);
768
+ if (currentIdentity(id))
769
+ connectIdentity(id);
770
+ }, delay);
771
+ reconnectTimerByBot.set(id.botId, timer);
772
+ }
625
773
  var backoffByBot = new Map;
626
774
  var backfillTimerByBot = new Map;
627
775
  var heartbeatsByBot = new Map;
@@ -693,6 +841,8 @@ async function joinMemberships(ws, id) {
693
841
  return;
694
842
  }
695
843
  const body = await r.json();
844
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
845
+ return;
696
846
  const channels = Array.isArray(body) ? body : body.channels || [];
697
847
  const joined = [];
698
848
  for (const ch of channels) {
@@ -707,7 +857,7 @@ async function joinMemberships(ws, id) {
707
857
  clearTimeout(prev);
708
858
  const t = setTimeout(() => {
709
859
  backfillTimerByBot.delete(id.botId);
710
- if (process.__shutdown)
860
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
711
861
  return;
712
862
  backfillIdentity(id, joined);
713
863
  }, 2000);
@@ -720,6 +870,8 @@ async function joinMemberships(ws, id) {
720
870
  async function backfillIdentity(id, channelIds) {
721
871
  const s = cursorFor(id);
722
872
  for (const channelId of channelIds) {
873
+ if (!currentIdentity(id))
874
+ return;
723
875
  try {
724
876
  const after = s.map.get(channelId);
725
877
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
@@ -729,6 +881,8 @@ async function backfillIdentity(id, channelIds) {
729
881
  if (!r.ok)
730
882
  continue;
731
883
  const msgs = (await r.json())?.messages ?? [];
884
+ if (!currentIdentity(id))
885
+ return;
732
886
  const plan = planBackfill(after, msgs, id.agentId);
733
887
  if (plan.seed) {
734
888
  s.map.set(channelId, plan.seed);
@@ -750,6 +904,9 @@ async function backfillIdentity(id, channelIds) {
750
904
  }
751
905
  }
752
906
  function connectIdentity(id) {
907
+ if (!currentIdentity(id))
908
+ return;
909
+ cancelReconnect(id.botId);
753
910
  stopHeartbeat(id.botId);
754
911
  const prev = socketsByBot.get(id.botId);
755
912
  if (prev) {
@@ -771,7 +928,7 @@ function connectIdentity(id) {
771
928
  } catch {}
772
929
  },
773
930
  reconnect: () => {
774
- if (process.__shutdown || heartbeatForced)
931
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced)
775
932
  return;
776
933
  heartbeatForced = true;
777
934
  log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
@@ -782,22 +939,25 @@ function connectIdentity(id) {
782
939
  } catch {}
783
940
  socketsByBot.delete(id.botId);
784
941
  stopHeartbeat(id.botId);
785
- setTimeout(() => {
786
- if (!process.__shutdown)
787
- connectIdentity(id);
788
- }, 500);
942
+ scheduleReconnect(id, 500);
789
943
  },
790
944
  getReadyState: () => ws.readyState ?? WS_CLOSED
791
945
  }, HB_PING_MS, HB_PONG_MS, HB_CONNECT_MS);
792
946
  heartbeatsByBot.set(id.botId, hb);
793
947
  hb.start();
794
948
  ws.on("open", () => {
949
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) {
950
+ ws.close();
951
+ return;
952
+ }
795
953
  backoffByBot.set(id.botId, 1000);
796
954
  try {
797
955
  ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
798
956
  } catch {}
799
957
  });
800
958
  ws.on("message", (raw) => {
959
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
960
+ return;
801
961
  let data;
802
962
  try {
803
963
  data = JSON.parse(String(raw));
@@ -829,6 +989,8 @@ function connectIdentity(id) {
829
989
  }
830
990
  });
831
991
  ws.on("close", () => {
992
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws)
993
+ return;
832
994
  stopHeartbeat(id.botId);
833
995
  if (process.__shutdown)
834
996
  return;
@@ -836,7 +998,7 @@ function connectIdentity(id) {
836
998
  return;
837
999
  const delay = backoffByBot.get(id.botId) ?? 1000;
838
1000
  log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
839
- setTimeout(() => connectIdentity(id), delay);
1001
+ scheduleReconnect(id, delay);
840
1002
  backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
841
1003
  });
842
1004
  ws.on("error", (e) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
@@ -848,19 +1010,21 @@ var connector = startConnector({
848
1010
  identities,
849
1011
  agentschat: {
850
1012
  async sendMessage(botId, chatId, content, replyTo) {
851
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1013
+ const id = requireIdentity(identities, botId);
852
1014
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
853
1015
  method: "POST",
854
1016
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
855
1017
  body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...replyTo ? { parent_id: replyTo } : {} })
1018
+ }).catch(() => {
1019
+ throw new PlatformNetworkError;
856
1020
  });
857
1021
  if (!res.ok)
858
- throw new Error(`agentschat send failed: ${res.status}`);
1022
+ throw new PlatformHttpError(res.status);
859
1023
  const data = await res.json();
860
1024
  return { id: data?.id };
861
1025
  },
862
1026
  async getChatInfo(botId, chatId) {
863
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1027
+ const id = requireIdentity(identities, botId);
864
1028
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
865
1029
  headers: { Authorization: `Bearer ${id.token}` }
866
1030
  });
@@ -870,7 +1034,7 @@ var connector = startConnector({
870
1034
  return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
871
1035
  },
872
1036
  async sendTyping(botId, chatId) {
873
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1037
+ const id = requireIdentity(identities, botId);
874
1038
  const ws = socketsByBot.get(id.botId);
875
1039
  if (ws && ws.readyState === WS.OPEN) {
876
1040
  try {
@@ -878,9 +1042,10 @@ var connector = startConnector({
878
1042
  } catch {}
879
1043
  }
880
1044
  },
881
- async getChannelContext(botId, chatId, sinceTs, excludeId) {
882
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
1045
+ async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
1046
+ const id = requireIdentity(identities, botId);
883
1047
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
1048
+ signal,
884
1049
  headers: { Authorization: `Bearer ${id.token}` }
885
1050
  });
886
1051
  if (!res.ok)
@@ -900,12 +1065,68 @@ var connector = startConnector({
900
1065
  logger: log
901
1066
  });
902
1067
  broadcast = (msg) => connector.injectAgentsChatMessage(msg);
1068
+ await connector.ready;
903
1069
  log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
904
1070
  for (const id of identities)
905
1071
  connectIdentity(id);
1072
+ function reloadIdentitiesFromConfig() {
1073
+ const next = loadIdentities().map((id) => identities.find((prev) => prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token && prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
1074
+ const prevByBot = new Map(identities.map((i) => [i.botId, i]));
1075
+ const nextByBot = new Map(next.map((i) => [i.botId, i]));
1076
+ const added = next.filter((i) => !prevByBot.has(i.botId));
1077
+ const removed = identities.filter((i) => !nextByBot.has(i.botId));
1078
+ const kept = next.filter((i) => prevByBot.has(i.botId));
1079
+ connector.reloadIdentities(next);
1080
+ identities = next;
1081
+ for (const id of removed) {
1082
+ cancelReconnect(id.botId);
1083
+ log(`hot-reload: disconnecting removed identity ${id.botId}`);
1084
+ stopHeartbeat(id.botId);
1085
+ const t = backfillTimerByBot.get(id.botId);
1086
+ if (t) {
1087
+ clearTimeout(t);
1088
+ backfillTimerByBot.delete(id.botId);
1089
+ }
1090
+ const ws = socketsByBot.get(id.botId);
1091
+ if (ws) {
1092
+ try {
1093
+ ws.removeAllListeners("close");
1094
+ ws.close();
1095
+ } catch {}
1096
+ socketsByBot.delete(id.botId);
1097
+ }
1098
+ backoffByBot.delete(id.botId);
1099
+ }
1100
+ for (const id of kept) {
1101
+ const prev = prevByBot.get(id.botId);
1102
+ if (prev !== id) {
1103
+ cancelReconnect(id.botId);
1104
+ const timer = backfillTimerByBot.get(id.botId);
1105
+ if (timer)
1106
+ clearTimeout(timer);
1107
+ backfillTimerByBot.delete(id.botId);
1108
+ log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
1109
+ connectIdentity(id);
1110
+ }
1111
+ }
1112
+ for (const id of added) {
1113
+ log(`hot-reload: connecting new identity ${id.botId}`);
1114
+ connectIdentity(id);
1115
+ }
1116
+ log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
1117
+ }
1118
+ process.on("SIGHUP", () => {
1119
+ try {
1120
+ reloadIdentitiesFromConfig();
1121
+ } catch (e) {
1122
+ log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
1123
+ }
1124
+ });
906
1125
  for (const sig of ["SIGINT", "SIGTERM"]) {
907
1126
  process.on(sig, () => {
908
1127
  process.__shutdown = true;
1128
+ for (const botId of reconnectTimerByBot.keys())
1129
+ cancelReconnect(botId);
909
1130
  for (const botId of [...heartbeatsByBot.keys()])
910
1131
  stopHeartbeat(botId);
911
1132
  for (const ws of socketsByBot.values()) {