@unagents/messenger-broker 0.10.0 → 0.11.1

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/index.d.mts CHANGED
@@ -22,6 +22,11 @@ interface BrokerOptions {
22
22
  discoveryPort?: number;
23
23
  /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
24
  discoveryAddr?: string;
25
+ /**
26
+ * Explicit discovery send targets, `host:port` each (`AGENT_MESSENGER_DISCOVERY_TARGETS`, comma separated).
27
+ * When non-empty, probes and announces go to these instead of broadcast (tests; LAN without broadcast).
28
+ */
29
+ discoveryTargets?: string[];
25
30
  /**
26
31
  * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
32
  * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
@@ -38,16 +43,16 @@ interface BrokerOptions {
38
43
  */
39
44
  mesh?: boolean;
40
45
  /**
41
- * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
- * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
46
+ * Test-only: a fixed list of mesh members that replaces discovery. Empty (default) = members are
47
+ * found by UDP discovery (announce / probe reply, token-checked). Not documented for users.
43
48
  */
44
49
  meshBrokers?: string[];
50
+ /** Mesh announce interval in ms (`AGENT_MESSENGER_MESH_ANNOUNCE_MS`, default 10000). */
51
+ meshAnnounceMs?: number;
45
52
  /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
53
  meshIntervalMs?: number;
47
54
  /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
55
  meshPeerTtlMs?: number;
49
- /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
- configFile?: string;
51
56
  }
52
57
  type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
58
  /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
package/dist/index.d.ts CHANGED
@@ -22,6 +22,11 @@ interface BrokerOptions {
22
22
  discoveryPort?: number;
23
23
  /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
24
  discoveryAddr?: string;
25
+ /**
26
+ * Explicit discovery send targets, `host:port` each (`AGENT_MESSENGER_DISCOVERY_TARGETS`, comma separated).
27
+ * When non-empty, probes and announces go to these instead of broadcast (tests; LAN without broadcast).
28
+ */
29
+ discoveryTargets?: string[];
25
30
  /**
26
31
  * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
32
  * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
@@ -38,16 +43,16 @@ interface BrokerOptions {
38
43
  */
39
44
  mesh?: boolean;
40
45
  /**
41
- * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
- * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
46
+ * Test-only: a fixed list of mesh members that replaces discovery. Empty (default) = members are
47
+ * found by UDP discovery (announce / probe reply, token-checked). Not documented for users.
43
48
  */
44
49
  meshBrokers?: string[];
50
+ /** Mesh announce interval in ms (`AGENT_MESSENGER_MESH_ANNOUNCE_MS`, default 10000). */
51
+ meshAnnounceMs?: number;
45
52
  /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
53
  meshIntervalMs?: number;
47
54
  /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
55
  meshPeerTtlMs?: number;
49
- /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
- configFile?: string;
51
56
  }
52
57
  type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
58
  /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import 'listhen';
2
2
  import 'h3';
3
3
  import '@unagents/messenger-protocol';
4
- export { D as DEFAULT_STATE_FILE, F as FALLBACK_ENV, a as FALLBACK_INTERVAL_ENV, M as MESH_ENV, S as STATE_DIR, V as VERSION, c as configFromEnv, r as resolveConfig, s as startBroker } from './shared/messenger-broker.B8W77sqB.mjs';
4
+ export { D as DEFAULT_STATE_FILE, F as FALLBACK_ENV, a as FALLBACK_INTERVAL_ENV, M as MESH_ENV, S as STATE_DIR, V as VERSION, c as configFromEnv, r as resolveConfig, s as startBroker } from './shared/messenger-broker.BPu-Wz2y.mjs';
5
5
  import 'node:os';
6
6
  import 'node:path';
7
7
  import 'defu';
package/dist/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { s as startBroker, l as log } from './shared/messenger-broker.B8W77sqB.mjs';
1
+ import { s as startBroker, l as log } from './shared/messenger-broker.BPu-Wz2y.mjs';
2
2
  import 'listhen';
3
3
  import 'h3';
4
4
  import '@unagents/messenger-protocol';
@@ -1,12 +1,12 @@
1
1
  import { listen } from 'listhen';
2
2
  import { createRouter, defineEventHandler, createApp, send, setResponseStatus, getRequestHeader, readRawBody, toNodeListener } from 'h3';
3
- import { DEFAULT_BIND, ENV, DEFAULT_DISCOVERY_PORT, DEFAULT_HTTP_PORT, PROTOCOL_NAME_PATTERN, PROTOCOL_META_MAX_LENGTH, PROTOCOL_MANIFEST_MAX_BYTES, PROTOCOL_DOC_MAX_BYTES, PROTOCOL_TOTAL_MAX_BYTES, canonicalJSON, HEARTBEAT_TIMEOUT_MS, PROTOCOL_MAX_STORED, MESH_SYNC_ROUTE, HEALTH_ROUTE, BROKER_POST_ROUTES, PROTOCOL_ROUTES, RELAY_ROUTE, DISCOVERY_KEY, DISCOVERY_PROBE_VALUE, DISCOVERY_REPLY_VALUE } from '@unagents/messenger-protocol';
3
+ import { DEFAULT_BIND, MESH_ANNOUNCE_ENV, DISCOVERY_TARGETS_ENV, ENV, DEFAULT_DISCOVERY_PORT, DEFAULT_HTTP_PORT, PROTOCOL_NAME_PATTERN, PROTOCOL_META_MAX_LENGTH, PROTOCOL_MANIFEST_MAX_BYTES, PROTOCOL_DOC_MAX_BYTES, PROTOCOL_TOTAL_MAX_BYTES, canonicalJSON, HEARTBEAT_TIMEOUT_MS, PROTOCOL_MAX_STORED, MESH_SYNC_ROUTE, DISCOVERY_KEY, DISCOVERY_ANNOUNCE_VALUE, MESH_ANNOUNCE_MAX_SKEW_MS, DISCOVERY_REPLY_VALUE, meshMacEqual, meshMac, HEALTH_ROUTE, BROKER_POST_ROUTES, PROTOCOL_ROUTES, RELAY_ROUTE, DISCOVERY_PROBE_VALUE } from '@unagents/messenger-protocol';
4
4
  import { homedir, hostname, networkInterfaces } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { defu } from 'defu';
7
7
  import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
8
8
  import { createConsola } from 'consola';
9
- import { createHash } from 'node:crypto';
9
+ import { createHash, randomBytes } from 'node:crypto';
10
10
  import { request } from 'node:http';
11
11
  import { createSocket } from 'node:dgram';
12
12
 
@@ -30,13 +30,14 @@ function configFromEnv(env = process.env) {
30
30
  discovery: (env[ENV.DISCOVERY] ?? "on").toLowerCase() !== "off",
31
31
  discoveryPort: parseInt(env[ENV.DISCOVERY_PORT] ?? String(DEFAULT_DISCOVERY_PORT), 10),
32
32
  discoveryAddr: env[ENV.DISCOVERY_ADDR] ?? "",
33
+ discoveryTargets: (env[DISCOVERY_TARGETS_ENV] ?? "").split(",").map((s) => s.trim()).filter(Boolean),
33
34
  fallback: ["1", "true", "on", "yes"].includes((env[FALLBACK_ENV] ?? "").toLowerCase()),
34
35
  fallbackIntervalMs: parseInt(env[FALLBACK_INTERVAL_ENV] ?? String(DEFAULT_FALLBACK_INTERVAL_MS), 10),
35
36
  mesh: (env[MESH_ENV.MESH] ?? "off").toLowerCase() === "on",
36
37
  meshBrokers: [],
38
+ meshAnnounceMs: parseInt(env[MESH_ANNOUNCE_ENV] ?? "10000", 10),
37
39
  meshIntervalMs: parseInt(env[MESH_ENV.INTERVAL_MS] ?? "5000", 10),
38
- meshPeerTtlMs: parseInt(env[MESH_ENV.PEER_TTL_MS] ?? "300000", 10),
39
- configFile: env[ENV.CONFIG] ?? join(STATE_DIR, "config.json")
40
+ meshPeerTtlMs: parseInt(env[MESH_ENV.PEER_TTL_MS] ?? "300000", 10)
40
41
  };
41
42
  }
42
43
  function resolveConfig(options = {}) {
@@ -225,6 +226,8 @@ function compareVersions(a, b) {
225
226
  const PENDING_RELAY_TTL_MS = 60 * 60 * 1e3;
226
227
  const DEFAULT_MESH_PEER_TTL_MS = 3e5;
227
228
  const MESH_KEY_MEMORY = 2e3;
229
+ class BadRequestError extends Error {
230
+ }
228
231
  class BrokerHandlers {
229
232
  constructor(state, opts = {}) {
230
233
  this.state = state;
@@ -426,6 +429,9 @@ class BrokerHandlers {
426
429
  return list.map(({ origin: _origin, ...p }) => p);
427
430
  }
428
431
  sendMessage(body) {
432
+ const rawText = body.text;
433
+ if (rawText !== void 0 && typeof rawText !== "string") throw new BadRequestError("invalid text: expected a string");
434
+ if (body.mode !== "ping" && (rawText === void 0 || rawText === "")) throw new BadRequestError("invalid text: expected a non-empty string");
429
435
  const resolved = this.resolvePeer(body.to);
430
436
  if (resolved.error !== void 0) return { ok: false, error: resolved.error };
431
437
  const { peer } = resolved;
@@ -780,6 +786,15 @@ class Mesh {
780
786
  if (this.timer) clearInterval(this.timer);
781
787
  this.timer = null;
782
788
  }
789
+ /** True while the last sync with `url` succeeded. */
790
+ linkUp(url) {
791
+ return this.links.get(url) === "up";
792
+ }
793
+ /** Forget everything learned about `url` (its broker left or moved). */
794
+ forget(url) {
795
+ this.links.delete(url);
796
+ this.origins.delete(url);
797
+ }
783
798
  /** Sync soon (coalesced). */
784
799
  kick() {
785
800
  if (this.running) this.again = true;
@@ -843,24 +858,6 @@ class Mesh {
843
858
  this.setLink(url, "up");
844
859
  }
845
860
  }
846
- function readMeshBrokers(configFile) {
847
- try {
848
- const parsed = JSON.parse(readFileSync(configFile, "utf8"));
849
- const list = Array.isArray(parsed.brokers) ? parsed.brokers : parsed.broker ? [parsed.broker] : [];
850
- const out = [];
851
- for (const s of list) {
852
- if (typeof s !== "string" || !s.trim()) continue;
853
- try {
854
- out.push(normalizeBrokerUrl(s));
855
- } catch {
856
- log.warn(`mesh: ignoring unparsable broker entry "${s}"`);
857
- }
858
- }
859
- return out;
860
- } catch {
861
- return [];
862
- }
863
- }
864
861
  function normalizeBrokerUrl(s) {
865
862
  s = s.trim();
866
863
  if (!/^https?:\/\//.test(s)) s = `http://${s}`;
@@ -882,6 +879,81 @@ function postJson(url, headers, body, timeoutMs) {
882
879
  });
883
880
  }
884
881
 
882
+ function meshExpireMs(announceMs) {
883
+ return announceMs * 3 + 5e3;
884
+ }
885
+ class MeshPeerTable {
886
+ constructor(opts) {
887
+ this.opts = opts;
888
+ this.now = opts.now ?? Date.now;
889
+ }
890
+ byId = /* @__PURE__ */ new Map();
891
+ mismatchLogged = /* @__PURE__ */ new Set();
892
+ now;
893
+ /** Current sync targets. */
894
+ urls() {
895
+ return [...this.byId.values()].map((e) => e.url);
896
+ }
897
+ entries() {
898
+ return [...this.byId.values()];
899
+ }
900
+ /** `announce` packet from `address` (UDP source). Returns true when accepted. */
901
+ handleAnnounce(m, address) {
902
+ if (m[DISCOVERY_KEY] !== DISCOVERY_ANNOUNCE_VALUE || m.mesh !== true) return false;
903
+ const ts = m.ts;
904
+ if (typeof ts !== "number" || !Number.isFinite(ts) || Math.abs(this.now() - ts) > MESH_ANNOUNCE_MAX_SKEW_MS) return false;
905
+ return this.accept(m, address, String(ts));
906
+ }
907
+ /** `broker` reply to our probe that carried `nonce`. Returns true when accepted. */
908
+ handleReply(m, address, nonce) {
909
+ if (m[DISCOVERY_KEY] !== DISCOVERY_REPLY_VALUE || m.mesh !== true) return false;
910
+ return this.accept(m, address, nonce);
911
+ }
912
+ accept(m, address, nonce) {
913
+ const { id, port, version, mac } = m;
914
+ if (typeof id !== "string" || !id || typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) return false;
915
+ if (typeof mac !== "string") return false;
916
+ if (!meshMacEqual(meshMac(this.opts.token, nonce, id, port), mac)) {
917
+ const key = `${id}@${address}`;
918
+ if (!this.mismatchLogged.has(key)) {
919
+ this.mismatchLogged.add(key);
920
+ log.warn(`mesh: ignoring ${id}@${address} (token mismatch)`);
921
+ }
922
+ return false;
923
+ }
924
+ if (id === this.opts.selfId) return false;
925
+ const url = `http://${address}:${port}`;
926
+ const prev = this.byId.get(id);
927
+ const entry = { id, url, version: typeof version === "string" ? version : "", lastSeen: this.now() };
928
+ this.byId.set(id, entry);
929
+ if (!prev) {
930
+ log.info(`mesh: found ${id} at ${url}`);
931
+ this.opts.onJoin?.(entry);
932
+ } else if (prev.url !== url) {
933
+ log.info(`mesh: ${id} moved ${prev.url} -> ${url}`);
934
+ this.opts.onDrop?.(prev.url);
935
+ this.opts.onJoin?.(entry);
936
+ }
937
+ return true;
938
+ }
939
+ /**
940
+ * Drop entries not vouched for within `expireMs` whose last sync did not succeed (`linkUp`): a link that
941
+ * still syncs stays even when UDP is lost. Returns the removed entries.
942
+ */
943
+ sweep(linkUp) {
944
+ const gone = [];
945
+ const t = this.now();
946
+ for (const e of this.byId.values()) {
947
+ if (t - e.lastSeen <= this.opts.expireMs || linkUp(e.url)) continue;
948
+ this.byId.delete(e.id);
949
+ gone.push(e);
950
+ log.info(`mesh: ${e.url} left`);
951
+ this.opts.onDrop?.(e.url);
952
+ }
953
+ return gone;
954
+ }
955
+ }
956
+
885
957
  function createBrokerApp(opts) {
886
958
  const routes = opts.handlers.routes();
887
959
  const protocolRoutes = opts.handlers.protocolRoutes();
@@ -892,14 +964,15 @@ function createBrokerApp(opts) {
892
964
  id: opts.id,
893
965
  port: opts.port(),
894
966
  peers: opts.handlers.alivePeerCount(),
895
- ...opts.fallback ? { fallback: true } : {}
967
+ ...opts.fallback ? { fallback: true } : {},
968
+ ...opts.mesh ? { mesh: true } : {}
896
969
  })));
897
970
  const post = (route, run) => router.post(route, defineEventHandler(async (event) => {
898
971
  if (!authorized(event, opts.token)) return unauthorized(event);
899
972
  try {
900
973
  return run(await readJson(event));
901
974
  } catch (e) {
902
- setResponseStatus(event, 500);
975
+ setResponseStatus(event, e instanceof BadRequestError ? 400 : 500);
903
976
  return { error: e.message };
904
977
  }
905
978
  }));
@@ -936,12 +1009,16 @@ function startDiscoveryResponder(opts) {
936
1009
  const disco = createSocket({ type: "udp4", reuseAddr: true });
937
1010
  disco.on("error", (e) => log.warn(`discovery responder error: ${e.message}`));
938
1011
  disco.on("message", (msg, rinfo) => {
1012
+ let m;
939
1013
  try {
940
- if (JSON.parse(msg.toString())[DISCOVERY_KEY] !== DISCOVERY_PROBE_VALUE) return;
1014
+ m = JSON.parse(msg.toString());
941
1015
  } catch {
942
1016
  return;
943
1017
  }
944
- const reply = Buffer.from(JSON.stringify(opts.reply()));
1018
+ if (m[DISCOVERY_KEY] === DISCOVERY_ANNOUNCE_VALUE) return opts.onAnnounce?.(m, rinfo);
1019
+ if (m[DISCOVERY_KEY] !== DISCOVERY_PROBE_VALUE) return;
1020
+ const nonce = typeof m.n === "string" && m.n.length > 0 && m.n.length <= 128 ? m.n : void 0;
1021
+ const reply = Buffer.from(JSON.stringify(opts.reply(nonce)));
945
1022
  disco.send(reply, rinfo.port, rinfo.address, () => {
946
1023
  });
947
1024
  });
@@ -958,6 +1035,48 @@ function startDiscoveryResponder(opts) {
958
1035
  });
959
1036
  });
960
1037
  }
1038
+ function parseDiscoveryTargets(list) {
1039
+ const out = [];
1040
+ for (const s of list) {
1041
+ const i = s.lastIndexOf(":");
1042
+ const port = Number(s.slice(i + 1));
1043
+ if (i <= 0 || !Number.isInteger(port) || port < 1 || port > 65535) {
1044
+ log.warn(`discovery: ignoring invalid target "${s}" (want host:port)`);
1045
+ continue;
1046
+ }
1047
+ out.push({ host: s.slice(0, i), port });
1048
+ }
1049
+ return out;
1050
+ }
1051
+ function sendTargets(opts) {
1052
+ const explicit = parseDiscoveryTargets(opts.targets ?? []);
1053
+ if (explicit.length) return explicit;
1054
+ return probeTargets(opts.addr).map((host) => ({ host, port: opts.port }));
1055
+ }
1056
+ function buildAnnounce(a) {
1057
+ return {
1058
+ [DISCOVERY_KEY]: DISCOVERY_ANNOUNCE_VALUE,
1059
+ id: a.id,
1060
+ port: a.port,
1061
+ version: a.version,
1062
+ mesh: true,
1063
+ ts: a.ts,
1064
+ mac: meshMac(a.token, String(a.ts), a.id, a.port)
1065
+ };
1066
+ }
1067
+ function sendAnnounce(sock, packet, targets) {
1068
+ const buf = Buffer.from(JSON.stringify(packet));
1069
+ for (const t of targets) {
1070
+ try {
1071
+ sock.send(buf, t.port, t.host, () => {
1072
+ });
1073
+ } catch {
1074
+ }
1075
+ }
1076
+ }
1077
+ function newNonce() {
1078
+ return randomBytes(16).toString("hex");
1079
+ }
961
1080
  function probeTargets(addr) {
962
1081
  if (addr) return [addr];
963
1082
  const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
@@ -1001,7 +1120,16 @@ function probeBrokers(opts) {
1001
1120
  if (m[DISCOVERY_KEY] !== DISCOVERY_REPLY_VALUE || !m.port) return;
1002
1121
  const url = `http://${rinfo.address}:${m.port}`;
1003
1122
  if (!found.has(url)) {
1004
- found.set(url, { url, host: rinfo.address, port: m.port, id: m.id ?? rinfo.address, version: m.version ?? "", fallback: m.fallback === true });
1123
+ found.set(url, {
1124
+ url,
1125
+ host: rinfo.address,
1126
+ port: m.port,
1127
+ id: m.id ?? rinfo.address,
1128
+ version: m.version ?? "",
1129
+ fallback: m.fallback === true,
1130
+ mesh: m.mesh === true,
1131
+ ...typeof m.mac === "string" ? { mac: m.mac } : {}
1132
+ });
1005
1133
  }
1006
1134
  } catch {
1007
1135
  }
@@ -1011,8 +1139,8 @@ function probeBrokers(opts) {
1011
1139
  sock.setBroadcast(true);
1012
1140
  } catch {
1013
1141
  }
1014
- const probe = Buffer.from(JSON.stringify({ [DISCOVERY_KEY]: DISCOVERY_PROBE_VALUE }));
1015
- for (const t of probeTargets(opts.addr)) sock.send(probe, opts.port, t, () => {
1142
+ const probe = Buffer.from(JSON.stringify({ [DISCOVERY_KEY]: DISCOVERY_PROBE_VALUE, ...opts.nonce ? { n: opts.nonce } : {} }));
1143
+ for (const t of sendTargets(opts)) sock.send(probe, t.port, t.host, () => {
1016
1144
  });
1017
1145
  });
1018
1146
  });
@@ -1068,7 +1196,7 @@ async function relayUndelivered(state, url, token) {
1068
1196
  return { sent, dropped };
1069
1197
  }
1070
1198
 
1071
- const version = "0.10.0";
1199
+ const version = "0.11.1";
1072
1200
  const pkg = {
1073
1201
  version: version};
1074
1202
 
@@ -1114,21 +1242,61 @@ async function startBroker(options = {}) {
1114
1242
  throw e;
1115
1243
  }
1116
1244
  port = listener.address.port;
1117
- const reply = () => ({ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE, id: cfg.id, port, version: VERSION, ...cfg.fallback ? { fallback: true } : {} });
1245
+ const reply = (nonce) => ({
1246
+ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE,
1247
+ id: cfg.id,
1248
+ port,
1249
+ version: VERSION,
1250
+ ...cfg.fallback ? { fallback: true } : {},
1251
+ ...cfg.mesh ? { mesh: true, ...nonce ? { mac: meshMac(cfg.token, nonce, cfg.id, port) } : {} } : {}
1252
+ });
1253
+ const fixed = cfg.meshBrokers.map(normalizeBrokerUrl);
1254
+ const peerTable = cfg.mesh ? new MeshPeerTable({
1255
+ selfId: cfg.id,
1256
+ token: cfg.token,
1257
+ expireMs: meshExpireMs(cfg.meshAnnounceMs),
1258
+ onDrop: (url) => mesh?.forget(url),
1259
+ onJoin: () => mesh?.kick()
1260
+ }) : null;
1118
1261
  let disco = null;
1119
1262
  if (cfg.discovery) {
1120
- disco = await startDiscoveryResponder({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, reply });
1263
+ disco = await startDiscoveryResponder({
1264
+ port: cfg.discoveryPort,
1265
+ addr: cfg.discoveryAddr,
1266
+ reply,
1267
+ ...peerTable ? { onAnnounce: (m, rinfo) => void peerTable.handleAnnounce(m, rinfo.address) } : {}
1268
+ });
1121
1269
  }
1270
+ let announceTimer = null;
1122
1271
  if (cfg.mesh) {
1123
- const fixed = cfg.meshBrokers.map(normalizeBrokerUrl);
1124
1272
  mesh = new Mesh({
1125
1273
  id: cfg.id,
1126
1274
  token: cfg.token,
1127
1275
  handlers,
1128
- urls: () => fixed.length ? fixed : readMeshBrokers(cfg.configFile),
1276
+ urls: () => fixed.length ? fixed : peerTable.urls(),
1129
1277
  intervalMs: cfg.meshIntervalMs
1130
1278
  });
1131
1279
  mesh.start();
1280
+ if (!cfg.discovery && !fixed.length) {
1281
+ log.warn("mesh: AGENT_MESSENGER_MESH=on but AGENT_MESSENGER_DISCOVERY=off; no other mesh broker can be found");
1282
+ }
1283
+ if (cfg.discovery && disco && !fixed.length) {
1284
+ const sock = disco;
1285
+ const targets = sendTargets({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets });
1286
+ const announceAndSweep = () => {
1287
+ sendAnnounce(sock, buildAnnounce({ token: cfg.token, id: cfg.id, port, version: VERSION, ts: Date.now() }), targets);
1288
+ peerTable.sweep((url) => mesh?.linkUp(url) ?? false);
1289
+ };
1290
+ announceAndSweep();
1291
+ announceTimer = setInterval(announceAndSweep, cfg.meshAnnounceMs);
1292
+ announceTimer.unref();
1293
+ const nonce = newNonce();
1294
+ void probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets, nonce, timeoutMs: 1e3 }).then((found) => {
1295
+ for (const b of found) {
1296
+ peerTable.handleReply({ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE, id: b.id, port: b.port, version: b.version, mesh: b.mesh, mac: b.mac }, b.host, nonce);
1297
+ }
1298
+ });
1299
+ }
1132
1300
  }
1133
1301
  log.info(`v${VERSION} id=${cfg.id} listening on ${cfg.bind}:${port} (state: ${cfg.stateFile})`);
1134
1302
  if (cfg.fallback) log.info(`FALLBACK mode: looking for another broker every ${cfg.fallbackIntervalMs} ms (discovery ${cfg.discovery ? "on" : "off"})`);
@@ -1140,6 +1308,7 @@ async function startBroker(options = {}) {
1140
1308
  closed = true;
1141
1309
  clearInterval(cleaner);
1142
1310
  if (fallbackTimer) clearInterval(fallbackTimer);
1311
+ if (announceTimer) clearInterval(announceTimer);
1143
1312
  mesh?.stop();
1144
1313
  if (disco) {
1145
1314
  await new Promise((resolve) => disco.close(() => resolve()));
@@ -1154,7 +1323,7 @@ async function startBroker(options = {}) {
1154
1323
  if (busy || closed) return;
1155
1324
  busy = true;
1156
1325
  try {
1157
- const found = await probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, timeoutMs: 1e3 });
1326
+ const found = await probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets, timeoutMs: 1e3 });
1158
1327
  const other = found.find((b) => !b.fallback && !(b.port === port && mine.has(b.host)));
1159
1328
  if (!other || closed) return;
1160
1329
  log.info(`fallback: found broker ${other.id} at ${other.url} \u2014 relaying messages and exiting`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unagents/messenger-broker",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "agent-messenger broker: HTTP daemon that tracks peers and routes messages between Claude Code sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "test": "vitest run"
29
29
  },
30
30
  "dependencies": {
31
- "@unagents/messenger-protocol": "0.10.0",
31
+ "@unagents/messenger-protocol": "0.11.1",
32
32
  "consola": "^3.4.2",
33
33
  "defu": "^6.1.7",
34
34
  "h3": "^1.15.11",