agent-comm-hub 0.5.0 → 0.6.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/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // src/index.ts
2
2
  import { createServer } from "node:http";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
3
5
 
4
6
  // src/herdr-ctl.ts
5
7
  import { execFile } from "node:child_process";
@@ -433,6 +435,7 @@ function decodeContent(kind, content) {
433
435
  }
434
436
 
435
437
  // src/hub.ts
438
+ var MAX_PROFILES = 512;
436
439
  var AgentHub = class {
437
440
  constructor(options) {
438
441
  this.options = options;
@@ -447,6 +450,8 @@ var AgentHub = class {
447
450
  waiters = /* @__PURE__ */ new Map();
448
451
  historyRing = [];
449
452
  lastSeen = /* @__PURE__ */ new Map();
453
+ /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
454
+ profiles = /* @__PURE__ */ new Map();
450
455
  gcTimer;
451
456
  /** Stop the idle GC (call when the hub shuts down). */
452
457
  dispose() {
@@ -466,13 +471,25 @@ var AgentHub = class {
466
471
  peers() {
467
472
  return [...this.lastSeen.keys()];
468
473
  }
469
- /** Register a peer; throws if the id is already taken. */
470
- register(peerId) {
474
+ /** Register a peer; throws if the id is already taken. Client metadata
475
+ * (when provided) refreshes the profile — with same-name sessions sharing a
476
+ * peer, the freshest connect wins, which self-heals stale metadata. */
477
+ register(peerId, client) {
471
478
  if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
472
479
  this.lastSeen.set(peerId, Date.now());
480
+ const existing = this.profiles.get(peerId);
481
+ this.profiles.set(peerId, {
482
+ ...existing,
483
+ id: peerId,
484
+ registeredAt: existing?.registeredAt ?? Date.now(),
485
+ ...client?.name !== void 0 ? { clientName: client.name } : existing?.clientName !== void 0 ? { clientName: existing.clientName } : {},
486
+ ...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
487
+ });
488
+ this.evictOverflowProfiles();
473
489
  this.options.onPeersChanged?.(this.peers());
474
490
  }
475
- /** Remove a peer and its queued messages; pending waiters resolve as timeouts. */
491
+ /** Remove a peer and its queued messages; pending waiters resolve as
492
+ * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
476
493
  unregister(peerId) {
477
494
  this.queues.delete(peerId);
478
495
  for (const waiter of this.waiters.get(peerId) ?? []) {
@@ -483,6 +500,110 @@ var AgentHub = class {
483
500
  this.lastSeen.delete(peerId);
484
501
  this.options.onPeersChanged?.(this.peers());
485
502
  }
503
+ /** Identity metadata for `peerId`, if the hub ever saw it. */
504
+ profileOf(peerId) {
505
+ return this.profiles.get(peerId);
506
+ }
507
+ /** Snapshot of every known profile (registered or not) for persistence. */
508
+ exportProfiles() {
509
+ return [...this.profiles.values()].map((profile) => ({ ...profile }));
510
+ }
511
+ /** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
512
+ * and non-finite fields are sanitized. Returns the imported count. Silent
513
+ * for roster observers — this runs before the server accepts requests. */
514
+ importProfiles(profiles) {
515
+ if (!Array.isArray(profiles)) return 0;
516
+ let count = 0;
517
+ for (const raw of profiles) {
518
+ if (typeof raw !== "object" || raw === null) continue;
519
+ const candidate = raw;
520
+ const id = candidate.id;
521
+ if (typeof id !== "string" || !PEER_ID_PATTERN.test(id) || id === BROADCAST) continue;
522
+ if (this.profiles.has(id)) continue;
523
+ const profile = {
524
+ id,
525
+ registeredAt: typeof candidate.registeredAt === "number" && Number.isFinite(candidate.registeredAt) ? candidate.registeredAt : Date.now()
526
+ };
527
+ if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
528
+ if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
529
+ if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
530
+ this.profiles.set(id, profile);
531
+ count++;
532
+ }
533
+ this.evictOverflowProfiles();
534
+ return count;
535
+ }
536
+ /** Set (or clear with `undefined`) the display alias of a registered peer.
537
+ * Routing state is untouched — this only changes how the peer is shown. */
538
+ setAlias(peerId, alias) {
539
+ if (!this.lastSeen.has(peerId)) throw new Error(`unknown peer: ${peerId}`);
540
+ const profile = this.profiles.get(peerId);
541
+ if (profile !== void 0) {
542
+ this.profiles.set(peerId, alias === void 0 ? omit(profile, "alias") : { ...profile, alias });
543
+ }
544
+ this.options.onPeersChanged?.(this.peers());
545
+ }
546
+ /**
547
+ * Atomically re-key a registered peer: mailbox, waiters, last-seen,
548
+ * profile, session bindings (via the caller's registry), and history
549
+ * attribution move from `oldId` to `newId` in one step, so queued
550
+ * messages, pending waits, ack routing, and history continuity all
551
+ * survive the rename. All validation happens before the first mutation.
552
+ */
553
+ renamePeer(oldId, newId) {
554
+ if (oldId === newId) return;
555
+ if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
556
+ if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
557
+ if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
558
+ if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
559
+ const queue = this.queues.get(oldId);
560
+ const waiters = this.waiters.get(oldId);
561
+ const seen = this.lastSeen.get(oldId) ?? Date.now();
562
+ const profile = this.profiles.get(oldId);
563
+ const stale = this.profiles.get(newId);
564
+ this.queues.delete(oldId);
565
+ this.waiters.delete(oldId);
566
+ this.lastSeen.delete(oldId);
567
+ this.profiles.delete(oldId);
568
+ if (queue !== void 0) this.queues.set(newId, queue);
569
+ if (waiters !== void 0) this.waiters.set(newId, waiters);
570
+ this.lastSeen.set(newId, seen);
571
+ if (profile !== void 0) {
572
+ const merged = {
573
+ ...stale,
574
+ ...profile,
575
+ id: newId,
576
+ registeredAt: stale !== void 0 && stale.registeredAt < profile.registeredAt ? stale.registeredAt : profile.registeredAt
577
+ };
578
+ this.profiles.set(newId, merged);
579
+ } else if (stale !== void 0) {
580
+ this.profiles.set(newId, stale);
581
+ } else {
582
+ this.profiles.set(newId, { id: newId, registeredAt: Date.now() });
583
+ }
584
+ for (const list of this.waiters.values()) {
585
+ for (const waiter of list) {
586
+ if (waiter.from === oldId) waiter.from = newId;
587
+ }
588
+ }
589
+ for (const message of this.historyRing) {
590
+ if (message.from === oldId) message.from = newId;
591
+ if (message.to === oldId) message.to = newId;
592
+ }
593
+ this.options.onPeersChanged?.(this.peers());
594
+ }
595
+ /** Keep the profile map bounded: drop the oldest profiles of peers that are
596
+ * NOT currently registered (live peers are never evicted). */
597
+ evictOverflowProfiles() {
598
+ if (this.profiles.size <= MAX_PROFILES) return;
599
+ const evictable = [...this.profiles.values()].filter((profile) => !this.lastSeen.has(profile.id)).sort((a, b) => a.registeredAt - b.registeredAt);
600
+ let overflow = this.profiles.size - MAX_PROFILES;
601
+ for (const profile of evictable) {
602
+ if (overflow <= 0) break;
603
+ this.profiles.delete(profile.id);
604
+ overflow--;
605
+ }
606
+ }
486
607
  /** Mark activity for `peer` (called on every tool call from that peer). */
487
608
  touch(peerId) {
488
609
  if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
@@ -529,13 +650,17 @@ var AgentHub = class {
529
650
  const now = Date.now();
530
651
  return {
531
652
  server: "agent-comm-hub",
532
- peers: this.peers().map((peer) => ({
533
- id: peer,
534
- connected: this.isActive(peer) || livePeers?.has(peer) === true,
535
- lastSeenMs: this.lastSeen.get(peer) ?? 0,
536
- queued: (this.queues.get(peer) ?? []).length,
537
- waiting: (this.waiters.get(peer) ?? []).length
538
- })),
653
+ peers: this.peers().map((peer) => {
654
+ const alias = this.profiles.get(peer)?.alias;
655
+ return {
656
+ id: peer,
657
+ ...alias !== void 0 ? { alias } : {},
658
+ connected: this.isActive(peer) || livePeers?.has(peer) === true,
659
+ lastSeenMs: this.lastSeen.get(peer) ?? 0,
660
+ queued: (this.queues.get(peer) ?? []).length,
661
+ waiting: (this.waiters.get(peer) ?? []).length
662
+ };
663
+ }),
539
664
  historyLimit: this.options.historyLimit,
540
665
  maxQueue: this.options.maxQueue
541
666
  };
@@ -627,7 +752,7 @@ var AgentHub = class {
627
752
  if (queue.length >= this.options.maxQueue) queue.shift();
628
753
  queue.push(message);
629
754
  this.queues.set(target, queue);
630
- this.options.onQueued?.(message);
755
+ this.options.onQueued?.(message, target);
631
756
  }
632
757
  /** Append a delivered message to the history ring. */
633
758
  remember(message) {
@@ -645,6 +770,10 @@ function drainFrom(queue, from) {
645
770
  queue.splice(0, queue.length, ...kept);
646
771
  return drained;
647
772
  }
773
+ function omit(value, key) {
774
+ const { [key]: _dropped, ...rest } = value;
775
+ return rest;
776
+ }
648
777
 
649
778
  // src/hub-tools.ts
650
779
  var DEFAULT_WAIT_MS = 3e4;
@@ -660,13 +789,25 @@ function sanitizePeerId(name) {
660
789
  const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
661
790
  return cleaned === "" ? "agent" : cleaned;
662
791
  }
663
- function autoRegisterPeer(hub, registry, sessionId, clientName) {
792
+ function sanitizeAlias(alias) {
793
+ const cleaned = alias.trim();
794
+ if (cleaned === "") throw new Error("alias must not be empty");
795
+ if (cleaned.length > 64) throw new Error(`alias too long: ${cleaned.length} chars (max 64)`);
796
+ if (/[\u0000-\u001f\u007f]/.test(cleaned)) throw new Error("alias must not contain control characters");
797
+ return cleaned;
798
+ }
799
+ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
664
800
  if (sessionId === void 0) return void 0;
665
801
  const bound = registry.peerFor(sessionId);
666
802
  if (bound !== void 0) return bound;
667
803
  if (registry.isSuppressed(sessionId)) return void 0;
668
804
  const peerId = sanitizePeerId(clientName ?? "agent");
669
- if (!hub.has(peerId)) hub.register(peerId);
805
+ if (!hub.has(peerId)) {
806
+ hub.register(peerId, {
807
+ ...clientName !== void 0 ? { name: clientName } : {},
808
+ ...clientVersion !== void 0 ? { version: clientVersion } : {}
809
+ });
810
+ }
670
811
  registry.bindPeer(sessionId, peerId);
671
812
  hub.touch(peerId);
672
813
  return peerId;
@@ -679,6 +820,23 @@ function livePeersFor(registry) {
679
820
  }
680
821
  return live;
681
822
  }
823
+ function rosterFor(hub, registry) {
824
+ const status = hub.status(livePeersFor(registry));
825
+ return status.peers.map((peer) => {
826
+ const profile = hub.profileOf(peer.id);
827
+ const alias = profile?.alias;
828
+ const clientName = profile?.clientName;
829
+ const clientVersion = profile?.clientVersion;
830
+ return {
831
+ id: peer.id,
832
+ ...alias !== void 0 ? { alias } : {},
833
+ ...clientName !== void 0 ? { clientName } : {},
834
+ ...clientVersion !== void 0 ? { clientVersion } : {},
835
+ connected: peer.connected,
836
+ lastSeenMs: peer.lastSeenMs
837
+ };
838
+ });
839
+ }
682
840
  function hubTools(hub, registry, options) {
683
841
  const schema = (properties, required = []) => ({
684
842
  type: "object",
@@ -722,6 +880,12 @@ function hubTools(hub, registry, options) {
722
880
  }
723
881
  return herdr;
724
882
  };
883
+ const requireManager = (peer, action) => {
884
+ const managers = options.managerPeers ?? "all";
885
+ if (managers !== "all" && !managers.has(peer)) {
886
+ throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
887
+ }
888
+ };
725
889
  const asStatuses = (value) => {
726
890
  if (!Array.isArray(value)) return void 0;
727
891
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
@@ -734,42 +898,108 @@ function hubTools(hub, registry, options) {
734
898
  return [
735
899
  {
736
900
  name: "bridge_register",
737
- description: 'Claim or rename your identity on the hub. Sessions auto-share a peer id derived from the client name; call this to switch to a readable unique peerId such as "mavis" or "opencode:myproject". Rejects when the id is claimed by another connection. Returns the current peer list.',
738
- inputSchema: schema({ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars.") }, ["peerId"]),
901
+ description: 'Claim or rename your identity on the hub. Sessions auto-share a peer id derived from the client name; call this to switch to a readable unique peerId such as "mavis" or "opencode:myproject". Optionally set your display alias (shown to other agents and the desktop roster; message routing keeps using the immutable peerId). Rejects when the id is claimed by another connection. Returns the current peer list.',
902
+ inputSchema: schema({
903
+ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars."),
904
+ alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
905
+ }, ["peerId"]),
739
906
  handler: async (args, sessionId) => {
740
907
  const peerId = String(args.peerId);
741
- if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
908
+ if (!PEER_ID_PATTERN.test(peerId)) {
742
909
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
743
910
  }
911
+ if (peerId === BROADCAST) {
912
+ throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
913
+ }
914
+ const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
744
915
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
745
916
  throw new Error(`peer already registered by another connection: ${peerId}`);
746
917
  }
747
918
  const current = registry.peerFor(sessionId);
748
919
  if (current !== void 0 && current !== peerId) {
749
- registry.unbindPeer(sessionId);
750
- if (registry.attachedCount(current) === 0) hub.unregister(current);
920
+ if (registry.attachedCount(current) === 1) {
921
+ hub.renamePeer(current, peerId);
922
+ registry.rebindPeerId(current, peerId);
923
+ } else {
924
+ registry.unbindPeer(sessionId);
925
+ if (registry.attachedCount(current) === 0) hub.unregister(current);
926
+ }
751
927
  }
752
- registry.bindPeer(sessionId, peerId);
753
928
  if (!hub.has(peerId)) hub.register(peerId);
929
+ registry.bindPeer(sessionId, peerId);
754
930
  registry.clearSuppress(sessionId);
755
931
  hub.touch(peerId);
756
- return { ok: true, peerId, peers: hub.peers() };
932
+ if (alias !== void 0) hub.setAlias(peerId, alias);
933
+ const finalAlias = hub.profileOf(peerId)?.alias;
934
+ return { ok: true, peerId, ...finalAlias !== void 0 ? { alias: finalAlias } : {}, peers: hub.peers() };
757
935
  }
758
936
  },
759
937
  {
760
938
  name: "bridge_unregister",
761
- description: "Leave the hub: detaches your session (and drops the peer when no other session shares it); auto-registration stays off until an explicit bridge_register. Idempotent.",
762
- inputSchema: schema({}),
763
- handler: async (_args, sessionId) => {
764
- const peer = registry.peerFor(sessionId);
765
- if (peer !== void 0) {
766
- registry.unbindPeer(sessionId);
767
- if (registry.attachedCount(peer) === 0) hub.unregister(peer);
939
+ description: "Leave the hub: detaches your session (and drops the peer when no other session shares it); auto-registration stays off until an explicit bridge_register. With `peer`, a hub MANAGER can kick another peer: its queue is dropped and every session attached to it is detached and kept from auto-re-registering. Idempotent.",
940
+ inputSchema: schema({
941
+ peer: optStr("Kick this peer instead of yourself. Requires manager rights (hub managerPeers).")
942
+ }),
943
+ handler: async (args, sessionId) => {
944
+ if (args.peer === void 0) {
945
+ const peer = registry.peerFor(sessionId);
946
+ if (peer !== void 0) {
947
+ registry.unbindPeer(sessionId);
948
+ if (registry.attachedCount(peer) === 0) hub.unregister(peer);
949
+ }
950
+ registry.suppressAuto(sessionId);
951
+ return { ok: true, peerId: peer ?? null };
952
+ }
953
+ const caller = registry.peerFor(sessionId);
954
+ if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
955
+ const target = String(args.peer);
956
+ requireManager(caller, `kicking '${target}'`);
957
+ if (!hub.has(target)) {
958
+ return { ok: true, peerId: null, kicked: false };
768
959
  }
769
- registry.suppressAuto(sessionId);
770
- return { ok: true, peerId: peer ?? null };
960
+ hub.unregister(target);
961
+ const detached = registry.unbindPeerId(target, { suppress: true });
962
+ return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
771
963
  }
772
964
  },
965
+ {
966
+ name: "bridge_rename",
967
+ description: "Rename on the hub, two layers: (1) `alias` sets a display name \u2014 cosmetic only, it shows up in bridge_peers / bridge_status and the desktop roster while routing, mailboxes, and history keep the immutable peer id; renaming yourself is open to everyone. (2) `peerId` truly re-keys another registered peer (manager rights required): mailbox, waiters, session bindings, and history attribution move atomically, so queued messages and acks stay continuous. Renaming ANOTHER peer (either layer) requires manager rights (hub managerPeers).",
968
+ inputSchema: schema({
969
+ alias: optStr("New display name (1-64 chars, spaces/CJK allowed; empty string clears back to no alias)."),
970
+ peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
971
+ peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
972
+ }),
973
+ handler: wrap(true, async (args, peer) => {
974
+ const hasAlias = args.alias !== void 0;
975
+ const hasPeerId = args.peerId !== void 0;
976
+ if (!hasAlias && !hasPeerId) {
977
+ throw new Error("nothing to rename: pass alias and/or peerId");
978
+ }
979
+ let target = args.peer === void 0 ? peer : String(args.peer);
980
+ if (target !== peer) requireManager(peer, `renaming '${target}'`);
981
+ let previousId;
982
+ if (hasPeerId) {
983
+ if (args.peer === void 0) {
984
+ throw new Error("peerId requires peer \u2014 to change your own id, call bridge_register(newId)");
985
+ }
986
+ const newId = String(args.peerId);
987
+ if (!PEER_ID_PATTERN.test(newId)) {
988
+ throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
989
+ }
990
+ previousId = target;
991
+ hub.renamePeer(target, newId);
992
+ registry.rebindPeerId(target, newId);
993
+ target = newId;
994
+ }
995
+ if (hasAlias) {
996
+ const alias = String(args.alias).trim();
997
+ hub.setAlias(target, alias === "" ? void 0 : sanitizeAlias(alias));
998
+ }
999
+ const applied = hub.profileOf(target)?.alias;
1000
+ return { ok: true, peerId: target, ...previousId !== void 0 ? { previousId } : {}, ...applied !== void 0 ? { alias: applied } : {} };
1001
+ })
1002
+ },
773
1003
  {
774
1004
  name: "bridge_chat",
775
1005
  description: 'Send a chat message to another agent on the hub. Use bridge_wait (long-poll) or bridge_poll to receive replies. `to` is the target peerId, or "all" to broadcast.',
@@ -840,20 +1070,21 @@ function hubTools(hub, registry, options) {
840
1070
  },
841
1071
  {
842
1072
  name: "bridge_peers",
843
- description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
1073
+ description: "List registered peers with their display identity and connection state: id (immutable routing name), optional alias (display name), client name/version reported at connect, whether connected (recent activity or a live SSE channel), and lastSeenMs.",
844
1074
  inputSchema: schema({}),
845
- handler: wrap(true, async () => {
846
- const status = hub.status(livePeersFor(registry));
847
- return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
848
- })
1075
+ handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
849
1076
  },
850
1077
  {
851
1078
  name: "bridge_history",
852
- description: 'Recent messages involving you (newest first); pass `peer` to inspect another peer\'s conversation, or `peer: "all"` for the unfiltered tail across every peer. Use to refresh context after a reconnect.',
853
- inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself.'), limit: int("How many messages to return (default 20).") }),
1079
+ description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
1080
+ inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
854
1081
  handler: wrap(true, async (args, peer) => {
855
1082
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
856
- const messages = args.peer === "all" ? hub.historyAll(limit) : hub.history(args.peer === void 0 ? peer : String(args.peer), limit);
1083
+ const target = args.peer === void 0 ? peer : String(args.peer);
1084
+ if (target !== peer) {
1085
+ requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`} requires manager rights`);
1086
+ }
1087
+ const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
857
1088
  return { messages: messages.map(present) };
858
1089
  })
859
1090
  },
@@ -1099,15 +1330,23 @@ var SessionRegistry = class {
1099
1330
  }
1100
1331
  /** Client-reported name per session (from the initialize clientInfo). */
1101
1332
  clientNames = /* @__PURE__ */ new Map();
1102
- /** Remember the client name reported by a session (on initialize). */
1103
- noteClient(sessionId, name) {
1333
+ /** Client-reported version per session (undefined when not reported). */
1334
+ clientVersions = /* @__PURE__ */ new Map();
1335
+ /** Remember the client name/version reported by a session (on initialize). */
1336
+ noteClient(sessionId, name, version) {
1104
1337
  this.clientNames.set(sessionId, name);
1338
+ if (version !== void 0) this.clientVersions.set(sessionId, version);
1105
1339
  }
1106
1340
  /** Client-reported name for a session, if any. */
1107
1341
  clientName(sessionId) {
1108
1342
  if (sessionId === void 0) return void 0;
1109
1343
  return this.clientNames.get(sessionId);
1110
1344
  }
1345
+ /** Client-reported version for a session, if any. */
1346
+ clientVersion(sessionId) {
1347
+ if (sessionId === void 0) return void 0;
1348
+ return this.clientVersions.get(sessionId);
1349
+ }
1111
1350
  /** Sessions whose owner explicitly unregistered; auto-registration is
1112
1351
  * suppressed for them until an explicit `bridge_register`. */
1113
1352
  suppressedAuto = /* @__PURE__ */ new Set();
@@ -1137,11 +1376,39 @@ var SessionRegistry = class {
1137
1376
  liveSessions() {
1138
1377
  return this.liveStreams;
1139
1378
  }
1140
- /** Drop every binding that points at `peerId` (used by the idle GC). */
1141
- unbindPeerId(peerId) {
1379
+ /** Drop every binding that points at `peerId` (used by the idle GC). With
1380
+ * `suppress`, the detached sessions also stop auto-re-registering — the
1381
+ * manager-kick path, so a kicked agent stays out until it explicitly
1382
+ * `bridge_register`s or reconnects with a fresh session. */
1383
+ unbindPeerId(peerId, options) {
1384
+ const detached = [];
1385
+ for (const [sessionId, bound] of this.peerBindings) {
1386
+ if (bound === peerId) {
1387
+ this.peerBindings.delete(sessionId);
1388
+ detached.push(sessionId);
1389
+ if (options?.suppress === true) this.suppressedAuto.add(sessionId);
1390
+ }
1391
+ }
1392
+ return detached;
1393
+ }
1394
+ /** Session ids currently attached to `peerId` (any binding direction). */
1395
+ sessionsForPeer(peerId) {
1396
+ const sessions = [];
1142
1397
  for (const [sessionId, bound] of this.peerBindings) {
1143
- if (bound === peerId) this.peerBindings.delete(sessionId);
1398
+ if (bound === peerId) sessions.push(sessionId);
1144
1399
  }
1400
+ return sessions;
1401
+ }
1402
+ /** Re-point every session bound to `oldId` at `newId` (true rename). */
1403
+ rebindPeerId(oldId, newId) {
1404
+ let count = 0;
1405
+ for (const [sessionId, bound] of this.peerBindings) {
1406
+ if (bound === oldId) {
1407
+ this.peerBindings.set(sessionId, newId);
1408
+ count++;
1409
+ }
1410
+ }
1411
+ return count;
1145
1412
  }
1146
1413
  /** How many sessions are currently attached to `peerId`. */
1147
1414
  attachedCount(peerId) {
@@ -1191,6 +1458,30 @@ var McpStreamableHttpServer = class {
1191
1458
  for (const stream of this.sseStreams.values()) stream.end();
1192
1459
  this.sseStreams.clear();
1193
1460
  }
1461
+ /** Push a JSON-RPC notification to every open SSE stream (roster events are
1462
+ * public — any peer could poll bridge_peers for the same information).
1463
+ * Write failures drop the dead stream silently; SSE is best-effort and
1464
+ * long-poll (bridge_wait) remains the reliable delivery path. */
1465
+ notifyAll(method, params) {
1466
+ this.notify([...this.sseStreams.keys()], method, params);
1467
+ }
1468
+ /** Push a JSON-RPC notification to the SSE streams of specific sessions. */
1469
+ notify(sessionIds, method, params) {
1470
+ const frame = `event: message
1471
+ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1472
+
1473
+ `;
1474
+ for (const sessionId of sessionIds) {
1475
+ const stream = this.sseStreams.get(sessionId);
1476
+ if (stream === void 0) continue;
1477
+ try {
1478
+ stream.write(frame);
1479
+ } catch {
1480
+ this.sseStreams.delete(sessionId);
1481
+ this.registry.markSseClosed(sessionId);
1482
+ }
1483
+ }
1484
+ }
1194
1485
  handleGet(req, res) {
1195
1486
  const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
1196
1487
  res.writeHead(200, {
@@ -1254,10 +1545,11 @@ var McpStreamableHttpServer = class {
1254
1545
  const newSessionId = this.registry.ensureSession(sessionId);
1255
1546
  const clientInfo = message.params?.clientInfo;
1256
1547
  const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
1548
+ const clientVersion = typeof clientInfo?.version === "string" && clientInfo.version !== "" ? clientInfo.version : void 0;
1257
1549
  if (clientName !== void 0) {
1258
- this.registry.noteClient(newSessionId, clientName);
1550
+ this.registry.noteClient(newSessionId, clientName, clientVersion);
1259
1551
  }
1260
- this.onInitialize(newSessionId, clientName);
1552
+ this.onInitialize(newSessionId, clientName, clientVersion);
1261
1553
  const requested = message.params?.protocolVersion;
1262
1554
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
1263
1555
  return {
@@ -1347,7 +1639,7 @@ function readBody(req) {
1347
1639
 
1348
1640
  // src/index.ts
1349
1641
  var SERVER_NAME = "agent-comm-hub";
1350
- var SERVER_VERSION = "0.5.0";
1642
+ var SERVER_VERSION = "0.6.0";
1351
1643
  var DEFAULT_HOST = "127.0.0.1";
1352
1644
  var DEFAULT_PORT = 18764;
1353
1645
  var DEFAULT_PATH = "/mcp";
@@ -1363,11 +1655,57 @@ var DEFAULT_CONFIG = {
1363
1655
  waitTimeoutMs: 6e4,
1364
1656
  defaultWaitMs: 3e4,
1365
1657
  connectedWindowMs: 3e4,
1366
- peerIdleTimeoutMs: 6e5
1658
+ peerIdleTimeoutMs: 6e5,
1659
+ // The companion desktop app (and `agent-comm-hub status` probe) registers
1660
+ // as `agent-hub-cli`; it is the natural roster manager. Agents themselves
1661
+ // are NOT managers — they keep chatting, a GUI manages.
1662
+ managerPeers: ["agent-hub-cli"]
1367
1663
  };
1664
+ function writeRosterFile(file, profiles) {
1665
+ mkdirSync(dirname(file), { recursive: true });
1666
+ const tmp = `${file}.tmp`;
1667
+ writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
1668
+ renameSync(tmp, file);
1669
+ }
1368
1670
  function startHub(config = {}, log = console) {
1369
1671
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1370
1672
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
1673
+ let mcp;
1674
+ const pushRoster = () => {
1675
+ mcp.notifyAll("notifications/message", {
1676
+ level: "info",
1677
+ logger: "bridge",
1678
+ data: { event: "peers_changed", peers: rosterFor(hub, registry) }
1679
+ });
1680
+ };
1681
+ const pushQueued = (message, target) => {
1682
+ mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
1683
+ level: "info",
1684
+ logger: "bridge",
1685
+ data: { event: "message", message: present(message) }
1686
+ });
1687
+ };
1688
+ let saveTimer;
1689
+ let rosterDirty = false;
1690
+ const flushRoster = () => {
1691
+ if (resolved.stateFile === void 0 || !rosterDirty) return;
1692
+ rosterDirty = false;
1693
+ try {
1694
+ writeRosterFile(resolved.stateFile, hub.exportProfiles());
1695
+ } catch (error) {
1696
+ log.warn(`roster save failed: ${error.message}`);
1697
+ }
1698
+ };
1699
+ const scheduleRosterSave = () => {
1700
+ if (resolved.stateFile === void 0) return;
1701
+ rosterDirty = true;
1702
+ if (saveTimer !== void 0) return;
1703
+ saveTimer = setTimeout(() => {
1704
+ saveTimer = void 0;
1705
+ flushRoster();
1706
+ }, 500);
1707
+ saveTimer.unref?.();
1708
+ };
1371
1709
  const hub = new AgentHub({
1372
1710
  maxQueue: resolved.maxQueue,
1373
1711
  historyLimit: resolved.historyLimit,
@@ -1376,9 +1714,37 @@ function startHub(config = {}, log = console) {
1376
1714
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1377
1715
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
1378
1716
  // The idle GC must never evict a peer whose session has a live SSE channel.
1379
- isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
1717
+ isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
1718
+ // SSE event push: roster changes and queued-mail hints. Best-effort — a
1719
+ // notification failure must never break routing, so swallow and log.
1720
+ onPeersChanged: () => {
1721
+ try {
1722
+ pushRoster();
1723
+ } catch (error) {
1724
+ log.warn(`peers_changed notification failed: ${error.message}`);
1725
+ }
1726
+ scheduleRosterSave();
1727
+ },
1728
+ onQueued: (message, target) => {
1729
+ try {
1730
+ pushQueued(message, target);
1731
+ } catch (error) {
1732
+ log.warn(`message notification failed: ${error.message}`);
1733
+ }
1734
+ }
1380
1735
  });
1381
1736
  const registry = new SessionRegistry();
1737
+ if (resolved.stateFile !== void 0) {
1738
+ try {
1739
+ const parsed = JSON.parse(readFileSync(resolved.stateFile, "utf8"));
1740
+ const imported = hub.importProfiles(parsed.profiles);
1741
+ if (imported > 0) log.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
1742
+ } catch (error) {
1743
+ if (error.code !== "ENOENT") {
1744
+ log.warn(`roster load failed: ${error.message}`);
1745
+ }
1746
+ }
1747
+ }
1382
1748
  const herdr = new HerdrCtl({
1383
1749
  bin: resolved.herdrBin,
1384
1750
  baseArgs: resolved.herdrBaseArgs,
@@ -1386,19 +1752,20 @@ function startHub(config = {}, log = console) {
1386
1752
  socketPath: resolved.herdrSocketPath,
1387
1753
  sendRequest: resolved.herdrSendRequest
1388
1754
  });
1389
- const mcp = new McpStreamableHttpServer(
1755
+ mcp = new McpStreamableHttpServer(
1390
1756
  hubTools(hub, registry, {
1391
1757
  defaultWaitMs: resolved.defaultWaitMs,
1392
1758
  waitTimeoutMs: resolved.waitTimeoutMs,
1393
1759
  herdr,
1394
- herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
1760
+ herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
1761
+ managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
1395
1762
  }),
1396
1763
  { name: SERVER_NAME, version: SERVER_VERSION },
1397
1764
  registry,
1398
1765
  (message) => log.warn(message),
1399
- (sessionId, clientName) => {
1766
+ (sessionId, clientName, clientVersion) => {
1400
1767
  try {
1401
- const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
1768
+ const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
1402
1769
  if (peer !== void 0) log.info(`peer joined: ${peer}`);
1403
1770
  } catch (error) {
1404
1771
  log.warn(`auto-register failed: ${error.message}`);
@@ -1419,6 +1786,11 @@ function startHub(config = {}, log = console) {
1419
1786
  close: () => {
1420
1787
  hub.dispose();
1421
1788
  mcp.close();
1789
+ if (saveTimer !== void 0) {
1790
+ clearTimeout(saveTimer);
1791
+ saveTimer = void 0;
1792
+ }
1793
+ flushRoster();
1422
1794
  server.closeAllConnections?.();
1423
1795
  server.close();
1424
1796
  }
@@ -1444,6 +1816,7 @@ export {
1444
1816
  encodeContent,
1445
1817
  hubTools,
1446
1818
  present,
1819
+ sanitizeAlias,
1447
1820
  sanitizePeerId,
1448
1821
  startHub
1449
1822
  };