agent-comm-hub 0.5.0 → 0.7.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,8 @@
1
1
  // src/index.ts
2
2
  import { createServer } from "node:http";
3
+ import { randomBytes as randomBytes2 } from "node:crypto";
4
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
5
+ import { dirname as dirname2 } from "node:path";
3
6
 
4
7
  // src/herdr-ctl.ts
5
8
  import { execFile } from "node:child_process";
@@ -433,6 +436,7 @@ function decodeContent(kind, content) {
433
436
  }
434
437
 
435
438
  // src/hub.ts
439
+ var MAX_PROFILES = 512;
436
440
  var AgentHub = class {
437
441
  constructor(options) {
438
442
  this.options = options;
@@ -447,6 +451,10 @@ var AgentHub = class {
447
451
  waiters = /* @__PURE__ */ new Map();
448
452
  historyRing = [];
449
453
  lastSeen = /* @__PURE__ */ new Map();
454
+ /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
455
+ profiles = /* @__PURE__ */ new Map();
456
+ /** Named channels (group channel), keyed by group id. */
457
+ groups = /* @__PURE__ */ new Map();
450
458
  gcTimer;
451
459
  /** Stop the idle GC (call when the hub shuts down). */
452
460
  dispose() {
@@ -466,14 +474,27 @@ var AgentHub = class {
466
474
  peers() {
467
475
  return [...this.lastSeen.keys()];
468
476
  }
469
- /** Register a peer; throws if the id is already taken. */
470
- register(peerId) {
477
+ /** Register a peer; throws if the id is already taken. Client metadata
478
+ * (when provided) refreshes the profile — with same-name sessions sharing a
479
+ * peer, the freshest connect wins, which self-heals stale metadata. */
480
+ register(peerId, client) {
471
481
  if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
472
482
  this.lastSeen.set(peerId, Date.now());
483
+ const existing = this.profiles.get(peerId);
484
+ this.profiles.set(peerId, {
485
+ ...existing,
486
+ id: peerId,
487
+ registeredAt: existing?.registeredAt ?? Date.now(),
488
+ ...client?.name !== void 0 ? { clientName: client.name } : existing?.clientName !== void 0 ? { clientName: existing.clientName } : {},
489
+ ...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
490
+ });
491
+ this.evictOverflowProfiles();
473
492
  this.options.onPeersChanged?.(this.peers());
474
493
  }
475
- /** Remove a peer and its queued messages; pending waiters resolve as timeouts. */
494
+ /** Remove a peer and its queued messages; pending waiters resolve as
495
+ * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
476
496
  unregister(peerId) {
497
+ if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
477
498
  this.queues.delete(peerId);
478
499
  for (const waiter of this.waiters.get(peerId) ?? []) {
479
500
  clearTimeout(waiter.timer);
@@ -483,6 +504,110 @@ var AgentHub = class {
483
504
  this.lastSeen.delete(peerId);
484
505
  this.options.onPeersChanged?.(this.peers());
485
506
  }
507
+ /** Identity metadata for `peerId`, if the hub ever saw it. */
508
+ profileOf(peerId) {
509
+ return this.profiles.get(peerId);
510
+ }
511
+ /** Snapshot of every known profile (registered or not) for persistence. */
512
+ exportProfiles() {
513
+ return [...this.profiles.values()].map((profile) => ({ ...profile }));
514
+ }
515
+ /** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
516
+ * and non-finite fields are sanitized. Returns the imported count. Silent
517
+ * for roster observers — this runs before the server accepts requests. */
518
+ importProfiles(profiles) {
519
+ if (!Array.isArray(profiles)) return 0;
520
+ let count = 0;
521
+ for (const raw of profiles) {
522
+ if (typeof raw !== "object" || raw === null) continue;
523
+ const candidate = raw;
524
+ const id = candidate.id;
525
+ if (typeof id !== "string" || !PEER_ID_PATTERN.test(id) || id === BROADCAST) continue;
526
+ if (this.profiles.has(id)) continue;
527
+ const profile = {
528
+ id,
529
+ registeredAt: typeof candidate.registeredAt === "number" && Number.isFinite(candidate.registeredAt) ? candidate.registeredAt : Date.now()
530
+ };
531
+ if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
532
+ if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
533
+ if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
534
+ this.profiles.set(id, profile);
535
+ count++;
536
+ }
537
+ this.evictOverflowProfiles();
538
+ return count;
539
+ }
540
+ /** Set (or clear with `undefined`) the display alias of a registered peer.
541
+ * Routing state is untouched — this only changes how the peer is shown. */
542
+ setAlias(peerId, alias) {
543
+ if (!this.lastSeen.has(peerId)) throw new Error(`unknown peer: ${peerId}`);
544
+ const profile = this.profiles.get(peerId);
545
+ if (profile !== void 0) {
546
+ this.profiles.set(peerId, alias === void 0 ? omit(profile, "alias") : { ...profile, alias });
547
+ }
548
+ this.options.onPeersChanged?.(this.peers());
549
+ }
550
+ /**
551
+ * Atomically re-key a registered peer: mailbox, waiters, last-seen,
552
+ * profile, session bindings (via the caller's registry), and history
553
+ * attribution move from `oldId` to `newId` in one step, so queued
554
+ * messages, pending waits, ack routing, and history continuity all
555
+ * survive the rename. All validation happens before the first mutation.
556
+ */
557
+ renamePeer(oldId, newId) {
558
+ if (oldId === newId) return;
559
+ if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
560
+ if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
561
+ if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
562
+ if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
563
+ const queue = this.queues.get(oldId);
564
+ const waiters = this.waiters.get(oldId);
565
+ const seen = this.lastSeen.get(oldId) ?? Date.now();
566
+ const profile = this.profiles.get(oldId);
567
+ const stale = this.profiles.get(newId);
568
+ this.queues.delete(oldId);
569
+ this.waiters.delete(oldId);
570
+ this.lastSeen.delete(oldId);
571
+ this.profiles.delete(oldId);
572
+ if (queue !== void 0) this.queues.set(newId, queue);
573
+ if (waiters !== void 0) this.waiters.set(newId, waiters);
574
+ this.lastSeen.set(newId, seen);
575
+ if (profile !== void 0) {
576
+ const merged = {
577
+ ...stale,
578
+ ...profile,
579
+ id: newId,
580
+ registeredAt: stale !== void 0 && stale.registeredAt < profile.registeredAt ? stale.registeredAt : profile.registeredAt
581
+ };
582
+ this.profiles.set(newId, merged);
583
+ } else if (stale !== void 0) {
584
+ this.profiles.set(newId, stale);
585
+ } else {
586
+ this.profiles.set(newId, { id: newId, registeredAt: Date.now() });
587
+ }
588
+ for (const list of this.waiters.values()) {
589
+ for (const waiter of list) {
590
+ if (waiter.from === oldId) waiter.from = newId;
591
+ }
592
+ }
593
+ for (const message of this.historyRing) {
594
+ if (message.from === oldId) message.from = newId;
595
+ if (message.to === oldId) message.to = newId;
596
+ }
597
+ this.options.onPeersChanged?.(this.peers());
598
+ }
599
+ /** Keep the profile map bounded: drop the oldest profiles of peers that are
600
+ * NOT currently registered (live peers are never evicted). */
601
+ evictOverflowProfiles() {
602
+ if (this.profiles.size <= MAX_PROFILES) return;
603
+ const evictable = [...this.profiles.values()].filter((profile) => !this.lastSeen.has(profile.id)).sort((a, b) => a.registeredAt - b.registeredAt);
604
+ let overflow = this.profiles.size - MAX_PROFILES;
605
+ for (const profile of evictable) {
606
+ if (overflow <= 0) break;
607
+ this.profiles.delete(profile.id);
608
+ overflow--;
609
+ }
610
+ }
486
611
  /** Mark activity for `peer` (called on every tool call from that peer). */
487
612
  touch(peerId) {
488
613
  if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
@@ -510,9 +635,20 @@ var AgentHub = class {
510
635
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
511
636
  return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
512
637
  }
513
- /** Recent messages involving `peer` (inbound and outbound), newest first. */
638
+ /** Recent messages involving `peer` (inbound and outbound, plus group
639
+ * channels the peer belongs to), newest first. */
514
640
  history(peerId, limit) {
515
- const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
641
+ const memberOf = new Set(
642
+ [...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
643
+ );
644
+ const filtered = this.historyRing.filter(
645
+ (message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
646
+ );
647
+ return filtered.slice(-Math.max(0, limit)).reverse();
648
+ }
649
+ /** Recent messages of one group channel, newest first. */
650
+ historyChannel(groupId, limit) {
651
+ const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
516
652
  return filtered.slice(-Math.max(0, limit)).reverse();
517
653
  }
518
654
  /** Most recent messages across every peer, unfiltered (newest first).
@@ -521,21 +657,190 @@ var AgentHub = class {
521
657
  historyAll(limit) {
522
658
  return this.historyRing.slice(-Math.max(0, limit)).reverse();
523
659
  }
660
+ // ---- groups (group channel) ------------------------------------------------
661
+ /** Create a channel; throws when the id is taken or a member is unknown. */
662
+ createGroup(id, members, createdBy, name) {
663
+ if (this.groups.has(id)) throw new Error(`group already exists: ${id}`);
664
+ if (!PEER_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id} (expected [A-Za-z0-9._:-]{1,64})`);
665
+ const unique = [...new Set(members)];
666
+ if (unique.length === 0) throw new Error("a group needs at least one member");
667
+ for (const member of unique) {
668
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
669
+ }
670
+ if (!unique.includes(createdBy)) unique.unshift(createdBy);
671
+ const group = { id, members: unique, createdBy, createdAt: Date.now(), ...name !== void 0 && name !== "" ? { name } : {} };
672
+ this.groups.set(id, group);
673
+ this.options.onGroupsChanged?.();
674
+ return group;
675
+ }
676
+ /** Remove a channel (its history rows stay — attribution is unchanged). */
677
+ deleteGroup(id) {
678
+ if (!this.groups.delete(id)) return false;
679
+ this.options.onGroupsChanged?.();
680
+ return true;
681
+ }
682
+ groupOf(id) {
683
+ return this.groups.get(id);
684
+ }
685
+ /** Add a registered peer to an existing channel, so they can send and read
686
+ * it like any other member. Throws when the group is unknown, the peer is
687
+ * not registered, or it is already inside. */
688
+ addGroupMember(groupId, member) {
689
+ const group = this.groups.get(groupId);
690
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
691
+ if (group.members.includes(member)) throw new Error(`peer '${member}' is already a member of group '${groupId}'`);
692
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
693
+ group.members.push(member);
694
+ this.options.onGroupsChanged?.();
695
+ return group;
696
+ }
697
+ /** Remove a member from a channel. The creator cannot be removed (it would
698
+ * orphan the channel's management). Throws when the group or the membership
699
+ * is missing. */
700
+ removeGroupMember(groupId, member) {
701
+ const group = this.groups.get(groupId);
702
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
703
+ if (member === group.createdBy) throw new Error(`cannot remove the group creator '${member}'`);
704
+ const index = group.members.indexOf(member);
705
+ if (index === -1) throw new Error(`peer '${member}' is not a member of group '${groupId}'`);
706
+ group.members.splice(index, 1);
707
+ this.options.onGroupsChanged?.();
708
+ return group;
709
+ }
710
+ /** All groups (snapshot copy), insertion order. */
711
+ allGroups() {
712
+ return [...this.groups.values()].map((group) => ({ ...group, members: [...group.members] }));
713
+ }
714
+ /** Send a chat message to every member of a group except the sender;
715
+ * members that went offline are skipped (they read the group history).
716
+ * The message carries `to` and `channel` = the group id. */
717
+ sendToGroup(from, groupId, content) {
718
+ const group = this.groups.get(groupId);
719
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
720
+ if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
721
+ const message = {
722
+ id: randomUUID(),
723
+ from,
724
+ to: groupId,
725
+ kind: "chat",
726
+ content,
727
+ channel: groupId,
728
+ ts: Date.now()
729
+ };
730
+ this.lastSeen.set(from, message.ts);
731
+ for (const member of group.members) {
732
+ if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
733
+ }
734
+ return message;
735
+ }
736
+ /** Snapshot of every group for persistence. */
737
+ exportGroups() {
738
+ return this.allGroups();
739
+ }
740
+ /** Load persisted groups at boot (same validation as createGroup, minus
741
+ * the "members must be online" rule — members reconnect later). */
742
+ importGroups(groups) {
743
+ if (!Array.isArray(groups)) return 0;
744
+ let count = 0;
745
+ for (const raw of groups) {
746
+ if (typeof raw !== "object" || raw === null) continue;
747
+ const candidate = raw;
748
+ if (typeof candidate.id !== "string" || !PEER_ID_PATTERN.test(candidate.id) || this.groups.has(candidate.id)) continue;
749
+ if (!Array.isArray(candidate.members) || candidate.members.length === 0) continue;
750
+ const members = [...new Set(candidate.members.filter((m) => typeof m === "string" && PEER_ID_PATTERN.test(m)))];
751
+ if (members.length === 0) continue;
752
+ this.groups.set(candidate.id, {
753
+ id: candidate.id,
754
+ members,
755
+ createdBy: typeof candidate.createdBy === "string" && PEER_ID_PATTERN.test(candidate.createdBy) ? candidate.createdBy : members[0],
756
+ createdAt: typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now(),
757
+ ...typeof candidate.name === "string" && candidate.name !== "" ? { name: candidate.name } : {}
758
+ });
759
+ count++;
760
+ }
761
+ return count;
762
+ }
763
+ // ---- persistence import/export (remote-mode SQLite store) -----------
764
+ /** Restore the history ring at boot. Shape-invalid rows are dropped; only
765
+ * the newest {@link HubOptions.historyLimit} survive. */
766
+ importHistory(messages) {
767
+ if (!Array.isArray(messages)) return 0;
768
+ const valid = [];
769
+ for (const raw of messages) {
770
+ if (typeof raw !== "object" || raw === null) continue;
771
+ const m = raw;
772
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
773
+ if (KINDS.includes(m.kind) === false) continue;
774
+ valid.push({
775
+ id: m.id,
776
+ from: m.from,
777
+ to: m.to,
778
+ kind: m.kind,
779
+ content: m.content,
780
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
781
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
782
+ ts: m.ts
783
+ });
784
+ }
785
+ for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
786
+ return Math.min(valid.length, this.options.historyLimit);
787
+ }
788
+ /** Snapshot of every offline mailbox, keyed by peer (persistence). */
789
+ exportMailboxes() {
790
+ const out = {};
791
+ for (const [peer, queue] of this.queues) {
792
+ if (queue.length > 0) out[peer] = queue.map((message) => ({ ...message }));
793
+ }
794
+ return out;
795
+ }
796
+ /** Restore offline mailboxes at boot (peer reconnects and drains later). */
797
+ importMailboxes(mailboxes) {
798
+ if (typeof mailboxes !== "object" || mailboxes === null) return 0;
799
+ let count = 0;
800
+ for (const [peer, queue] of Object.entries(mailboxes)) {
801
+ if (!Array.isArray(queue) || queue.length === 0) continue;
802
+ const restored = [];
803
+ for (const raw of queue) {
804
+ if (typeof raw !== "object" || raw === null) continue;
805
+ const m = raw;
806
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
807
+ restored.push({
808
+ id: m.id,
809
+ from: m.from,
810
+ to: typeof m.to === "string" ? m.to : peer,
811
+ kind: KINDS.includes(m.kind) ? m.kind : "chat",
812
+ content: m.content,
813
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
814
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
815
+ ts: m.ts
816
+ });
817
+ }
818
+ if (restored.length > 0) {
819
+ this.queues.set(peer, restored.slice(-this.options.maxQueue));
820
+ count += restored.length;
821
+ }
822
+ }
823
+ return count;
824
+ }
524
825
  /**
525
- * Live summary for the status tool. `livePeers` (sessions with a live SSE
526
- * stream) count as connected even without recent tool activity.
527
- */
826
+ * Live summary for the status tool. `livePeers` (sessions with a live SSE
827
+ * stream) count as connected even without recent tool activity.
828
+ */
528
829
  status(livePeers) {
529
830
  const now = Date.now();
530
831
  return {
531
832
  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
- })),
833
+ peers: this.peers().map((peer) => {
834
+ const alias = this.profiles.get(peer)?.alias;
835
+ return {
836
+ id: peer,
837
+ ...alias !== void 0 ? { alias } : {},
838
+ connected: this.isActive(peer) || livePeers?.has(peer) === true,
839
+ lastSeenMs: this.lastSeen.get(peer) ?? 0,
840
+ queued: (this.queues.get(peer) ?? []).length,
841
+ waiting: (this.waiters.get(peer) ?? []).length
842
+ };
843
+ }),
539
844
  historyLimit: this.options.historyLimit,
540
845
  maxQueue: this.options.maxQueue
541
846
  };
@@ -544,7 +849,7 @@ var AgentHub = class {
544
849
  poll(peerId, from) {
545
850
  const queue = this.queues.get(peerId) ?? [];
546
851
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
547
- for (const message of drained) this.remember(message);
852
+ if (drained.length > 0) this.options.onMailboxesChanged?.();
548
853
  return drained;
549
854
  }
550
855
  /**
@@ -627,12 +932,15 @@ var AgentHub = class {
627
932
  if (queue.length >= this.options.maxQueue) queue.shift();
628
933
  queue.push(message);
629
934
  this.queues.set(target, queue);
630
- this.options.onQueued?.(message);
935
+ this.remember(message);
936
+ this.options.onQueued?.(message, target);
631
937
  }
632
- /** Append a delivered message to the history ring. */
938
+ /** Append a delivered message to the history ring (and fire the
939
+ * persistence hook — one call per message, delivered or queued). */
633
940
  remember(message) {
634
941
  this.historyRing.push(message);
635
942
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
943
+ this.options.onMessage?.(message);
636
944
  }
637
945
  };
638
946
  function drainFrom(queue, from) {
@@ -645,14 +953,164 @@ function drainFrom(queue, from) {
645
953
  queue.splice(0, queue.length, ...kept);
646
954
  return drained;
647
955
  }
956
+ function omit(value, key) {
957
+ const { [key]: _dropped, ...rest } = value;
958
+ return rest;
959
+ }
960
+
961
+ // src/auth.ts
962
+ import { randomBytes } from "node:crypto";
963
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
964
+ import { dirname } from "node:path";
965
+ var ANON_JOIN_PEER = "__join__";
966
+ var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
967
+ function loadTokenFile(file) {
968
+ let parsed;
969
+ try {
970
+ parsed = JSON.parse(readFileSync(file, "utf8"));
971
+ } catch (error) {
972
+ throw new Error(`auth token file unreadable: ${file} (${error.message})`);
973
+ }
974
+ const rows = parsed?.tokens;
975
+ if (!Array.isArray(rows)) throw new Error(`auth token file ${file}: expected {"tokens":[...]}`);
976
+ const table = /* @__PURE__ */ new Map();
977
+ const peers = /* @__PURE__ */ new Set();
978
+ rows.forEach((row, index) => {
979
+ const msg = (why) => `auth token file ${file}: entry #${index + 1} ${why}`;
980
+ if (typeof row !== "object" || row === null) throw new Error(msg("is not an object"));
981
+ const { token, peer, role, owner } = row;
982
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
983
+ throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
984
+ }
985
+ if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
986
+ throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
987
+ }
988
+ if (role !== "agent" && role !== "manager") {
989
+ throw new Error(msg(`has an invalid role '${String(role)}' (expected 'agent' | 'manager')`));
990
+ }
991
+ if (owner !== void 0 && typeof owner !== "string") throw new Error(msg("has a non-string owner"));
992
+ if (table.has(token)) throw new Error(msg("duplicates a token"));
993
+ if (peers.has(peer)) {
994
+ throw new Error(msg(`maps a second token to peer '${peer}' (two tokens sharing a peer share its mailbox)`));
995
+ }
996
+ table.set(token, { peer, role, ...typeof owner === "string" && owner !== "" ? { owner } : {} });
997
+ peers.add(peer);
998
+ });
999
+ if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
1000
+ return table;
1001
+ }
1002
+ function tokenTable(map) {
1003
+ return {
1004
+ authenticate: (token) => token === void 0 ? void 0 : map.get(token)
1005
+ };
1006
+ }
1007
+ function watchTokenFile(file, log) {
1008
+ let table = loadTokenFile(file);
1009
+ const reload = () => {
1010
+ try {
1011
+ const next = loadTokenFile(file);
1012
+ table = next;
1013
+ log.info(`auth tokens reloaded: ${table.size} entr(ies)`);
1014
+ } catch (error) {
1015
+ log.warn(`auth token reload ignored, keeping previous table: ${error.message}`);
1016
+ }
1017
+ };
1018
+ log.info(`auth tokens loaded: ${table.size} entr(ies) from ${file}`);
1019
+ let mtime = statMtime(file);
1020
+ const timer = setInterval(() => {
1021
+ const current = statMtime(file);
1022
+ if (current === mtime) return;
1023
+ mtime = current;
1024
+ reload();
1025
+ }, 2e3);
1026
+ timer.unref?.();
1027
+ return { authenticate: (token) => token === void 0 ? void 0 : table.get(token), reload };
1028
+ }
1029
+ function statMtime(file) {
1030
+ try {
1031
+ return statSync(file).mtimeMs;
1032
+ } catch {
1033
+ return 0;
1034
+ }
1035
+ }
1036
+ function bearerFromHeaders(headers) {
1037
+ const raw = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization;
1038
+ if (raw === void 0) return void 0;
1039
+ const match = /^Bearer\s+(.+)$/.exec(raw.trim());
1040
+ return match === null ? void 0 : match[1];
1041
+ }
1042
+ function generateToken() {
1043
+ return randomBytes(24).toString("base64url");
1044
+ }
1045
+ function readTokens(file) {
1046
+ if (!existsSync(file)) return [];
1047
+ try {
1048
+ return [...loadTokenFile(file).entries()].map(([token, id]) => ({
1049
+ token,
1050
+ peer: id.peer,
1051
+ role: id.role,
1052
+ ...id.owner !== void 0 ? { owner: id.owner } : {}
1053
+ }));
1054
+ } catch (error) {
1055
+ try {
1056
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1057
+ if (Array.isArray(parsed.tokens) && parsed.tokens.length === 0) return [];
1058
+ } catch {
1059
+ }
1060
+ throw error;
1061
+ }
1062
+ }
1063
+ function writeTokenFile(file, rows) {
1064
+ mkdirSync(dirname(file), { recursive: true });
1065
+ const tmp = `${file}.tmp`;
1066
+ writeFileSync(tmp, JSON.stringify({ tokens: rows }, null, 2) + "\n", "utf8");
1067
+ renameSync(tmp, file);
1068
+ }
1069
+ function addToken(file, row) {
1070
+ const token = row.token ?? generateToken();
1071
+ if (!TOKEN_PATTERN.test(token)) {
1072
+ throw new Error(`invalid token provided (expect 16-128 chars of [A-Za-z0-9._:-])`);
1073
+ }
1074
+ const peers = new Set(readTokens(file).map((t) => t.peer));
1075
+ if (peers.has(row.peer)) {
1076
+ throw new Error(`peer '${row.peer}' already has a token \u2014 remove it first to rotate (see \`auth remove\`)`);
1077
+ }
1078
+ const owner = row.owner !== void 0 && row.owner !== "" ? row.owner : void 0;
1079
+ const identity = { token, peer: row.peer, role: row.role, ...owner !== void 0 ? { owner } : {} };
1080
+ const next = [...readTokens(file), identity];
1081
+ loadTokenFileVoid(next);
1082
+ writeTokenFile(file, next);
1083
+ return identity;
1084
+ }
1085
+ function removeToken(file, peer) {
1086
+ const tokens = readTokens(file);
1087
+ const kept = tokens.filter((t) => t.peer !== peer);
1088
+ if (kept.length === tokens.length) return false;
1089
+ writeTokenFile(file, kept);
1090
+ return true;
1091
+ }
1092
+ function loadTokenFileVoid(rows) {
1093
+ const table = /* @__PURE__ */ new Map();
1094
+ const peers = /* @__PURE__ */ new Set();
1095
+ for (const { token, peer, role, owner } of rows) {
1096
+ if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
1097
+ if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
1098
+ if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
1099
+ if (table.has(token)) throw new Error("duplicate token");
1100
+ if (peers.has(peer)) throw new Error(`duplicate peer '${peer}'`);
1101
+ table.set(token, { peer, role, ...owner !== void 0 && owner !== "" ? { owner } : {} });
1102
+ peers.add(peer);
1103
+ }
1104
+ }
648
1105
 
649
1106
  // src/hub-tools.ts
650
1107
  var DEFAULT_WAIT_MS = 3e4;
651
1108
  function present(message) {
652
- const { ref, ...rest } = message;
1109
+ const { ref, channel, ...rest } = message;
653
1110
  return {
654
1111
  ...rest,
655
1112
  ...ref !== void 0 ? { ref } : {},
1113
+ ...channel !== void 0 ? { channel } : {},
656
1114
  content: decodeContent(message.kind, message.content)
657
1115
  };
658
1116
  }
@@ -660,13 +1118,26 @@ function sanitizePeerId(name) {
660
1118
  const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
661
1119
  return cleaned === "" ? "agent" : cleaned;
662
1120
  }
663
- function autoRegisterPeer(hub, registry, sessionId, clientName) {
1121
+ function sanitizeAlias(alias) {
1122
+ const cleaned = alias.trim();
1123
+ if (cleaned === "") throw new Error("alias must not be empty");
1124
+ if (cleaned.length > 64) throw new Error(`alias too long: ${cleaned.length} chars (max 64)`);
1125
+ if (/[\u0000-\u001f\u007f]/.test(cleaned)) throw new Error("alias must not contain control characters");
1126
+ return cleaned;
1127
+ }
1128
+ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
664
1129
  if (sessionId === void 0) return void 0;
665
1130
  const bound = registry.peerFor(sessionId);
666
1131
  if (bound !== void 0) return bound;
667
1132
  if (registry.isSuppressed(sessionId)) return void 0;
668
- const peerId = sanitizePeerId(clientName ?? "agent");
669
- if (!hub.has(peerId)) hub.register(peerId);
1133
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1134
+ const peerId = tokenPeer === ANON_JOIN_PEER ? `join-${sanitizePeerId(clientName ?? "agent")}-${Math.random().toString(36).slice(2, 6)}` : tokenPeer ?? sanitizePeerId(clientName ?? "agent");
1135
+ if (!hub.has(peerId)) {
1136
+ hub.register(peerId, {
1137
+ ...clientName !== void 0 ? { name: clientName } : {},
1138
+ ...clientVersion !== void 0 ? { version: clientVersion } : {}
1139
+ });
1140
+ }
670
1141
  registry.bindPeer(sessionId, peerId);
671
1142
  hub.touch(peerId);
672
1143
  return peerId;
@@ -679,6 +1150,38 @@ function livePeersFor(registry) {
679
1150
  }
680
1151
  return live;
681
1152
  }
1153
+ function rosterFor(hub, registry) {
1154
+ const status = hub.status(livePeersFor(registry));
1155
+ const peerIps = /* @__PURE__ */ new Map();
1156
+ const peerAnon = /* @__PURE__ */ new Map();
1157
+ for (const [session, peer] of registry.peerBindings) {
1158
+ const ip = registry.sessionIps.get(session);
1159
+ if (ip !== void 0) {
1160
+ const list = peerIps.get(peer) ?? [];
1161
+ if (!list.includes(ip)) list.push(ip);
1162
+ peerIps.set(peer, list);
1163
+ }
1164
+ const identity = registry.tokenFor(session);
1165
+ if (identity?.anonymous === true) peerAnon.set(peer, true);
1166
+ }
1167
+ return status.peers.map((peer) => {
1168
+ const profile = hub.profileOf(peer.id);
1169
+ const alias = profile?.alias;
1170
+ const clientName = profile?.clientName;
1171
+ const clientVersion = profile?.clientVersion;
1172
+ const ips = peerIps.get(peer.id);
1173
+ return {
1174
+ id: peer.id,
1175
+ ...alias !== void 0 ? { alias } : {},
1176
+ ...clientName !== void 0 ? { clientName } : {},
1177
+ ...clientVersion !== void 0 ? { clientVersion } : {},
1178
+ ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1179
+ ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
1180
+ connected: peer.connected,
1181
+ lastSeenMs: peer.lastSeenMs
1182
+ };
1183
+ });
1184
+ }
682
1185
  function hubTools(hub, registry, options) {
683
1186
  const schema = (properties, required = []) => ({
684
1187
  type: "object",
@@ -708,6 +1211,7 @@ function hubTools(hub, registry, options) {
708
1211
  from: message.from,
709
1212
  to: message.to,
710
1213
  kind: message.kind,
1214
+ ...message.channel !== void 0 ? { channel: message.channel } : {},
711
1215
  ts: message.ts
712
1216
  });
713
1217
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -722,6 +1226,13 @@ function hubTools(hub, registry, options) {
722
1226
  }
723
1227
  return herdr;
724
1228
  };
1229
+ const requireManager = (peer, action, sessionId) => {
1230
+ if (registry.tokenFor(sessionId)?.role === "manager") return;
1231
+ const managers = options.managerPeers ?? "all";
1232
+ if (managers !== "all" && !managers.has(peer)) {
1233
+ throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
1234
+ }
1235
+ };
725
1236
  const asStatuses = (value) => {
726
1237
  if (!Array.isArray(value)) return void 0;
727
1238
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
@@ -734,42 +1245,112 @@ function hubTools(hub, registry, options) {
734
1245
  return [
735
1246
  {
736
1247
  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"]),
1248
+ 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.',
1249
+ inputSchema: schema({
1250
+ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars."),
1251
+ alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
1252
+ }, ["peerId"]),
739
1253
  handler: async (args, sessionId) => {
740
1254
  const peerId = String(args.peerId);
741
- if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
1255
+ if (!PEER_ID_PATTERN.test(peerId)) {
742
1256
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
743
1257
  }
1258
+ if (peerId === BROADCAST) {
1259
+ throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
1260
+ }
1261
+ const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
1262
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1263
+ if (tokenPeer !== void 0 && peerId !== tokenPeer) {
1264
+ throw new Error(`your identity is bound to your access token ('${tokenPeer}') \u2014 ask a hub manager to rename you`);
1265
+ }
744
1266
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
745
1267
  throw new Error(`peer already registered by another connection: ${peerId}`);
746
1268
  }
747
1269
  const current = registry.peerFor(sessionId);
748
1270
  if (current !== void 0 && current !== peerId) {
749
- registry.unbindPeer(sessionId);
750
- if (registry.attachedCount(current) === 0) hub.unregister(current);
1271
+ if (registry.attachedCount(current) === 1) {
1272
+ hub.renamePeer(current, peerId);
1273
+ registry.rebindPeerId(current, peerId);
1274
+ } else {
1275
+ registry.unbindPeer(sessionId);
1276
+ if (registry.attachedCount(current) === 0) hub.unregister(current);
1277
+ }
751
1278
  }
752
- registry.bindPeer(sessionId, peerId);
753
1279
  if (!hub.has(peerId)) hub.register(peerId);
1280
+ registry.bindPeer(sessionId, peerId);
754
1281
  registry.clearSuppress(sessionId);
755
1282
  hub.touch(peerId);
756
- return { ok: true, peerId, peers: hub.peers() };
1283
+ if (alias !== void 0) hub.setAlias(peerId, alias);
1284
+ const finalAlias = hub.profileOf(peerId)?.alias;
1285
+ return { ok: true, peerId, ...finalAlias !== void 0 ? { alias: finalAlias } : {}, peers: hub.peers() };
757
1286
  }
758
1287
  },
759
1288
  {
760
1289
  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);
1290
+ 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.",
1291
+ inputSchema: schema({
1292
+ peer: optStr("Kick this peer instead of yourself. Requires manager rights (hub managerPeers).")
1293
+ }),
1294
+ handler: async (args, sessionId) => {
1295
+ if (args.peer === void 0) {
1296
+ const peer = registry.peerFor(sessionId);
1297
+ if (peer !== void 0) {
1298
+ registry.unbindPeer(sessionId);
1299
+ if (registry.attachedCount(peer) === 0) hub.unregister(peer);
1300
+ }
1301
+ registry.suppressAuto(sessionId);
1302
+ return { ok: true, peerId: peer ?? null };
1303
+ }
1304
+ const caller = registry.peerFor(sessionId);
1305
+ if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
1306
+ const target = String(args.peer);
1307
+ requireManager(caller, `kicking '${target}'`, sessionId);
1308
+ if (!hub.has(target)) {
1309
+ return { ok: true, peerId: null, kicked: false };
768
1310
  }
769
- registry.suppressAuto(sessionId);
770
- return { ok: true, peerId: peer ?? null };
1311
+ hub.unregister(target);
1312
+ const detached = registry.unbindPeerId(target, { suppress: true });
1313
+ return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
771
1314
  }
772
1315
  },
1316
+ {
1317
+ name: "bridge_rename",
1318
+ 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).",
1319
+ inputSchema: schema({
1320
+ alias: optStr("New display name (1-64 chars, spaces/CJK allowed; empty string clears back to no alias)."),
1321
+ peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
1322
+ peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
1323
+ }),
1324
+ handler: wrap(true, async (args, peer, sessionId) => {
1325
+ const hasAlias = args.alias !== void 0;
1326
+ const hasPeerId = args.peerId !== void 0;
1327
+ if (!hasAlias && !hasPeerId) {
1328
+ throw new Error("nothing to rename: pass alias and/or peerId");
1329
+ }
1330
+ let target = args.peer === void 0 ? peer : String(args.peer);
1331
+ if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
1332
+ let previousId;
1333
+ if (hasPeerId) {
1334
+ if (args.peer === void 0) {
1335
+ throw new Error("peerId requires peer \u2014 to change your own id, call bridge_register(newId)");
1336
+ }
1337
+ const newId = String(args.peerId);
1338
+ if (!PEER_ID_PATTERN.test(newId)) {
1339
+ throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
1340
+ }
1341
+ previousId = target;
1342
+ hub.renamePeer(target, newId);
1343
+ registry.rebindPeerId(target, newId);
1344
+ target = newId;
1345
+ }
1346
+ if (hasAlias) {
1347
+ const alias = String(args.alias).trim();
1348
+ hub.setAlias(target, alias === "" ? void 0 : sanitizeAlias(alias));
1349
+ }
1350
+ const applied = hub.profileOf(target)?.alias;
1351
+ return { ok: true, peerId: target, ...previousId !== void 0 ? { previousId } : {}, ...applied !== void 0 ? { alias: applied } : {} };
1352
+ })
1353
+ },
773
1354
  {
774
1355
  name: "bridge_chat",
775
1356
  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,23 +1421,106 @@ function hubTools(hub, registry, options) {
840
1421
  },
841
1422
  {
842
1423
  name: "bridge_peers",
843
- description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
1424
+ 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
1425
  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
- })
1426
+ handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
849
1427
  },
850
1428
  {
851
1429
  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).") }),
854
- handler: wrap(true, async (args, peer) => {
1430
+ 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).',
1431
+ 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).") }),
1432
+ handler: wrap(true, async (args, peer, sessionId) => {
855
1433
  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);
1434
+ const target = args.peer === void 0 ? peer : String(args.peer);
1435
+ if (target !== peer) {
1436
+ requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
1437
+ }
1438
+ if (args.channel !== void 0) {
1439
+ const groupId = String(args.channel);
1440
+ const group = hub.groupOf(groupId);
1441
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1442
+ if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
1443
+ return { messages: hub.historyChannel(groupId, limit).map(present) };
1444
+ }
1445
+ const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
857
1446
  return { messages: messages.map(present) };
858
1447
  })
859
1448
  },
1449
+ // ---- group tools (group channel) ---------------------------------------------
1450
+ // A group routes one message to several members; the message carries
1451
+ // channel = group id. Offline members are skipped (they read history) —
1452
+ // unlike direct sends, which fail on unknown recipients.
1453
+ {
1454
+ name: "bridge_group_create",
1455
+ description: "Create a group channel. You become a member automatically; `members` lists additional peerIds (each must be registered). Members exchange messages via bridge_group_send; anyone in the group reads its history with bridge_history { channel }. Use bridge_group_delete to remove (creator or manager).",
1456
+ inputSchema: schema(
1457
+ {
1458
+ group: str("Unique group id: letters/digits/._:- , 1-64 chars."),
1459
+ name: optStr("Optional display name."),
1460
+ members: { type: "array", items: { type: "string" }, description: "Member peerIds (you are added automatically; duplicates ignored)." }
1461
+ },
1462
+ ["group"]
1463
+ ),
1464
+ handler: wrap(true, async (args, peer) => {
1465
+ const groupId = String(args.group);
1466
+ const members = Array.isArray(args.members) ? args.members.map(String) : [];
1467
+ const group = hub.createGroup(groupId, members, peer, args.name === void 0 ? void 0 : String(args.name));
1468
+ return { ok: true, group: { id: group.id, ...group.name !== void 0 ? { name: group.name } : {}, members: group.members, createdBy: group.createdBy } };
1469
+ })
1470
+ },
1471
+ {
1472
+ name: "bridge_group_send",
1473
+ description: "Send a chat message to every member of a group channel except yourself. Offline members are skipped (they catch up via bridge_history { channel }). You must be a member.",
1474
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), message: str("The message text.") }, ["group", "message"]),
1475
+ handler: wrap(true, async (args, peer) => receipt(hub.sendToGroup(peer, String(args.group), String(args.message))))
1476
+ },
1477
+ {
1478
+ name: "bridge_group_list",
1479
+ description: "List every group channel with its members and creator.",
1480
+ inputSchema: schema({}),
1481
+ handler: wrap(true, async () => ({ groups: hub.allGroups() }))
1482
+ },
1483
+ {
1484
+ name: "bridge_group_delete",
1485
+ description: "Delete a group channel (history rows are kept). The creator or a hub manager may delete.",
1486
+ inputSchema: schema({ group: str("Group id to delete.") }, ["group"]),
1487
+ handler: wrap(true, async (args, peer, sessionId) => {
1488
+ const groupId = String(args.group);
1489
+ const group = hub.groupOf(groupId);
1490
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1491
+ if (group.createdBy !== peer) requireManager(peer, `deleting group '${groupId}'`, sessionId);
1492
+ hub.deleteGroup(groupId);
1493
+ return { ok: true, group: groupId };
1494
+ })
1495
+ },
1496
+ {
1497
+ name: "bridge_group_add_member",
1498
+ description: "Add a registered peer to a group channel; the new member can then send to it and read its history. The creator or a hub manager may add.",
1499
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId of the new member.") }, ["group", "member"]),
1500
+ handler: wrap(true, async (args, peer, sessionId) => {
1501
+ const groupId = String(args.group);
1502
+ const member = String(args.member);
1503
+ const group = hub.groupOf(groupId);
1504
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1505
+ if (group.createdBy !== peer) requireManager(peer, `adding a member to group '${groupId}'`, sessionId);
1506
+ const updated = hub.addGroupMember(groupId, member);
1507
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1508
+ })
1509
+ },
1510
+ {
1511
+ name: "bridge_group_remove_member",
1512
+ description: "Remove a member from a group channel; they stop receiving its messages (history rows are kept). The creator or a hub manager may remove; the creator itself cannot be removed.",
1513
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId to remove.") }, ["group", "member"]),
1514
+ handler: wrap(true, async (args, peer, sessionId) => {
1515
+ const groupId = String(args.group);
1516
+ const member = String(args.member);
1517
+ const group = hub.groupOf(groupId);
1518
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1519
+ if (group.createdBy !== peer) requireManager(peer, `removing a member from group '${groupId}'`, sessionId);
1520
+ const updated = hub.removeGroupMember(groupId, member);
1521
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1522
+ })
1523
+ },
860
1524
  // ---- herdr control tools ------------------------------------------
861
1525
  // These type into real agent terminals via the herdr runtime. They are
862
1526
  // gated by checkControl and documented as physical input: unlike
@@ -1059,6 +1723,16 @@ function hubTools(hub, registry, options) {
1059
1723
 
1060
1724
  // src/mcp-server.ts
1061
1725
  import { randomUUID as randomUUID2 } from "node:crypto";
1726
+ import { readFileSync as readFileSync2 } from "node:fs";
1727
+ var ADMIN_PATH = "/admin";
1728
+ var ADMIN_API_PATH = "/admin/api";
1729
+ var adminPageCache;
1730
+ function adminPage() {
1731
+ if (adminPageCache === void 0) {
1732
+ adminPageCache = readFileSync2(new URL("../assets/admin.html", import.meta.url), "utf8");
1733
+ }
1734
+ return adminPageCache;
1735
+ }
1062
1736
  var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1063
1737
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
1064
1738
  var MAX_BODY_BYTES = 1048576;
@@ -1099,15 +1773,23 @@ var SessionRegistry = class {
1099
1773
  }
1100
1774
  /** Client-reported name per session (from the initialize clientInfo). */
1101
1775
  clientNames = /* @__PURE__ */ new Map();
1102
- /** Remember the client name reported by a session (on initialize). */
1103
- noteClient(sessionId, name) {
1776
+ /** Client-reported version per session (undefined when not reported). */
1777
+ clientVersions = /* @__PURE__ */ new Map();
1778
+ /** Remember the client name/version reported by a session (on initialize). */
1779
+ noteClient(sessionId, name, version) {
1104
1780
  this.clientNames.set(sessionId, name);
1781
+ if (version !== void 0) this.clientVersions.set(sessionId, version);
1105
1782
  }
1106
1783
  /** Client-reported name for a session, if any. */
1107
1784
  clientName(sessionId) {
1108
1785
  if (sessionId === void 0) return void 0;
1109
1786
  return this.clientNames.get(sessionId);
1110
1787
  }
1788
+ /** Client-reported version for a session, if any. */
1789
+ clientVersion(sessionId) {
1790
+ if (sessionId === void 0) return void 0;
1791
+ return this.clientVersions.get(sessionId);
1792
+ }
1111
1793
  /** Sessions whose owner explicitly unregistered; auto-registration is
1112
1794
  * suppressed for them until an explicit `bridge_register`. */
1113
1795
  suppressedAuto = /* @__PURE__ */ new Set();
@@ -1123,8 +1805,24 @@ var SessionRegistry = class {
1123
1805
  isSuppressed(sessionId) {
1124
1806
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
1125
1807
  }
1808
+ /** Session → client IP (remote mode: the admin roster shows where a peer
1809
+ * connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
1810
+ sessionIps = /* @__PURE__ */ new Map();
1126
1811
  /** Sessions with a live SSE stream (server→client channel). */
1127
1812
  liveStreams = /* @__PURE__ */ new Set();
1813
+ /** Session → token identity (remote auth mode only; see src/auth.ts).
1814
+ * The TOKEN, not the client-reported name, decides the peer id, so two
1815
+ * users running the same client get distinct peers. */
1816
+ tokenBindings = /* @__PURE__ */ new Map();
1817
+ /** Bind a session to its token identity (at initialize / SSE open). */
1818
+ bindToken(sessionId, identity) {
1819
+ this.tokenBindings.set(sessionId, identity);
1820
+ }
1821
+ /** Token identity bound to a session, if remote auth is in use. */
1822
+ tokenFor(sessionId) {
1823
+ if (sessionId === void 0) return void 0;
1824
+ return this.tokenBindings.get(sessionId);
1825
+ }
1128
1826
  /** Mark a session's SSE stream open (server→client channel alive). */
1129
1827
  markSseOpen(sessionId) {
1130
1828
  this.liveStreams.add(sessionId);
@@ -1137,11 +1835,39 @@ var SessionRegistry = class {
1137
1835
  liveSessions() {
1138
1836
  return this.liveStreams;
1139
1837
  }
1140
- /** Drop every binding that points at `peerId` (used by the idle GC). */
1141
- unbindPeerId(peerId) {
1838
+ /** Drop every binding that points at `peerId` (used by the idle GC). With
1839
+ * `suppress`, the detached sessions also stop auto-re-registering — the
1840
+ * manager-kick path, so a kicked agent stays out until it explicitly
1841
+ * `bridge_register`s or reconnects with a fresh session. */
1842
+ unbindPeerId(peerId, options) {
1843
+ const detached = [];
1844
+ for (const [sessionId, bound] of this.peerBindings) {
1845
+ if (bound === peerId) {
1846
+ this.peerBindings.delete(sessionId);
1847
+ detached.push(sessionId);
1848
+ if (options?.suppress === true) this.suppressedAuto.add(sessionId);
1849
+ }
1850
+ }
1851
+ return detached;
1852
+ }
1853
+ /** Session ids currently attached to `peerId` (any binding direction). */
1854
+ sessionsForPeer(peerId) {
1855
+ const sessions = [];
1142
1856
  for (const [sessionId, bound] of this.peerBindings) {
1143
- if (bound === peerId) this.peerBindings.delete(sessionId);
1857
+ if (bound === peerId) sessions.push(sessionId);
1144
1858
  }
1859
+ return sessions;
1860
+ }
1861
+ /** Re-point every session bound to `oldId` at `newId` (true rename). */
1862
+ rebindPeerId(oldId, newId) {
1863
+ let count = 0;
1864
+ for (const [sessionId, bound] of this.peerBindings) {
1865
+ if (bound === oldId) {
1866
+ this.peerBindings.set(sessionId, newId);
1867
+ count++;
1868
+ }
1869
+ }
1870
+ return count;
1145
1871
  }
1146
1872
  /** How many sessions are currently attached to `peerId`. */
1147
1873
  attachedCount(peerId) {
@@ -1155,18 +1881,29 @@ var SessionRegistry = class {
1155
1881
  var McpStreamableHttpServer = class {
1156
1882
  constructor(tools, info, registry, log = () => {
1157
1883
  }, onInitialize = () => {
1158
- }) {
1884
+ }, authenticate, adminApi) {
1159
1885
  this.tools = tools;
1160
1886
  this.info = info;
1161
1887
  this.registry = registry;
1162
1888
  this.log = log;
1163
1889
  this.onInitialize = onInitialize;
1890
+ this.authenticate = authenticate;
1891
+ this.adminApi = adminApi;
1164
1892
  }
1165
1893
  sseStreams = /* @__PURE__ */ new Map();
1166
1894
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
1167
1895
  attach(server, path2) {
1168
1896
  server.on("request", (req, res) => {
1169
1897
  const url = new URL(req.url ?? "/", "http://localhost");
1898
+ if (url.pathname === ADMIN_PATH && req.method === "GET") {
1899
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
1900
+ res.end(adminPage());
1901
+ return;
1902
+ }
1903
+ if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
1904
+ void this.handleAdminApi(req, res, url);
1905
+ return;
1906
+ }
1170
1907
  if (url.pathname !== path2) {
1171
1908
  res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
1172
1909
  return;
@@ -1191,8 +1928,39 @@ var McpStreamableHttpServer = class {
1191
1928
  for (const stream of this.sseStreams.values()) stream.end();
1192
1929
  this.sseStreams.clear();
1193
1930
  }
1931
+ /** Push a JSON-RPC notification to every open SSE stream (roster events are
1932
+ * public — any peer could poll bridge_peers for the same information).
1933
+ * Write failures drop the dead stream silently; SSE is best-effort and
1934
+ * long-poll (bridge_wait) remains the reliable delivery path. */
1935
+ notifyAll(method, params) {
1936
+ this.notify([...this.sseStreams.keys()], method, params);
1937
+ }
1938
+ /** Push a JSON-RPC notification to the SSE streams of specific sessions. */
1939
+ notify(sessionIds, method, params) {
1940
+ const frame = `event: message
1941
+ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1942
+
1943
+ `;
1944
+ for (const sessionId of sessionIds) {
1945
+ const stream = this.sseStreams.get(sessionId);
1946
+ if (stream === void 0) continue;
1947
+ try {
1948
+ stream.write(frame);
1949
+ } catch {
1950
+ this.sseStreams.delete(sessionId);
1951
+ this.registry.markSseClosed(sessionId);
1952
+ }
1953
+ }
1954
+ }
1194
1955
  handleGet(req, res) {
1956
+ if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
1957
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1958
+ res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
1959
+ return;
1960
+ }
1195
1961
  const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
1962
+ const identity = this.authenticate?.(req);
1963
+ if (identity !== void 0) this.registry.bindToken(sessionId, identity);
1196
1964
  res.writeHead(200, {
1197
1965
  ...corsHeaders(),
1198
1966
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -1208,7 +1976,79 @@ var McpStreamableHttpServer = class {
1208
1976
  this.registry.markSseClosed(sessionId);
1209
1977
  });
1210
1978
  }
1979
+ /** Reject a request without a valid token (remote auth mode). */
1980
+ unauthorized(res, id, why) {
1981
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1982
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
1983
+ }
1984
+ /** Management API for the bundled admin page: issue/revoke tokens and list
1985
+ * them. Like everything else, each call is authenticated by `authenticate`
1986
+ * and must resolve to a MANAGER role — an agent token gets 403. */
1987
+ async handleAdminApi(req, res, url) {
1988
+ const respond = (status, body) => {
1989
+ res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1990
+ res.end(JSON.stringify(body));
1991
+ };
1992
+ const identity = this.authenticate?.(req);
1993
+ if (identity === void 0) {
1994
+ respond(401, { error: "unauthorized: manager token required" });
1995
+ return;
1996
+ }
1997
+ if (identity.role !== "manager") {
1998
+ respond(403, { error: "forbidden: this API needs a manager-role token" });
1999
+ return;
2000
+ }
2001
+ const api = this.adminApi;
2002
+ const seg = url.pathname.slice(ADMIN_API_PATH.length).replace(/^\/+|\/+$/g, "").split("/");
2003
+ if (req.method === "GET" && seg[0] === "tokens") {
2004
+ respond(200, { tokens: api.listTokens(url.searchParams.get("reveal") === "1") });
2005
+ return;
2006
+ }
2007
+ if (req.method === "POST" && seg[0] === "tokens" && seg[1] === "add") {
2008
+ let input;
2009
+ try {
2010
+ input = JSON.parse((await readBody(req)).toString("utf8"));
2011
+ } catch (error) {
2012
+ respond(400, { error: `bad json: ${error.message}` });
2013
+ return;
2014
+ }
2015
+ try {
2016
+ const created = api.addToken({
2017
+ peer: String(input.peer ?? ""),
2018
+ role: String(input.role ?? "agent"),
2019
+ owner: input.owner === void 0 ? void 0 : String(input.owner)
2020
+ });
2021
+ respond(200, { created });
2022
+ } catch (error) {
2023
+ respond(400, { error: error.message });
2024
+ }
2025
+ return;
2026
+ }
2027
+ if (req.method === "DELETE" && seg[0] === "tokens" && seg[1] === "remove" && seg[2] !== void 0) {
2028
+ respond(200, { removed: api.removeToken(decodeURIComponent(seg[2])) });
2029
+ return;
2030
+ }
2031
+ respond(404, { error: "not found" });
2032
+ }
1211
2033
  async handlePost(req, res) {
2034
+ let authIdentity;
2035
+ if (this.authenticate !== void 0) {
2036
+ authIdentity = this.authenticate(req);
2037
+ if (authIdentity === void 0) {
2038
+ this.unauthorized(res, null, "missing or invalid bearer token");
2039
+ return;
2040
+ }
2041
+ const existingSession = this.registry.sessionIdFor(req);
2042
+ const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
2043
+ const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
2044
+ if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
2045
+ this.unauthorized(res, null, "token does not match this session");
2046
+ return;
2047
+ }
2048
+ if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
2049
+ this.registry.bindToken(existingSession, authIdentity);
2050
+ }
2051
+ }
1212
2052
  let body;
1213
2053
  try {
1214
2054
  body = await readBody(req);
@@ -1229,12 +2069,13 @@ var McpStreamableHttpServer = class {
1229
2069
  }
1230
2070
  const id = message.id ?? null;
1231
2071
  const sessionId = this.registry.sessionIdFor(req);
2072
+ if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
1232
2073
  if (id === null) {
1233
2074
  res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
1234
2075
  return;
1235
2076
  }
1236
2077
  try {
1237
- const { result, extraHeaders } = await this.dispatch(message, sessionId);
2078
+ const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
1238
2079
  res.writeHead(200, {
1239
2080
  ...corsHeaders(),
1240
2081
  "Content-Type": "application/json; charset=utf-8",
@@ -1247,17 +2088,21 @@ var McpStreamableHttpServer = class {
1247
2088
  this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
1248
2089
  }
1249
2090
  }
1250
- async dispatch(message, sessionId) {
2091
+ async dispatch(message, sessionId, authIdentity) {
1251
2092
  const method = message.method ?? "";
1252
2093
  switch (method) {
1253
2094
  case "initialize": {
1254
2095
  const newSessionId = this.registry.ensureSession(sessionId);
1255
2096
  const clientInfo = message.params?.clientInfo;
1256
2097
  const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
2098
+ const clientVersion = typeof clientInfo?.version === "string" && clientInfo.version !== "" ? clientInfo.version : void 0;
1257
2099
  if (clientName !== void 0) {
1258
- this.registry.noteClient(newSessionId, clientName);
2100
+ this.registry.noteClient(newSessionId, clientName, clientVersion);
1259
2101
  }
1260
- this.onInitialize(newSessionId, clientName);
2102
+ if (authIdentity !== void 0) {
2103
+ this.registry.bindToken(newSessionId, authIdentity);
2104
+ }
2105
+ this.onInitialize(newSessionId, clientName, clientVersion);
1261
2106
  const requested = message.params?.protocolVersion;
1262
2107
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
1263
2108
  return {
@@ -1320,6 +2165,14 @@ function rpcError(code, message) {
1320
2165
  function messageProtocolVersion(req) {
1321
2166
  return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
1322
2167
  }
2168
+ function clientIp(req) {
2169
+ const forwarded = req.headers["x-forwarded-for"];
2170
+ const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
2171
+ if (typeof raw === "string" && raw.trim() !== "") {
2172
+ return raw.split(",")[0].trim();
2173
+ }
2174
+ return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
2175
+ }
1323
2176
  function corsHeaders() {
1324
2177
  return {
1325
2178
  "Access-Control-Allow-Origin": "*",
@@ -1345,9 +2198,149 @@ function readBody(req) {
1345
2198
  });
1346
2199
  }
1347
2200
 
2201
+ // src/persist.ts
2202
+ import { DatabaseSync } from "node:sqlite";
2203
+ var KIND_NAMES = ["chat", "task", "notice", "ack"];
2204
+ function messageToRow(message) {
2205
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
2206
+ }
2207
+ function rowToMessage(row) {
2208
+ const id = row.id;
2209
+ const from = row.from_peer;
2210
+ const to = row.to_peer;
2211
+ const kind = row.kind;
2212
+ const content = row.content;
2213
+ const ts = row.ts;
2214
+ if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof content !== "string" || typeof ts !== "number") return void 0;
2215
+ if (!KIND_NAMES.includes(kind)) return void 0;
2216
+ return {
2217
+ id,
2218
+ from,
2219
+ to,
2220
+ kind,
2221
+ content,
2222
+ ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2223
+ ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2224
+ ts
2225
+ };
2226
+ }
2227
+ var SQLiteStateStore = class {
2228
+ constructor(file, historyLimit, log) {
2229
+ this.historyLimit = historyLimit;
2230
+ this.db = new DatabaseSync(file);
2231
+ this.db.exec("PRAGMA journal_mode = WAL");
2232
+ this.db.exec(`
2233
+ CREATE TABLE IF NOT EXISTS profiles (
2234
+ id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
2235
+ );
2236
+ CREATE TABLE IF NOT EXISTS messages (
2237
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2238
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2239
+ );
2240
+ CREATE TABLE IF NOT EXISTS mailboxes (
2241
+ peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2242
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2243
+ );
2244
+ CREATE TABLE IF NOT EXISTS groups (
2245
+ id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2246
+ );
2247
+ `);
2248
+ log.warn(`sqlite state open: ${file}`);
2249
+ }
2250
+ db;
2251
+ buffer = [];
2252
+ /** Buffer one history message (called per message via the hub hook). */
2253
+ appendMessage(message) {
2254
+ this.buffer.push(message);
2255
+ }
2256
+ /** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
2257
+ flush(snapshot) {
2258
+ const insert = this.db.prepare(
2259
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
2260
+ );
2261
+ for (const message of this.buffer.splice(0, this.buffer.length)) {
2262
+ insert.run(...messageToRow(message));
2263
+ }
2264
+ this.db.exec("DELETE FROM mailboxes");
2265
+ const insertMail = this.db.prepare(
2266
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2267
+ );
2268
+ for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2269
+ for (const message of queue) insertMail.run(peer, ...messageToRow(message));
2270
+ }
2271
+ this.db.exec("DELETE FROM profiles");
2272
+ const insertProfile = this.db.prepare(
2273
+ "INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
2274
+ );
2275
+ for (const profile of snapshot.profiles) {
2276
+ insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
2277
+ }
2278
+ this.db.exec("DELETE FROM groups");
2279
+ const insertGroup = this.db.prepare(
2280
+ "INSERT INTO groups (id, name, members, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
2281
+ );
2282
+ for (const group of snapshot.groups) {
2283
+ insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
2284
+ }
2285
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
2286
+ const total = Number(row.n);
2287
+ if (total > this.historyLimit * 2) {
2288
+ this.db.exec(`DELETE FROM messages WHERE seq <= (SELECT MAX(seq) FROM messages) - ${this.historyLimit}`);
2289
+ }
2290
+ }
2291
+ /** Read everything persisted so far (boot restore). */
2292
+ load() {
2293
+ const profiles = [];
2294
+ for (const row of this.db.prepare("SELECT * FROM profiles").all()) {
2295
+ if (typeof row.id !== "string") continue;
2296
+ profiles.push({
2297
+ id: row.id,
2298
+ ...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
2299
+ ...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
2300
+ ...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
2301
+ registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
2302
+ });
2303
+ }
2304
+ const messages = [];
2305
+ for (const row of this.db.prepare("SELECT * FROM messages ORDER BY seq").all()) {
2306
+ const message = rowToMessage(row);
2307
+ if (message !== void 0) messages.push(message);
2308
+ }
2309
+ const mailboxes = {};
2310
+ for (const row of this.db.prepare("SELECT * FROM mailboxes ORDER BY seq").all()) {
2311
+ if (typeof row.peer !== "string") continue;
2312
+ const message = rowToMessage(row);
2313
+ if (message === void 0) continue;
2314
+ (mailboxes[row.peer] ??= []).push(message);
2315
+ }
2316
+ const groups = [];
2317
+ for (const row of this.db.prepare("SELECT * FROM groups").all()) {
2318
+ if (typeof row.id !== "string" || typeof row.members !== "string") continue;
2319
+ try {
2320
+ const members = JSON.parse(row.members);
2321
+ if (!Array.isArray(members) || members.length === 0) continue;
2322
+ groups.push({
2323
+ id: row.id,
2324
+ members: members.filter((m) => typeof m === "string"),
2325
+ createdBy: typeof row.created_by === "string" ? row.created_by : "",
2326
+ createdAt: typeof row.created_at === "number" ? row.created_at : Date.now(),
2327
+ ...typeof row.name === "string" && row.name !== "" ? { name: row.name } : {}
2328
+ });
2329
+ } catch {
2330
+ }
2331
+ }
2332
+ return { profiles, messages, mailboxes, groups };
2333
+ }
2334
+ /** Final flush + close (hub shutdown). */
2335
+ close(snapshot) {
2336
+ this.flush(snapshot);
2337
+ this.db.close();
2338
+ }
2339
+ };
2340
+
1348
2341
  // src/index.ts
1349
2342
  var SERVER_NAME = "agent-comm-hub";
1350
- var SERVER_VERSION = "0.5.0";
2343
+ var SERVER_VERSION = "0.7.0";
1351
2344
  var DEFAULT_HOST = "127.0.0.1";
1352
2345
  var DEFAULT_PORT = 18764;
1353
2346
  var DEFAULT_PATH = "/mcp";
@@ -1363,11 +2356,107 @@ var DEFAULT_CONFIG = {
1363
2356
  waitTimeoutMs: 6e4,
1364
2357
  defaultWaitMs: 3e4,
1365
2358
  connectedWindowMs: 3e4,
1366
- peerIdleTimeoutMs: 6e5
2359
+ peerIdleTimeoutMs: 6e5,
2360
+ // The companion desktop app (and `agent-comm-hub status` probe) registers
2361
+ // as `agent-hub-cli`; it is the natural roster manager. Agents themselves
2362
+ // are NOT managers — they keep chatting, a GUI manages.
2363
+ managerPeers: ["agent-hub-cli"]
1367
2364
  };
2365
+ function authTokensAdmin(file, reloadAuth) {
2366
+ if (file === void 0) return void 0;
2367
+ return {
2368
+ listTokens: (reveal) => readTokens(file).map((r) => ({
2369
+ token: reveal ? r.token : `${r.token.slice(0, 6)}\u2026${r.token.slice(-4)}`,
2370
+ peer: r.peer,
2371
+ role: r.role,
2372
+ ...r.owner !== void 0 ? { owner: r.owner } : {}
2373
+ })),
2374
+ addToken: (input) => {
2375
+ const row = addToken(file, { peer: input.peer, role: input.role, owner: input.owner });
2376
+ reloadAuth?.();
2377
+ return { token: row.token, peer: row.peer, role: row.role, ...row.owner !== void 0 ? { owner: row.owner } : {} };
2378
+ },
2379
+ removeToken: (peer) => {
2380
+ const ok = removeToken(file, peer);
2381
+ if (ok) reloadAuth?.();
2382
+ return ok;
2383
+ },
2384
+ reloadAuth: () => reloadAuth?.()
2385
+ };
2386
+ }
2387
+ function writeRosterFile(file, profiles) {
2388
+ mkdirSync2(dirname2(file), { recursive: true });
2389
+ const tmp = `${file}.tmp`;
2390
+ writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2391
+ renameSync2(tmp, file);
2392
+ }
1368
2393
  function startHub(config = {}, log = console) {
1369
2394
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1370
2395
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
2396
+ let mcp;
2397
+ let dbStore;
2398
+ const pushRoster = () => {
2399
+ mcp.notifyAll("notifications/message", {
2400
+ level: "info",
2401
+ logger: "bridge",
2402
+ data: { event: "peers_changed", peers: rosterFor(hub, registry) }
2403
+ });
2404
+ };
2405
+ const pushQueued = (message, target) => {
2406
+ mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
2407
+ level: "info",
2408
+ logger: "bridge",
2409
+ data: { event: "message", message: present(message) }
2410
+ });
2411
+ };
2412
+ const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
2413
+ const pushRelay = (message) => {
2414
+ const managers = [];
2415
+ for (const sessionId of registry.liveSessions()) {
2416
+ const peer = registry.peerFor(sessionId);
2417
+ if (peer === void 0) continue;
2418
+ if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
2419
+ managers.push(sessionId);
2420
+ }
2421
+ }
2422
+ if (managers.length > 0) {
2423
+ mcp.notify(managers, "notifications/message", {
2424
+ level: "info",
2425
+ logger: "bridge",
2426
+ data: { event: "message", message: present(message) }
2427
+ });
2428
+ }
2429
+ };
2430
+ let saveTimer;
2431
+ let rosterDirty = false;
2432
+ let dbDirty = false;
2433
+ const flushSaves = () => {
2434
+ if (rosterDirty && resolved.stateFile !== void 0) {
2435
+ rosterDirty = false;
2436
+ try {
2437
+ writeRosterFile(resolved.stateFile, hub.exportProfiles());
2438
+ } catch (error) {
2439
+ log.warn(`roster save failed: ${error.message}`);
2440
+ }
2441
+ }
2442
+ if (dbDirty && dbStore !== void 0) {
2443
+ dbDirty = false;
2444
+ try {
2445
+ dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2446
+ } catch (error) {
2447
+ log.warn(`sqlite flush failed: ${error.message}`);
2448
+ }
2449
+ }
2450
+ };
2451
+ const scheduleSave = () => {
2452
+ if (resolved.stateFile === void 0 && dbStore === void 0) return;
2453
+ if (saveTimer !== void 0) return;
2454
+ saveTimer = setTimeout(() => {
2455
+ saveTimer = void 0;
2456
+ flushSaves();
2457
+ }, 500);
2458
+ saveTimer.unref?.();
2459
+ };
1371
2460
  const hub = new AgentHub({
1372
2461
  maxQueue: resolved.maxQueue,
1373
2462
  historyLimit: resolved.historyLimit,
@@ -1375,10 +2464,94 @@ function startHub(config = {}, log = console) {
1375
2464
  connectedWindowMs: resolved.connectedWindowMs,
1376
2465
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1377
2466
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
2467
+ // Remote persistence: buffer every history append; re-snapshot when a
2468
+ // mailbox is consumed so drained messages cannot resurrect on restart.
2469
+ // Also relay every message to manager sessions (the relay stream view) —
2470
+ // best-effort; a notification failure must never break routing.
2471
+ onMessage: (message) => {
2472
+ if (dbStore !== void 0) {
2473
+ dbStore.appendMessage(message);
2474
+ dbDirty = true;
2475
+ }
2476
+ try {
2477
+ pushRelay(message);
2478
+ } catch (error) {
2479
+ log.warn(`relay notification failed: ${error.message}`);
2480
+ }
2481
+ },
2482
+ onMailboxesChanged: () => {
2483
+ if (dbStore === void 0) return;
2484
+ dbDirty = true;
2485
+ },
2486
+ onGroupsChanged: () => {
2487
+ if (dbStore === void 0) return;
2488
+ dbDirty = true;
2489
+ scheduleSave();
2490
+ },
1378
2491
  // The idle GC must never evict a peer whose session has a live SSE channel.
1379
- isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
2492
+ isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
2493
+ // SSE event push: roster changes and queued-mail hints. Best-effort — a
2494
+ // notification failure must never break routing, so swallow and log.
2495
+ onPeersChanged: () => {
2496
+ try {
2497
+ pushRoster();
2498
+ } catch (error) {
2499
+ log.warn(`peers_changed notification failed: ${error.message}`);
2500
+ }
2501
+ rosterDirty = true;
2502
+ dbDirty = true;
2503
+ scheduleSave();
2504
+ },
2505
+ onQueued: (message, target) => {
2506
+ try {
2507
+ pushQueued(message, target);
2508
+ } catch (error) {
2509
+ log.warn(`message notification failed: ${error.message}`);
2510
+ }
2511
+ }
1380
2512
  });
1381
2513
  const registry = new SessionRegistry();
2514
+ if (resolved.db !== void 0) {
2515
+ try {
2516
+ dbStore = new SQLiteStateStore(resolved.db, resolved.historyLimit, log);
2517
+ const state = dbStore.load();
2518
+ const importedProfiles = hub.importProfiles(state.profiles);
2519
+ if (state.profiles.length === 0 && resolved.stateFile !== void 0) {
2520
+ try {
2521
+ const legacy = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
2522
+ const migrated = hub.importProfiles(legacy.profiles);
2523
+ if (migrated > 0) log.info(`roster migrated: ${migrated} profile(s) from ${resolved.stateFile} into ${resolved.db}`);
2524
+ } catch (error) {
2525
+ if (error.code !== "ENOENT") {
2526
+ log.warn(`legacy roster import failed: ${error.message}`);
2527
+ }
2528
+ }
2529
+ } else if (importedProfiles > 0) {
2530
+ log.info(`roster restored: ${importedProfiles} profile(s) from ${resolved.db}`);
2531
+ }
2532
+ const restoredMessages = hub.importHistory(state.messages);
2533
+ const restoredQueued = hub.importMailboxes(state.mailboxes);
2534
+ const restoredGroups = hub.importGroups(state.groups);
2535
+ if (restoredMessages + restoredQueued + restoredGroups > 0) {
2536
+ log.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
2537
+ dbDirty = true;
2538
+ }
2539
+ } catch (error) {
2540
+ dbStore = void 0;
2541
+ log.warn(`sqlite state unavailable (${error.message}); running memory-only`);
2542
+ }
2543
+ }
2544
+ if (dbStore === void 0 && resolved.stateFile !== void 0) {
2545
+ try {
2546
+ const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
2547
+ const imported = hub.importProfiles(parsed.profiles);
2548
+ if (imported > 0) log.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
2549
+ } catch (error) {
2550
+ if (error.code !== "ENOENT") {
2551
+ log.warn(`roster load failed: ${error.message}`);
2552
+ }
2553
+ }
2554
+ }
1382
2555
  const herdr = new HerdrCtl({
1383
2556
  bin: resolved.herdrBin,
1384
2557
  baseArgs: resolved.herdrBaseArgs,
@@ -1386,24 +2559,58 @@ function startHub(config = {}, log = console) {
1386
2559
  socketPath: resolved.herdrSocketPath,
1387
2560
  sendRequest: resolved.herdrSendRequest
1388
2561
  });
1389
- const mcp = new McpStreamableHttpServer(
2562
+ let auth;
2563
+ let authReload;
2564
+ if (resolved.authTokens !== void 0) {
2565
+ const table = watchTokenFile(resolved.authTokens, {
2566
+ info: (message) => log.info(message),
2567
+ warn: (message) => log.warn(message)
2568
+ });
2569
+ authReload = table.reload;
2570
+ auth = (req) => {
2571
+ const presented = bearerFromHeaders(req.headers);
2572
+ if (presented !== void 0) return table.authenticate(presented);
2573
+ if (resolved.allowJoin === true) {
2574
+ return { peer: ANON_JOIN_PEER, role: "agent", anonymous: true };
2575
+ }
2576
+ return void 0;
2577
+ };
2578
+ } else if (resolved.allowJoin === true) {
2579
+ auth = (req) => ({ peer: ANON_JOIN_PEER, role: "agent", anonymous: true });
2580
+ }
2581
+ mcp = new McpStreamableHttpServer(
1390
2582
  hubTools(hub, registry, {
1391
2583
  defaultWaitMs: resolved.defaultWaitMs,
1392
2584
  waitTimeoutMs: resolved.waitTimeoutMs,
1393
2585
  herdr,
1394
- herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
2586
+ herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
2587
+ managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
1395
2588
  }),
1396
2589
  { name: SERVER_NAME, version: SERVER_VERSION },
1397
2590
  registry,
1398
2591
  (message) => log.warn(message),
1399
- (sessionId, clientName) => {
2592
+ (sessionId, clientName, clientVersion) => {
1400
2593
  try {
1401
- const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
1402
- if (peer !== void 0) log.info(`peer joined: ${peer}`);
2594
+ const identity = registry.tokenFor(sessionId);
2595
+ if (identity?.anonymous === true && identity.peer === ANON_JOIN_PEER) {
2596
+ let candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2597
+ while (hub.has(candidate)) {
2598
+ candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2599
+ }
2600
+ registry.bindToken(sessionId, { ...identity, peer: candidate });
2601
+ }
2602
+ const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
2603
+ if (peer !== void 0) {
2604
+ const owner = registry.tokenFor(sessionId)?.owner;
2605
+ const anon = registry.tokenFor(sessionId)?.anonymous === true ? " (anonymous join)" : "";
2606
+ log.info(`peer joined: ${peer}${owner !== void 0 ? ` (owner: ${owner})` : ""}${anon}`);
2607
+ }
1403
2608
  } catch (error) {
1404
2609
  log.warn(`auto-register failed: ${error.message}`);
1405
2610
  }
1406
- }
2611
+ },
2612
+ auth,
2613
+ authTokensAdmin(resolved.authTokens, authReload)
1407
2614
  );
1408
2615
  const server = createServer();
1409
2616
  mcp.attach(server, resolved.path);
@@ -1419,6 +2626,19 @@ function startHub(config = {}, log = console) {
1419
2626
  close: () => {
1420
2627
  hub.dispose();
1421
2628
  mcp.close();
2629
+ if (saveTimer !== void 0) {
2630
+ clearTimeout(saveTimer);
2631
+ saveTimer = void 0;
2632
+ }
2633
+ flushSaves();
2634
+ if (dbStore !== void 0) {
2635
+ try {
2636
+ dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2637
+ } catch (error) {
2638
+ log.warn(`sqlite close failed: ${error.message}`);
2639
+ }
2640
+ dbStore = void 0;
2641
+ }
1422
2642
  server.closeAllConnections?.();
1423
2643
  server.close();
1424
2644
  }
@@ -1438,12 +2658,21 @@ export {
1438
2658
  PEER_ID_PATTERN,
1439
2659
  SERVER_NAME,
1440
2660
  SERVER_VERSION,
2661
+ SQLiteStateStore,
1441
2662
  SessionRegistry,
2663
+ addToken,
1442
2664
  autoRegisterPeer,
1443
2665
  decodeContent,
1444
2666
  encodeContent,
2667
+ generateToken,
1445
2668
  hubTools,
2669
+ loadTokenFile,
1446
2670
  present,
2671
+ readTokens,
2672
+ removeToken,
2673
+ sanitizeAlias,
1447
2674
  sanitizePeerId,
1448
- startHub
2675
+ startHub,
2676
+ tokenTable,
2677
+ watchTokenFile
1449
2678
  };