@unagents/messenger-broker 0.9.0 → 0.10.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/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
1
+ import { Peer, Message, RelayedMessage, StoredProtocol } from '@unagents/messenger-protocol';
2
2
 
3
3
  /**
4
4
  * Broker configuration. Every field can be given to `startBroker(options)`;
@@ -78,6 +78,8 @@ interface BrokerStateFile {
78
78
  nextMsgId: number;
79
79
  /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
80
  pending?: PendingRelay[];
81
+ /** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
82
+ protocols?: StoredProtocol[];
81
83
  }
82
84
  /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
85
  interface PendingRelay extends RelayedMessage {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
1
+ import { Peer, Message, RelayedMessage, StoredProtocol } from '@unagents/messenger-protocol';
2
2
 
3
3
  /**
4
4
  * Broker configuration. Every field can be given to `startBroker(options)`;
@@ -78,6 +78,8 @@ interface BrokerStateFile {
78
78
  nextMsgId: number;
79
79
  /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
80
  pending?: PendingRelay[];
81
+ /** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
82
+ protocols?: StoredProtocol[];
81
83
  }
82
84
  /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
85
  interface PendingRelay extends RelayedMessage {
package/dist/index.mjs CHANGED
@@ -1,11 +1,12 @@
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.CY2SPlVR.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.B8W77sqB.mjs';
5
5
  import 'node:os';
6
6
  import 'node:path';
7
7
  import 'defu';
8
8
  import 'node:fs';
9
9
  import 'consola';
10
+ import 'node:crypto';
10
11
  import 'node:http';
11
12
  import 'node:dgram';
package/dist/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { s as startBroker, l as log } from './shared/messenger-broker.CY2SPlVR.mjs';
1
+ import { s as startBroker, l as log } from './shared/messenger-broker.B8W77sqB.mjs';
2
2
  import 'listhen';
3
3
  import 'h3';
4
4
  import '@unagents/messenger-protocol';
@@ -7,6 +7,7 @@ import 'node:path';
7
7
  import 'defu';
8
8
  import 'node:fs';
9
9
  import 'consola';
10
+ import 'node:crypto';
10
11
  import 'node:http';
11
12
  import 'node:dgram';
12
13
 
@@ -1,11 +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, HEARTBEAT_TIMEOUT_MS, MESH_SYNC_ROUTE, HEALTH_ROUTE, BROKER_POST_ROUTES, RELAY_ROUTE, DISCOVERY_KEY, DISCOVERY_PROBE_VALUE, DISCOVERY_REPLY_VALUE } from '@unagents/messenger-protocol';
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';
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
10
  import { request } from 'node:http';
10
11
  import { createSocket } from 'node:dgram';
11
12
 
@@ -54,6 +55,8 @@ class BrokerState {
54
55
  messages = [];
55
56
  nextMsgId = 1;
56
57
  pending = [];
58
+ /** Protocol definitions by `"name@version"`. Kept while at least one alive peer declares them (see `BrokerHandlers.gcProtocols`). */
59
+ protocols = /* @__PURE__ */ new Map();
57
60
  /** Mesh: when each origin broker last synced with us (ms epoch, memory only). Replicated peers live as long as this is fresh. */
58
61
  originSeen = /* @__PURE__ */ new Map();
59
62
  saveTimer = null;
@@ -68,7 +71,12 @@ class BrokerState {
68
71
  this.messages = raw.messages ?? [];
69
72
  this.nextMsgId = raw.nextMsgId ?? 1;
70
73
  this.pending = raw.pending ?? [];
71
- log.info(`Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s))`);
74
+ for (const sp of raw.protocols ?? []) {
75
+ if (sp && typeof sp.name === "string" && typeof sp.version === "string") this.protocols.set(`${sp.name}@${sp.version}`, sp);
76
+ }
77
+ log.info(
78
+ `Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s)${this.protocols.size ? `, ${this.protocols.size} protocol(s)` : ""})`
79
+ );
72
80
  } catch {
73
81
  }
74
82
  }
@@ -93,7 +101,8 @@ class BrokerState {
93
101
  peers: [...this.peers.values()],
94
102
  messages: this.messages,
95
103
  nextMsgId: this.nextMsgId,
96
- ...this.pending.length ? { pending: this.pending } : {}
104
+ ...this.pending.length ? { pending: this.pending } : {},
105
+ ...this.protocols.size ? { protocols: [...this.protocols.values()] } : {}
97
106
  };
98
107
  writeFileSync(tmp, JSON.stringify(snapshot, null, 1));
99
108
  renameSync(tmp, this.file);
@@ -107,6 +116,112 @@ class BrokerState {
107
116
  }
108
117
  }
109
118
 
119
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
120
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
121
+ const MAX_FIELD_LENGTH = 1024;
122
+ function protocolHash(manifest, doc) {
123
+ return createHash("sha256").update(canonicalJSON({ manifest, doc })).digest("hex");
124
+ }
125
+ function isPlainObject(v) {
126
+ return typeof v === "object" && v !== null && !Array.isArray(v);
127
+ }
128
+ function checkDefinition(name, version, def) {
129
+ const bad = (error) => ({ ok: false, code: "INVALID_DEFINITION", error });
130
+ if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) {
131
+ return bad(`invalid protocol name (expected ${String(PROTOCOL_NAME_PATTERN)})`);
132
+ }
133
+ if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) {
134
+ return bad("invalid version (expected semver, e.g. 1.2.0 or 0.0.0+abc1234)");
135
+ }
136
+ if (!isPlainObject(def)) return bad("missing definition");
137
+ const { manifest, doc, source, ref } = def;
138
+ if (!isPlainObject(manifest)) return bad("definition.manifest must be an object");
139
+ if (typeof doc !== "string") return bad("definition.doc must be a string");
140
+ if (typeof source !== "string" || !source || source.length > MAX_FIELD_LENGTH) return bad("definition.source must be a non-empty string");
141
+ if (typeof ref !== "string" || !ref || ref.length > MAX_FIELD_LENGTH) return bad("definition.ref must be a non-empty string");
142
+ if (manifest.name !== name) return bad(`protocol.json "name" must equal "${name}"`);
143
+ if (manifest.description !== void 0 && typeof manifest.description !== "string") return bad('protocol.json "description" must be a string');
144
+ if (manifest.messages !== void 0) {
145
+ if (!isPlainObject(manifest.messages)) return bad('protocol.json "messages" must be an object');
146
+ for (const [k, schema] of Object.entries(manifest.messages)) {
147
+ if (!isPlainObject(schema)) return bad(`protocol.json messages.${k} must be an object (JSON Schema)`);
148
+ }
149
+ }
150
+ const manifestBytes = Buffer.byteLength(JSON.stringify(manifest));
151
+ const docBytes = Buffer.byteLength(doc);
152
+ if (manifestBytes > PROTOCOL_MANIFEST_MAX_BYTES) {
153
+ return { ok: false, code: "TOO_LARGE", error: `protocol.json is ${manifestBytes} bytes (limit ${PROTOCOL_MANIFEST_MAX_BYTES})` };
154
+ }
155
+ if (docBytes > PROTOCOL_DOC_MAX_BYTES) {
156
+ return { ok: false, code: "TOO_LARGE", error: `PROTOCOL.md is ${docBytes} bytes (limit ${PROTOCOL_DOC_MAX_BYTES})` };
157
+ }
158
+ if (manifestBytes + docBytes > PROTOCOL_TOTAL_MAX_BYTES) {
159
+ return { ok: false, code: "TOO_LARGE", error: `definition is ${manifestBytes + docBytes} bytes (limit ${PROTOCOL_TOTAL_MAX_BYTES})` };
160
+ }
161
+ return { ok: true };
162
+ }
163
+ function sanitizeDecls(list) {
164
+ if (!Array.isArray(list)) return [];
165
+ const out = [];
166
+ for (const d of list) {
167
+ if (!isPlainObject(d)) continue;
168
+ const { name, version, hash } = d;
169
+ if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) continue;
170
+ if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) continue;
171
+ if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) continue;
172
+ if (out.some((o) => o.name === name)) continue;
173
+ out.push({ name, version, hash });
174
+ }
175
+ return out;
176
+ }
177
+ function checkMeta(meta) {
178
+ if (!isPlainObject(meta)) return null;
179
+ const { protocol, version } = meta;
180
+ if (typeof protocol !== "string" || typeof version !== "string") return null;
181
+ if (protocol.length > PROTOCOL_META_MAX_LENGTH || version.length > PROTOCOL_META_MAX_LENGTH) return null;
182
+ return { protocol, version };
183
+ }
184
+ function isTagless(v) {
185
+ return v.startsWith("0.0.0+");
186
+ }
187
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/;
188
+ function compareVersions(a, b) {
189
+ const ta = isTagless(a);
190
+ const tb = isTagless(b);
191
+ if (ta || tb) return ta && tb ? 0 : ta ? -1 : 1;
192
+ const ma = SEMVER.exec(a);
193
+ const mb = SEMVER.exec(b);
194
+ if (!ma || !mb) return a < b ? -1 : a > b ? 1 : 0;
195
+ for (let i = 1; i <= 3; i++) {
196
+ const x = BigInt(ma[i]);
197
+ const y = BigInt(mb[i]);
198
+ if (x !== y) return x < y ? -1 : 1;
199
+ }
200
+ const pa = ma[4];
201
+ const pb = mb[4];
202
+ if (pa === void 0 && pb === void 0) return 0;
203
+ if (pa === void 0) return 1;
204
+ if (pb === void 0) return -1;
205
+ const ia = pa.split(".");
206
+ const ib = pb.split(".");
207
+ for (let i = 0; i < Math.min(ia.length, ib.length); i++) {
208
+ const x = ia[i];
209
+ const y = ib[i];
210
+ const nx = /^\d+$/.test(x);
211
+ const ny = /^\d+$/.test(y);
212
+ if (nx && ny) {
213
+ const dx = BigInt(x);
214
+ const dy = BigInt(y);
215
+ if (dx !== dy) return dx < dy ? -1 : 1;
216
+ } else if (nx !== ny) {
217
+ return nx ? -1 : 1;
218
+ } else if (x !== y) {
219
+ return x < y ? -1 : 1;
220
+ }
221
+ }
222
+ return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1;
223
+ }
224
+
110
225
  const PENDING_RELAY_TTL_MS = 60 * 60 * 1e3;
111
226
  const DEFAULT_MESH_PEER_TTL_MS = 3e5;
112
227
  const MESH_KEY_MEMORY = 2e3;
@@ -116,6 +231,8 @@ class BrokerHandlers {
116
231
  this.opts = opts;
117
232
  }
118
233
  recentMeshKeys = /* @__PURE__ */ new Set();
234
+ /** Mesh protocol conflicts already logged (`name@version:hash`), so a standing conflict warns once, not every round. */
235
+ warnedProtocolConflicts = /* @__PURE__ */ new Set();
119
236
  // --- peer bookkeeping ---
120
237
  /** Cutoff `last_seen` (ISO) below which a peer counts as stale. */
121
238
  static cutoff() {
@@ -158,6 +275,56 @@ class BrokerHandlers {
158
275
  this.state.pending = kept;
159
276
  this.state.scheduleSave();
160
277
  }
278
+ this.gcProtocols();
279
+ }
280
+ // --- protocol store ---
281
+ /** Alive peers (replicas included) that declare `name@version`. */
282
+ serversOf(name, version) {
283
+ const cutoff = BrokerHandlers.cutoff();
284
+ return [...this.state.peers.values()].filter(
285
+ (p) => this.isAlive(p, cutoff) && p.protocols?.some((d) => d.name === name && d.version === version)
286
+ );
287
+ }
288
+ /**
289
+ * Drop stored definitions that no alive peer (replicas included) declares by `(name, version)` any more.
290
+ * Runs after `cleanStalePeers`, `register`, `unregister`, `/set-protocol`, `/unset-protocol`, `meshApplyPeers`
291
+ * and before `/get-protocol` answers.
292
+ */
293
+ gcProtocols() {
294
+ if (this.state.protocols.size === 0) return;
295
+ const cutoff = BrokerHandlers.cutoff();
296
+ const live = /* @__PURE__ */ new Set();
297
+ for (const p of this.state.peers.values()) {
298
+ if (!this.isAlive(p, cutoff)) continue;
299
+ for (const d of p.protocols ?? []) live.add(`${d.name}@${d.version}`);
300
+ }
301
+ let removed = 0;
302
+ for (const key of [...this.state.protocols.keys()]) {
303
+ if (!live.has(key)) {
304
+ this.state.protocols.delete(key);
305
+ removed++;
306
+ }
307
+ }
308
+ if (removed) {
309
+ log.info(`Dropped ${removed} protocol definition(s) without a declaring peer`);
310
+ this.state.scheduleSave();
311
+ }
312
+ }
313
+ /**
314
+ * Record `decls` on `peer` (hash only). A name the peer already declares is left alone (only `/set-protocol`
315
+ * changes a declaration, so a heartbeat that raced a change cannot undo it), and so is a declaration whose hash
316
+ * differs from the definition stored for the same `(name, version)`.
317
+ */
318
+ mergeDecls(peer, decls) {
319
+ let changed = false;
320
+ for (const d of decls) {
321
+ if (peer.protocols?.some((x) => x.name === d.name)) continue;
322
+ const stored = this.state.protocols.get(`${d.name}@${d.version}`);
323
+ if (stored && stored.hash !== d.hash) continue;
324
+ peer.protocols = [...peer.protocols ?? [], d];
325
+ changed = true;
326
+ }
327
+ return changed;
161
328
  }
162
329
  /** A peer that registered here (not a mesh replica). Only those can heartbeat / rename. */
163
330
  localPeer(id) {
@@ -206,7 +373,10 @@ class BrokerHandlers {
206
373
  registered_at: now,
207
374
  last_seen: now
208
375
  });
209
- this.deliverPending(this.state.peers.get(id));
376
+ const peer = this.state.peers.get(id);
377
+ if (body.protocols !== void 0) this.mergeDecls(peer, sanitizeDecls(body.protocols));
378
+ this.deliverPending(peer);
379
+ this.gcProtocols();
210
380
  this.state.scheduleSave();
211
381
  return { id };
212
382
  }
@@ -214,8 +384,10 @@ class BrokerHandlers {
214
384
  const p = this.localPeer(body.id);
215
385
  if (!p) return { ok: false, error: "unknown peer (re-register)" };
216
386
  p.last_seen = (/* @__PURE__ */ new Date()).toISOString();
387
+ if (body.protocols !== void 0) this.mergeDecls(p, sanitizeDecls(body.protocols));
217
388
  this.state.scheduleSave();
218
- return { ok: true };
389
+ const resend = (p.protocols ?? []).filter((d) => !this.state.protocols.has(`${d.name}@${d.version}`)).map((d) => `${d.name}@${d.version}`);
390
+ return resend.length ? { ok: true, resend } : { ok: true };
219
391
  }
220
392
  setSummary(body) {
221
393
  const p = this.localPeer(body.id);
@@ -247,6 +419,10 @@ class BrokerHandlers {
247
419
  break;
248
420
  }
249
421
  if (body.exclude_id) list = list.filter((p) => p.id !== body.exclude_id);
422
+ if (body.protocol) {
423
+ const { protocol, version } = body;
424
+ list = list.filter((p) => p.protocols?.some((d) => d.name === protocol && (!version || d.version === version)));
425
+ }
250
426
  return list.map(({ origin: _origin, ...p }) => p);
251
427
  }
252
428
  sendMessage(body) {
@@ -255,6 +431,12 @@ class BrokerHandlers {
255
431
  const { peer } = resolved;
256
432
  const mode = body.mode ?? "pull";
257
433
  if (mode !== "pull" && mode !== "push" && mode !== "ping") return { ok: false, error: `invalid mode: ${String(mode)}` };
434
+ let meta;
435
+ if (body.meta !== void 0) {
436
+ const checked = checkMeta(body.meta);
437
+ if (!checked) return { ok: false, error: "invalid meta: expected {protocol: string, version: string}, each at most 128 characters" };
438
+ meta = checked;
439
+ }
258
440
  const msg = {
259
441
  id: this.state.nextMsgId++,
260
442
  from_id: body.from_id ?? "cli",
@@ -264,7 +446,8 @@ class BrokerHandlers {
264
446
  mode,
265
447
  sent_at: (/* @__PURE__ */ new Date()).toISOString(),
266
448
  delivered: 0,
267
- acknowledged: 0
449
+ acknowledged: 0,
450
+ ...meta ? { meta } : {}
268
451
  };
269
452
  this.state.messages.push(msg);
270
453
  this.state.scheduleSave();
@@ -293,12 +476,99 @@ class BrokerHandlers {
293
476
  }
294
477
  unregister(body) {
295
478
  this.removePeer(body.id);
479
+ this.gcProtocols();
480
+ this.state.scheduleSave();
481
+ return { ok: true };
482
+ }
483
+ // --- protocol registry ---
484
+ protocolError(code, error, extra = {}) {
485
+ return { ok: false, error, code, ...extra };
486
+ }
487
+ /**
488
+ * `/set-protocol`. Order: unknown peer, definition shape / size (INVALID_DEFINITION, TOO_LARGE), hash
489
+ * re-computation (HASH_MISMATCH), different hash for the same `(name, version)` (PROTOCOL_CONFLICT), held
490
+ * definitions limit (LIMIT). The same hash again is a no-op success, so a client may re-send at will.
491
+ */
492
+ setProtocol(body) {
493
+ const peer = this.localPeer(body.id);
494
+ if (!peer) return { ok: false, error: "unknown peer" };
495
+ const checked = checkDefinition(body.name, body.version, body.definition);
496
+ if (!checked.ok) return this.protocolError(checked.code, checked.error);
497
+ const { name, version, definition } = body;
498
+ const hash = protocolHash(definition.manifest, definition.doc);
499
+ if (body.hash !== hash) {
500
+ return this.protocolError("HASH_MISMATCH", `hash mismatch for ${name}@${version}: sent ${String(body.hash)}, computed ${hash}`);
501
+ }
502
+ this.gcProtocols();
503
+ const key = `${name}@${version}`;
504
+ const existing = this.state.protocols.get(key);
505
+ if (existing && existing.hash !== hash) {
506
+ const servers = this.serversOf(name, version).map((p) => `${p.name || p.id}@${p.hostname}`);
507
+ return this.protocolError(
508
+ "PROTOCOL_CONFLICT",
509
+ `${key} is already registered with a different definition (hash ${existing.hash}, held by ${servers.join(", ") || "no server"}); every server of it must unset_protocol first`,
510
+ { existing_hash: existing.hash, servers }
511
+ );
512
+ }
513
+ if (!existing) {
514
+ if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
515
+ return this.protocolError("LIMIT", `this broker already holds ${PROTOCOL_MAX_STORED} protocol definitions (limit)`);
516
+ }
517
+ this.state.protocols.set(key, {
518
+ name,
519
+ version,
520
+ hash,
521
+ definition: { manifest: definition.manifest, doc: definition.doc, source: definition.source, ref: definition.ref },
522
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
523
+ });
524
+ }
525
+ peer.protocols = [...(peer.protocols ?? []).filter((d) => d.name !== name), { name, version, hash }];
526
+ this.gcProtocols();
296
527
  this.state.scheduleSave();
297
528
  return { ok: true };
298
529
  }
530
+ /** `/unset-protocol`: drop one declaration by `name`, or all of the peer's. Succeeds with `removed: 0` when there was none. */
531
+ unsetProtocol(body) {
532
+ const peer = this.localPeer(body.id);
533
+ if (!peer) return { ok: false, error: "unknown peer" };
534
+ if (body.name !== void 0 && typeof body.name !== "string") return { ok: false, error: "invalid name" };
535
+ const before = peer.protocols ?? [];
536
+ const kept = body.name === void 0 ? [] : before.filter((d) => d.name !== body.name);
537
+ const removed = before.length - kept.length;
538
+ if (kept.length) peer.protocols = kept;
539
+ else delete peer.protocols;
540
+ if (removed) {
541
+ this.gcProtocols();
542
+ this.state.scheduleSave();
543
+ }
544
+ return { ok: true, removed };
545
+ }
546
+ /** `/get-protocol`: the stored definition of `name` at `version`, or at the highest held version. No round trip to any server. */
547
+ getProtocol(body) {
548
+ if (typeof body.name !== "string" || !body.name) return this.protocolError("PROTOCOL_NOT_FOUND", "protocol name is required", { versions: [] });
549
+ this.gcProtocols();
550
+ const held = [...this.state.protocols.values()].filter((sp) => sp.name === body.name);
551
+ held.sort((a, b) => compareVersions(b.version, a.version) || (a.registered_at < b.registered_at ? 1 : -1));
552
+ const versions = held.map((sp) => sp.version);
553
+ const found = body.version === void 0 ? held[0] : held.find((sp) => sp.version === body.version);
554
+ if (!found) {
555
+ const what = body.version === void 0 ? `"${body.name}"` : `"${body.name}" version "${body.version}"`;
556
+ return this.protocolError("PROTOCOL_NOT_FOUND", `Protocol ${what} not found (registered versions: ${versions.join(", ") || "none"})`, { versions });
557
+ }
558
+ return { ok: true, protocol: found, servers: this.serversOf(found.name, found.version).length };
559
+ }
560
+ /** Route table of the protocol registry in `PROTOCOL_ROUTES` order. */
561
+ protocolRoutes() {
562
+ return {
563
+ "/set-protocol": (b) => this.setProtocol(b),
564
+ "/unset-protocol": (b) => this.unsetProtocol(b),
565
+ "/get-protocol": (b) => this.getProtocol(b)
566
+ };
567
+ }
299
568
  // --- relay from a fallback broker ---
300
569
  /** Queue a message for `peerId` exactly like `sendMessage` does. */
301
570
  enqueue(peerId, m) {
571
+ const meta = m.meta === void 0 ? null : checkMeta(m.meta);
302
572
  this.state.messages.push({
303
573
  id: this.state.nextMsgId++,
304
574
  from_id: m.from_id,
@@ -308,7 +578,8 @@ class BrokerHandlers {
308
578
  mode: m.mode ?? "pull",
309
579
  sent_at: m.sent_at,
310
580
  delivered: 0,
311
- acknowledged: 0
581
+ acknowledged: 0,
582
+ ...meta ? { meta } : {}
312
583
  });
313
584
  }
314
585
  /** Give `peer` the relayed messages that were waiting for its name. */
@@ -384,8 +655,64 @@ class BrokerHandlers {
384
655
  for (const p of [...this.state.peers.values()]) {
385
656
  if (p.origin === origin && !incoming.has(p.id)) this.removePeer(p.id);
386
657
  }
658
+ this.gcProtocols();
387
659
  this.state.scheduleSave();
388
660
  }
661
+ /** Definitions declared by this broker's direct, alive peers (what a mesh sync sends). */
662
+ meshLocalProtocols() {
663
+ const out = /* @__PURE__ */ new Map();
664
+ for (const p of this.meshLocalPeers()) {
665
+ for (const d of p.protocols ?? []) {
666
+ const key = `${d.name}@${d.version}`;
667
+ const sp = this.state.protocols.get(key);
668
+ if (sp && sp.hash === d.hash) out.set(key, sp);
669
+ }
670
+ }
671
+ return [...out.values()];
672
+ }
673
+ /**
674
+ * Take over definitions another broker's direct peers declare. Only a definition that a peer we now hold
675
+ * declares with the same hash is stored (the rest would be collected at once). A `(name, version)` we already
676
+ * hold with another hash stays as it is (ours wins) and the received one is dropped with a warning.
677
+ */
678
+ meshApplyProtocols(from, protocols) {
679
+ let added = false;
680
+ for (const sp of protocols) {
681
+ const checked = checkDefinition(sp?.name, sp?.version, sp?.definition);
682
+ if (!checked.ok) {
683
+ log.warn(`mesh: dropped protocol from ${from}: ${checked.error}`);
684
+ continue;
685
+ }
686
+ const hash = protocolHash(sp.definition.manifest, sp.definition.doc);
687
+ if (hash !== sp.hash) {
688
+ log.warn(`mesh: dropped protocol ${sp.name}@${sp.version} from ${from}: hash mismatch`);
689
+ continue;
690
+ }
691
+ const key = `${sp.name}@${sp.version}`;
692
+ const mine = this.state.protocols.get(key);
693
+ if (mine) {
694
+ if (mine.hash !== hash && !this.warnedProtocolConflicts.has(`${key}:${hash}`)) {
695
+ this.warnedProtocolConflicts.add(`${key}:${hash}`);
696
+ log.warn(`mesh: protocol ${key} from ${from} differs from the one held here (${hash} vs ${mine.hash}); keeping ours`);
697
+ }
698
+ continue;
699
+ }
700
+ if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
701
+ log.warn(`mesh: dropped protocol ${key} from ${from}: ${PROTOCOL_MAX_STORED} definitions held (limit)`);
702
+ continue;
703
+ }
704
+ if (!this.serversOf(sp.name, sp.version).some((p) => p.protocols?.some((d) => d.name === sp.name && d.version === sp.version && d.hash === hash))) continue;
705
+ this.state.protocols.set(key, {
706
+ name: sp.name,
707
+ version: sp.version,
708
+ hash,
709
+ definition: { manifest: sp.definition.manifest, doc: sp.definition.doc, source: sp.definition.source, ref: sp.definition.ref },
710
+ registered_at: typeof sp.registered_at === "string" ? sp.registered_at : (/* @__PURE__ */ new Date()).toISOString()
711
+ });
712
+ added = true;
713
+ }
714
+ if (added) this.state.scheduleSave();
715
+ }
389
716
  /** Take over messages forwarded by broker `from`; returns the sender-side ids to drop (duplicates count as taken). */
390
717
  meshAccept(from, messages) {
391
718
  const accepted = [];
@@ -409,8 +736,10 @@ class BrokerHandlers {
409
736
  if (!body.from) throw new Error("mesh sync: missing from");
410
737
  if (body.from === this.opts.id) return { id: body.from, peers: [], accepted: [] };
411
738
  this.meshApplyPeers(body.from, body.peers ?? []);
739
+ this.meshApplyProtocols(body.from, body.protocols ?? []);
412
740
  const accepted = this.meshAccept(body.from, body.messages ?? []);
413
- return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted };
741
+ const protocols = this.meshLocalProtocols();
742
+ return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted, ...protocols.length ? { protocols } : {} };
414
743
  }
415
744
  /** Route table in `BROKER_POST_ROUTES` order. */
416
745
  routes() {
@@ -485,10 +814,12 @@ class Mesh {
485
814
  if (this.links.get(url) === "self" || this.stopped) return;
486
815
  const { handlers, id } = this.opts;
487
816
  const origin = this.origins.get(url);
817
+ const protocols = handlers.meshLocalProtocols();
488
818
  const req = {
489
819
  from: id,
490
820
  peers: handlers.meshLocalPeers(),
491
- messages: origin ? handlers.meshOutbox(origin) : []
821
+ messages: origin ? handlers.meshOutbox(origin) : [],
822
+ ...protocols.length ? { protocols } : {}
492
823
  };
493
824
  let res;
494
825
  try {
@@ -506,6 +837,7 @@ class Mesh {
506
837
  const learned = this.origins.get(url) !== res.id;
507
838
  this.origins.set(url, res.id);
508
839
  handlers.meshApplyPeers(res.id, res.peers ?? []);
840
+ handlers.meshApplyProtocols(res.id, res.protocols ?? []);
509
841
  handlers.meshDrop(res.accepted ?? []);
510
842
  if (learned && handlers.meshOutbox(res.id).length) this.again = true;
511
843
  this.setLink(url, "up");
@@ -552,6 +884,7 @@ function postJson(url, headers, body, timeoutMs) {
552
884
 
553
885
  function createBrokerApp(opts) {
554
886
  const routes = opts.handlers.routes();
887
+ const protocolRoutes = opts.handlers.protocolRoutes();
555
888
  const router = createRouter();
556
889
  router.get(HEALTH_ROUTE, defineEventHandler(() => ({
557
890
  status: "ok",
@@ -572,6 +905,7 @@ function createBrokerApp(opts) {
572
905
  }));
573
906
  for (const route of BROKER_POST_ROUTES) post(route, (body) => routes[route](body));
574
907
  post(RELAY_ROUTE, (body) => opts.handlers.relayMessages(body));
908
+ for (const route of PROTOCOL_ROUTES) post(route, (body) => protocolRoutes[route](body));
575
909
  if (opts.mesh) post(MESH_SYNC_ROUTE, (body) => opts.handlers.meshSync(body));
576
910
  const app = createApp();
577
911
  app.use(router);
@@ -707,7 +1041,8 @@ async function relayUndelivered(state, url, token) {
707
1041
  to_hostname: peer.hostname,
708
1042
  text: m.text,
709
1043
  mode: m.mode ?? "pull",
710
- sent_at: m.sent_at
1044
+ sent_at: m.sent_at,
1045
+ ...m.meta ? { meta: m.meta } : {}
711
1046
  });
712
1047
  }
713
1048
  let sent = 0;
@@ -719,7 +1054,7 @@ async function relayUndelivered(state, url, token) {
719
1054
  dropped += r.dropped;
720
1055
  } else if (res.status === 404) {
721
1056
  for (const it of items) {
722
- const r = await post(url, "/send-message", token, { to: it.to_name, from_id: it.from_id, from_name: it.from_name, text: it.text, mode: it.mode });
1057
+ const r = await post(url, "/send-message", token, { to: it.to_name, from_id: it.from_id, from_name: it.from_name, text: it.text, mode: it.mode, meta: it.meta });
723
1058
  if (r.ok && (await r.json()).ok) sent++;
724
1059
  else dropped++;
725
1060
  }
@@ -733,7 +1068,7 @@ async function relayUndelivered(state, url, token) {
733
1068
  return { sent, dropped };
734
1069
  }
735
1070
 
736
- const version = "0.9.0";
1071
+ const version = "0.10.0";
737
1072
  const pkg = {
738
1073
  version: version};
739
1074
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unagents/messenger-broker",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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.9.0",
31
+ "@unagents/messenger-protocol": "0.10.0",
32
32
  "consola": "^3.4.2",
33
33
  "defu": "^6.1.7",
34
34
  "h3": "^1.15.11",