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/cli.js CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createServer } from "node:http";
5
+ import { randomBytes as randomBytes2 } from "node:crypto";
6
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
7
+ import { dirname as dirname2 } from "node:path";
5
8
 
6
9
  // src/herdr-ctl.ts
7
10
  import { execFile } from "node:child_process";
@@ -418,6 +421,8 @@ function socketSend(socketPath, method, params) {
418
421
  import { randomUUID } from "node:crypto";
419
422
 
420
423
  // src/protocol.ts
424
+ var KINDS = ["chat", "task", "notice", "ack"];
425
+ var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
421
426
  var BROADCAST = "all";
422
427
  function decodeContent(kind, content) {
423
428
  if (kind !== "task" && kind !== "ack") return content;
@@ -430,6 +435,7 @@ function decodeContent(kind, content) {
430
435
  }
431
436
 
432
437
  // src/hub.ts
438
+ var MAX_PROFILES = 512;
433
439
  var AgentHub = class {
434
440
  constructor(options) {
435
441
  this.options = options;
@@ -444,6 +450,10 @@ var AgentHub = class {
444
450
  waiters = /* @__PURE__ */ new Map();
445
451
  historyRing = [];
446
452
  lastSeen = /* @__PURE__ */ new Map();
453
+ /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
454
+ profiles = /* @__PURE__ */ new Map();
455
+ /** Named channels (group channel), keyed by group id. */
456
+ groups = /* @__PURE__ */ new Map();
447
457
  gcTimer;
448
458
  /** Stop the idle GC (call when the hub shuts down). */
449
459
  dispose() {
@@ -463,14 +473,27 @@ var AgentHub = class {
463
473
  peers() {
464
474
  return [...this.lastSeen.keys()];
465
475
  }
466
- /** Register a peer; throws if the id is already taken. */
467
- register(peerId) {
476
+ /** Register a peer; throws if the id is already taken. Client metadata
477
+ * (when provided) refreshes the profile — with same-name sessions sharing a
478
+ * peer, the freshest connect wins, which self-heals stale metadata. */
479
+ register(peerId, client) {
468
480
  if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
469
481
  this.lastSeen.set(peerId, Date.now());
482
+ const existing = this.profiles.get(peerId);
483
+ this.profiles.set(peerId, {
484
+ ...existing,
485
+ id: peerId,
486
+ registeredAt: existing?.registeredAt ?? Date.now(),
487
+ ...client?.name !== void 0 ? { clientName: client.name } : existing?.clientName !== void 0 ? { clientName: existing.clientName } : {},
488
+ ...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
489
+ });
490
+ this.evictOverflowProfiles();
470
491
  this.options.onPeersChanged?.(this.peers());
471
492
  }
472
- /** Remove a peer and its queued messages; pending waiters resolve as timeouts. */
493
+ /** Remove a peer and its queued messages; pending waiters resolve as
494
+ * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
473
495
  unregister(peerId) {
496
+ if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
474
497
  this.queues.delete(peerId);
475
498
  for (const waiter of this.waiters.get(peerId) ?? []) {
476
499
  clearTimeout(waiter.timer);
@@ -480,6 +503,110 @@ var AgentHub = class {
480
503
  this.lastSeen.delete(peerId);
481
504
  this.options.onPeersChanged?.(this.peers());
482
505
  }
506
+ /** Identity metadata for `peerId`, if the hub ever saw it. */
507
+ profileOf(peerId) {
508
+ return this.profiles.get(peerId);
509
+ }
510
+ /** Snapshot of every known profile (registered or not) for persistence. */
511
+ exportProfiles() {
512
+ return [...this.profiles.values()].map((profile) => ({ ...profile }));
513
+ }
514
+ /** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
515
+ * and non-finite fields are sanitized. Returns the imported count. Silent
516
+ * for roster observers — this runs before the server accepts requests. */
517
+ importProfiles(profiles) {
518
+ if (!Array.isArray(profiles)) return 0;
519
+ let count = 0;
520
+ for (const raw of profiles) {
521
+ if (typeof raw !== "object" || raw === null) continue;
522
+ const candidate = raw;
523
+ const id = candidate.id;
524
+ if (typeof id !== "string" || !PEER_ID_PATTERN.test(id) || id === BROADCAST) continue;
525
+ if (this.profiles.has(id)) continue;
526
+ const profile = {
527
+ id,
528
+ registeredAt: typeof candidate.registeredAt === "number" && Number.isFinite(candidate.registeredAt) ? candidate.registeredAt : Date.now()
529
+ };
530
+ if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
531
+ if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
532
+ if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
533
+ this.profiles.set(id, profile);
534
+ count++;
535
+ }
536
+ this.evictOverflowProfiles();
537
+ return count;
538
+ }
539
+ /** Set (or clear with `undefined`) the display alias of a registered peer.
540
+ * Routing state is untouched — this only changes how the peer is shown. */
541
+ setAlias(peerId, alias) {
542
+ if (!this.lastSeen.has(peerId)) throw new Error(`unknown peer: ${peerId}`);
543
+ const profile = this.profiles.get(peerId);
544
+ if (profile !== void 0) {
545
+ this.profiles.set(peerId, alias === void 0 ? omit(profile, "alias") : { ...profile, alias });
546
+ }
547
+ this.options.onPeersChanged?.(this.peers());
548
+ }
549
+ /**
550
+ * Atomically re-key a registered peer: mailbox, waiters, last-seen,
551
+ * profile, session bindings (via the caller's registry), and history
552
+ * attribution move from `oldId` to `newId` in one step, so queued
553
+ * messages, pending waits, ack routing, and history continuity all
554
+ * survive the rename. All validation happens before the first mutation.
555
+ */
556
+ renamePeer(oldId, newId) {
557
+ if (oldId === newId) return;
558
+ if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
559
+ if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
560
+ if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
561
+ if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
562
+ const queue = this.queues.get(oldId);
563
+ const waiters = this.waiters.get(oldId);
564
+ const seen = this.lastSeen.get(oldId) ?? Date.now();
565
+ const profile = this.profiles.get(oldId);
566
+ const stale = this.profiles.get(newId);
567
+ this.queues.delete(oldId);
568
+ this.waiters.delete(oldId);
569
+ this.lastSeen.delete(oldId);
570
+ this.profiles.delete(oldId);
571
+ if (queue !== void 0) this.queues.set(newId, queue);
572
+ if (waiters !== void 0) this.waiters.set(newId, waiters);
573
+ this.lastSeen.set(newId, seen);
574
+ if (profile !== void 0) {
575
+ const merged = {
576
+ ...stale,
577
+ ...profile,
578
+ id: newId,
579
+ registeredAt: stale !== void 0 && stale.registeredAt < profile.registeredAt ? stale.registeredAt : profile.registeredAt
580
+ };
581
+ this.profiles.set(newId, merged);
582
+ } else if (stale !== void 0) {
583
+ this.profiles.set(newId, stale);
584
+ } else {
585
+ this.profiles.set(newId, { id: newId, registeredAt: Date.now() });
586
+ }
587
+ for (const list of this.waiters.values()) {
588
+ for (const waiter of list) {
589
+ if (waiter.from === oldId) waiter.from = newId;
590
+ }
591
+ }
592
+ for (const message of this.historyRing) {
593
+ if (message.from === oldId) message.from = newId;
594
+ if (message.to === oldId) message.to = newId;
595
+ }
596
+ this.options.onPeersChanged?.(this.peers());
597
+ }
598
+ /** Keep the profile map bounded: drop the oldest profiles of peers that are
599
+ * NOT currently registered (live peers are never evicted). */
600
+ evictOverflowProfiles() {
601
+ if (this.profiles.size <= MAX_PROFILES) return;
602
+ const evictable = [...this.profiles.values()].filter((profile) => !this.lastSeen.has(profile.id)).sort((a, b) => a.registeredAt - b.registeredAt);
603
+ let overflow = this.profiles.size - MAX_PROFILES;
604
+ for (const profile of evictable) {
605
+ if (overflow <= 0) break;
606
+ this.profiles.delete(profile.id);
607
+ overflow--;
608
+ }
609
+ }
483
610
  /** Mark activity for `peer` (called on every tool call from that peer). */
484
611
  touch(peerId) {
485
612
  if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
@@ -507,9 +634,20 @@ var AgentHub = class {
507
634
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
508
635
  return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
509
636
  }
510
- /** Recent messages involving `peer` (inbound and outbound), newest first. */
637
+ /** Recent messages involving `peer` (inbound and outbound, plus group
638
+ * channels the peer belongs to), newest first. */
511
639
  history(peerId, limit) {
512
- const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
640
+ const memberOf = new Set(
641
+ [...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
642
+ );
643
+ const filtered = this.historyRing.filter(
644
+ (message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
645
+ );
646
+ return filtered.slice(-Math.max(0, limit)).reverse();
647
+ }
648
+ /** Recent messages of one group channel, newest first. */
649
+ historyChannel(groupId, limit) {
650
+ const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
513
651
  return filtered.slice(-Math.max(0, limit)).reverse();
514
652
  }
515
653
  /** Most recent messages across every peer, unfiltered (newest first).
@@ -518,21 +656,190 @@ var AgentHub = class {
518
656
  historyAll(limit) {
519
657
  return this.historyRing.slice(-Math.max(0, limit)).reverse();
520
658
  }
659
+ // ---- groups (group channel) ------------------------------------------------
660
+ /** Create a channel; throws when the id is taken or a member is unknown. */
661
+ createGroup(id, members, createdBy, name) {
662
+ if (this.groups.has(id)) throw new Error(`group already exists: ${id}`);
663
+ if (!PEER_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id} (expected [A-Za-z0-9._:-]{1,64})`);
664
+ const unique = [...new Set(members)];
665
+ if (unique.length === 0) throw new Error("a group needs at least one member");
666
+ for (const member of unique) {
667
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
668
+ }
669
+ if (!unique.includes(createdBy)) unique.unshift(createdBy);
670
+ const group = { id, members: unique, createdBy, createdAt: Date.now(), ...name !== void 0 && name !== "" ? { name } : {} };
671
+ this.groups.set(id, group);
672
+ this.options.onGroupsChanged?.();
673
+ return group;
674
+ }
675
+ /** Remove a channel (its history rows stay — attribution is unchanged). */
676
+ deleteGroup(id) {
677
+ if (!this.groups.delete(id)) return false;
678
+ this.options.onGroupsChanged?.();
679
+ return true;
680
+ }
681
+ groupOf(id) {
682
+ return this.groups.get(id);
683
+ }
684
+ /** Add a registered peer to an existing channel, so they can send and read
685
+ * it like any other member. Throws when the group is unknown, the peer is
686
+ * not registered, or it is already inside. */
687
+ addGroupMember(groupId, member) {
688
+ const group = this.groups.get(groupId);
689
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
690
+ if (group.members.includes(member)) throw new Error(`peer '${member}' is already a member of group '${groupId}'`);
691
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
692
+ group.members.push(member);
693
+ this.options.onGroupsChanged?.();
694
+ return group;
695
+ }
696
+ /** Remove a member from a channel. The creator cannot be removed (it would
697
+ * orphan the channel's management). Throws when the group or the membership
698
+ * is missing. */
699
+ removeGroupMember(groupId, member) {
700
+ const group = this.groups.get(groupId);
701
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
702
+ if (member === group.createdBy) throw new Error(`cannot remove the group creator '${member}'`);
703
+ const index = group.members.indexOf(member);
704
+ if (index === -1) throw new Error(`peer '${member}' is not a member of group '${groupId}'`);
705
+ group.members.splice(index, 1);
706
+ this.options.onGroupsChanged?.();
707
+ return group;
708
+ }
709
+ /** All groups (snapshot copy), insertion order. */
710
+ allGroups() {
711
+ return [...this.groups.values()].map((group) => ({ ...group, members: [...group.members] }));
712
+ }
713
+ /** Send a chat message to every member of a group except the sender;
714
+ * members that went offline are skipped (they read the group history).
715
+ * The message carries `to` and `channel` = the group id. */
716
+ sendToGroup(from, groupId, content) {
717
+ const group = this.groups.get(groupId);
718
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
719
+ if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
720
+ const message = {
721
+ id: randomUUID(),
722
+ from,
723
+ to: groupId,
724
+ kind: "chat",
725
+ content,
726
+ channel: groupId,
727
+ ts: Date.now()
728
+ };
729
+ this.lastSeen.set(from, message.ts);
730
+ for (const member of group.members) {
731
+ if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
732
+ }
733
+ return message;
734
+ }
735
+ /** Snapshot of every group for persistence. */
736
+ exportGroups() {
737
+ return this.allGroups();
738
+ }
739
+ /** Load persisted groups at boot (same validation as createGroup, minus
740
+ * the "members must be online" rule — members reconnect later). */
741
+ importGroups(groups) {
742
+ if (!Array.isArray(groups)) return 0;
743
+ let count = 0;
744
+ for (const raw of groups) {
745
+ if (typeof raw !== "object" || raw === null) continue;
746
+ const candidate = raw;
747
+ if (typeof candidate.id !== "string" || !PEER_ID_PATTERN.test(candidate.id) || this.groups.has(candidate.id)) continue;
748
+ if (!Array.isArray(candidate.members) || candidate.members.length === 0) continue;
749
+ const members = [...new Set(candidate.members.filter((m) => typeof m === "string" && PEER_ID_PATTERN.test(m)))];
750
+ if (members.length === 0) continue;
751
+ this.groups.set(candidate.id, {
752
+ id: candidate.id,
753
+ members,
754
+ createdBy: typeof candidate.createdBy === "string" && PEER_ID_PATTERN.test(candidate.createdBy) ? candidate.createdBy : members[0],
755
+ createdAt: typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now(),
756
+ ...typeof candidate.name === "string" && candidate.name !== "" ? { name: candidate.name } : {}
757
+ });
758
+ count++;
759
+ }
760
+ return count;
761
+ }
762
+ // ---- persistence import/export (remote-mode SQLite store) -----------
763
+ /** Restore the history ring at boot. Shape-invalid rows are dropped; only
764
+ * the newest {@link HubOptions.historyLimit} survive. */
765
+ importHistory(messages) {
766
+ if (!Array.isArray(messages)) return 0;
767
+ const valid = [];
768
+ for (const raw of messages) {
769
+ if (typeof raw !== "object" || raw === null) continue;
770
+ const m = raw;
771
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
772
+ if (KINDS.includes(m.kind) === false) continue;
773
+ valid.push({
774
+ id: m.id,
775
+ from: m.from,
776
+ to: m.to,
777
+ kind: m.kind,
778
+ content: m.content,
779
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
780
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
781
+ ts: m.ts
782
+ });
783
+ }
784
+ for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
785
+ return Math.min(valid.length, this.options.historyLimit);
786
+ }
787
+ /** Snapshot of every offline mailbox, keyed by peer (persistence). */
788
+ exportMailboxes() {
789
+ const out = {};
790
+ for (const [peer, queue] of this.queues) {
791
+ if (queue.length > 0) out[peer] = queue.map((message) => ({ ...message }));
792
+ }
793
+ return out;
794
+ }
795
+ /** Restore offline mailboxes at boot (peer reconnects and drains later). */
796
+ importMailboxes(mailboxes) {
797
+ if (typeof mailboxes !== "object" || mailboxes === null) return 0;
798
+ let count = 0;
799
+ for (const [peer, queue] of Object.entries(mailboxes)) {
800
+ if (!Array.isArray(queue) || queue.length === 0) continue;
801
+ const restored = [];
802
+ for (const raw of queue) {
803
+ if (typeof raw !== "object" || raw === null) continue;
804
+ const m = raw;
805
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
806
+ restored.push({
807
+ id: m.id,
808
+ from: m.from,
809
+ to: typeof m.to === "string" ? m.to : peer,
810
+ kind: KINDS.includes(m.kind) ? m.kind : "chat",
811
+ content: m.content,
812
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
813
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
814
+ ts: m.ts
815
+ });
816
+ }
817
+ if (restored.length > 0) {
818
+ this.queues.set(peer, restored.slice(-this.options.maxQueue));
819
+ count += restored.length;
820
+ }
821
+ }
822
+ return count;
823
+ }
521
824
  /**
522
- * Live summary for the status tool. `livePeers` (sessions with a live SSE
523
- * stream) count as connected even without recent tool activity.
524
- */
825
+ * Live summary for the status tool. `livePeers` (sessions with a live SSE
826
+ * stream) count as connected even without recent tool activity.
827
+ */
525
828
  status(livePeers) {
526
829
  const now = Date.now();
527
830
  return {
528
831
  server: "agent-comm-hub",
529
- peers: this.peers().map((peer) => ({
530
- id: peer,
531
- connected: this.isActive(peer) || livePeers?.has(peer) === true,
532
- lastSeenMs: this.lastSeen.get(peer) ?? 0,
533
- queued: (this.queues.get(peer) ?? []).length,
534
- waiting: (this.waiters.get(peer) ?? []).length
535
- })),
832
+ peers: this.peers().map((peer) => {
833
+ const alias = this.profiles.get(peer)?.alias;
834
+ return {
835
+ id: peer,
836
+ ...alias !== void 0 ? { alias } : {},
837
+ connected: this.isActive(peer) || livePeers?.has(peer) === true,
838
+ lastSeenMs: this.lastSeen.get(peer) ?? 0,
839
+ queued: (this.queues.get(peer) ?? []).length,
840
+ waiting: (this.waiters.get(peer) ?? []).length
841
+ };
842
+ }),
536
843
  historyLimit: this.options.historyLimit,
537
844
  maxQueue: this.options.maxQueue
538
845
  };
@@ -541,7 +848,7 @@ var AgentHub = class {
541
848
  poll(peerId, from) {
542
849
  const queue = this.queues.get(peerId) ?? [];
543
850
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
544
- for (const message of drained) this.remember(message);
851
+ if (drained.length > 0) this.options.onMailboxesChanged?.();
545
852
  return drained;
546
853
  }
547
854
  /**
@@ -624,12 +931,15 @@ var AgentHub = class {
624
931
  if (queue.length >= this.options.maxQueue) queue.shift();
625
932
  queue.push(message);
626
933
  this.queues.set(target, queue);
627
- this.options.onQueued?.(message);
934
+ this.remember(message);
935
+ this.options.onQueued?.(message, target);
628
936
  }
629
- /** Append a delivered message to the history ring. */
937
+ /** Append a delivered message to the history ring (and fire the
938
+ * persistence hook — one call per message, delivered or queued). */
630
939
  remember(message) {
631
940
  this.historyRing.push(message);
632
941
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
942
+ this.options.onMessage?.(message);
633
943
  }
634
944
  };
635
945
  function drainFrom(queue, from) {
@@ -642,14 +952,159 @@ function drainFrom(queue, from) {
642
952
  queue.splice(0, queue.length, ...kept);
643
953
  return drained;
644
954
  }
955
+ function omit(value, key) {
956
+ const { [key]: _dropped, ...rest } = value;
957
+ return rest;
958
+ }
959
+
960
+ // src/auth.ts
961
+ import { randomBytes } from "node:crypto";
962
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
963
+ import { dirname } from "node:path";
964
+ var ANON_JOIN_PEER = "__join__";
965
+ var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
966
+ function loadTokenFile(file) {
967
+ let parsed;
968
+ try {
969
+ parsed = JSON.parse(readFileSync(file, "utf8"));
970
+ } catch (error) {
971
+ throw new Error(`auth token file unreadable: ${file} (${error.message})`);
972
+ }
973
+ const rows = parsed?.tokens;
974
+ if (!Array.isArray(rows)) throw new Error(`auth token file ${file}: expected {"tokens":[...]}`);
975
+ const table = /* @__PURE__ */ new Map();
976
+ const peers = /* @__PURE__ */ new Set();
977
+ rows.forEach((row, index) => {
978
+ const msg = (why) => `auth token file ${file}: entry #${index + 1} ${why}`;
979
+ if (typeof row !== "object" || row === null) throw new Error(msg("is not an object"));
980
+ const { token, peer, role, owner } = row;
981
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
982
+ throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
983
+ }
984
+ if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
985
+ throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
986
+ }
987
+ if (role !== "agent" && role !== "manager") {
988
+ throw new Error(msg(`has an invalid role '${String(role)}' (expected 'agent' | 'manager')`));
989
+ }
990
+ if (owner !== void 0 && typeof owner !== "string") throw new Error(msg("has a non-string owner"));
991
+ if (table.has(token)) throw new Error(msg("duplicates a token"));
992
+ if (peers.has(peer)) {
993
+ throw new Error(msg(`maps a second token to peer '${peer}' (two tokens sharing a peer share its mailbox)`));
994
+ }
995
+ table.set(token, { peer, role, ...typeof owner === "string" && owner !== "" ? { owner } : {} });
996
+ peers.add(peer);
997
+ });
998
+ if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
999
+ return table;
1000
+ }
1001
+ function watchTokenFile(file, log2) {
1002
+ let table = loadTokenFile(file);
1003
+ const reload = () => {
1004
+ try {
1005
+ const next = loadTokenFile(file);
1006
+ table = next;
1007
+ log2.info(`auth tokens reloaded: ${table.size} entr(ies)`);
1008
+ } catch (error) {
1009
+ log2.warn(`auth token reload ignored, keeping previous table: ${error.message}`);
1010
+ }
1011
+ };
1012
+ log2.info(`auth tokens loaded: ${table.size} entr(ies) from ${file}`);
1013
+ let mtime = statMtime(file);
1014
+ const timer = setInterval(() => {
1015
+ const current = statMtime(file);
1016
+ if (current === mtime) return;
1017
+ mtime = current;
1018
+ reload();
1019
+ }, 2e3);
1020
+ timer.unref?.();
1021
+ return { authenticate: (token) => token === void 0 ? void 0 : table.get(token), reload };
1022
+ }
1023
+ function statMtime(file) {
1024
+ try {
1025
+ return statSync(file).mtimeMs;
1026
+ } catch {
1027
+ return 0;
1028
+ }
1029
+ }
1030
+ function bearerFromHeaders(headers) {
1031
+ const raw = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization;
1032
+ if (raw === void 0) return void 0;
1033
+ const match = /^Bearer\s+(.+)$/.exec(raw.trim());
1034
+ return match === null ? void 0 : match[1];
1035
+ }
1036
+ function generateToken() {
1037
+ return randomBytes(24).toString("base64url");
1038
+ }
1039
+ function readTokens(file) {
1040
+ if (!existsSync(file)) return [];
1041
+ try {
1042
+ return [...loadTokenFile(file).entries()].map(([token, id]) => ({
1043
+ token,
1044
+ peer: id.peer,
1045
+ role: id.role,
1046
+ ...id.owner !== void 0 ? { owner: id.owner } : {}
1047
+ }));
1048
+ } catch (error) {
1049
+ try {
1050
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1051
+ if (Array.isArray(parsed.tokens) && parsed.tokens.length === 0) return [];
1052
+ } catch {
1053
+ }
1054
+ throw error;
1055
+ }
1056
+ }
1057
+ function writeTokenFile(file, rows) {
1058
+ mkdirSync(dirname(file), { recursive: true });
1059
+ const tmp = `${file}.tmp`;
1060
+ writeFileSync(tmp, JSON.stringify({ tokens: rows }, null, 2) + "\n", "utf8");
1061
+ renameSync(tmp, file);
1062
+ }
1063
+ function addToken(file, row) {
1064
+ const token = row.token ?? generateToken();
1065
+ if (!TOKEN_PATTERN.test(token)) {
1066
+ throw new Error(`invalid token provided (expect 16-128 chars of [A-Za-z0-9._:-])`);
1067
+ }
1068
+ const peers = new Set(readTokens(file).map((t) => t.peer));
1069
+ if (peers.has(row.peer)) {
1070
+ throw new Error(`peer '${row.peer}' already has a token \u2014 remove it first to rotate (see \`auth remove\`)`);
1071
+ }
1072
+ const owner = row.owner !== void 0 && row.owner !== "" ? row.owner : void 0;
1073
+ const identity = { token, peer: row.peer, role: row.role, ...owner !== void 0 ? { owner } : {} };
1074
+ const next = [...readTokens(file), identity];
1075
+ loadTokenFileVoid(next);
1076
+ writeTokenFile(file, next);
1077
+ return identity;
1078
+ }
1079
+ function removeToken(file, peer) {
1080
+ const tokens = readTokens(file);
1081
+ const kept = tokens.filter((t) => t.peer !== peer);
1082
+ if (kept.length === tokens.length) return false;
1083
+ writeTokenFile(file, kept);
1084
+ return true;
1085
+ }
1086
+ function loadTokenFileVoid(rows) {
1087
+ const table = /* @__PURE__ */ new Map();
1088
+ const peers = /* @__PURE__ */ new Set();
1089
+ for (const { token, peer, role, owner } of rows) {
1090
+ if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
1091
+ if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
1092
+ if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
1093
+ if (table.has(token)) throw new Error("duplicate token");
1094
+ if (peers.has(peer)) throw new Error(`duplicate peer '${peer}'`);
1095
+ table.set(token, { peer, role, ...owner !== void 0 && owner !== "" ? { owner } : {} });
1096
+ peers.add(peer);
1097
+ }
1098
+ }
645
1099
 
646
1100
  // src/hub-tools.ts
647
1101
  var DEFAULT_WAIT_MS = 3e4;
648
1102
  function present(message) {
649
- const { ref, ...rest } = message;
1103
+ const { ref, channel, ...rest } = message;
650
1104
  return {
651
1105
  ...rest,
652
1106
  ...ref !== void 0 ? { ref } : {},
1107
+ ...channel !== void 0 ? { channel } : {},
653
1108
  content: decodeContent(message.kind, message.content)
654
1109
  };
655
1110
  }
@@ -657,13 +1112,26 @@ function sanitizePeerId(name) {
657
1112
  const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
658
1113
  return cleaned === "" ? "agent" : cleaned;
659
1114
  }
660
- function autoRegisterPeer(hub, registry, sessionId, clientName) {
1115
+ function sanitizeAlias(alias) {
1116
+ const cleaned = alias.trim();
1117
+ if (cleaned === "") throw new Error("alias must not be empty");
1118
+ if (cleaned.length > 64) throw new Error(`alias too long: ${cleaned.length} chars (max 64)`);
1119
+ if (/[\u0000-\u001f\u007f]/.test(cleaned)) throw new Error("alias must not contain control characters");
1120
+ return cleaned;
1121
+ }
1122
+ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
661
1123
  if (sessionId === void 0) return void 0;
662
1124
  const bound = registry.peerFor(sessionId);
663
1125
  if (bound !== void 0) return bound;
664
1126
  if (registry.isSuppressed(sessionId)) return void 0;
665
- const peerId = sanitizePeerId(clientName ?? "agent");
666
- if (!hub.has(peerId)) hub.register(peerId);
1127
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1128
+ const peerId = tokenPeer === ANON_JOIN_PEER ? `join-${sanitizePeerId(clientName ?? "agent")}-${Math.random().toString(36).slice(2, 6)}` : tokenPeer ?? sanitizePeerId(clientName ?? "agent");
1129
+ if (!hub.has(peerId)) {
1130
+ hub.register(peerId, {
1131
+ ...clientName !== void 0 ? { name: clientName } : {},
1132
+ ...clientVersion !== void 0 ? { version: clientVersion } : {}
1133
+ });
1134
+ }
667
1135
  registry.bindPeer(sessionId, peerId);
668
1136
  hub.touch(peerId);
669
1137
  return peerId;
@@ -676,6 +1144,38 @@ function livePeersFor(registry) {
676
1144
  }
677
1145
  return live;
678
1146
  }
1147
+ function rosterFor(hub, registry) {
1148
+ const status = hub.status(livePeersFor(registry));
1149
+ const peerIps = /* @__PURE__ */ new Map();
1150
+ const peerAnon = /* @__PURE__ */ new Map();
1151
+ for (const [session, peer] of registry.peerBindings) {
1152
+ const ip = registry.sessionIps.get(session);
1153
+ if (ip !== void 0) {
1154
+ const list = peerIps.get(peer) ?? [];
1155
+ if (!list.includes(ip)) list.push(ip);
1156
+ peerIps.set(peer, list);
1157
+ }
1158
+ const identity = registry.tokenFor(session);
1159
+ if (identity?.anonymous === true) peerAnon.set(peer, true);
1160
+ }
1161
+ return status.peers.map((peer) => {
1162
+ const profile = hub.profileOf(peer.id);
1163
+ const alias = profile?.alias;
1164
+ const clientName = profile?.clientName;
1165
+ const clientVersion = profile?.clientVersion;
1166
+ const ips = peerIps.get(peer.id);
1167
+ return {
1168
+ id: peer.id,
1169
+ ...alias !== void 0 ? { alias } : {},
1170
+ ...clientName !== void 0 ? { clientName } : {},
1171
+ ...clientVersion !== void 0 ? { clientVersion } : {},
1172
+ ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1173
+ ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
1174
+ connected: peer.connected,
1175
+ lastSeenMs: peer.lastSeenMs
1176
+ };
1177
+ });
1178
+ }
679
1179
  function hubTools(hub, registry, options) {
680
1180
  const schema = (properties, required = []) => ({
681
1181
  type: "object",
@@ -705,6 +1205,7 @@ function hubTools(hub, registry, options) {
705
1205
  from: message.from,
706
1206
  to: message.to,
707
1207
  kind: message.kind,
1208
+ ...message.channel !== void 0 ? { channel: message.channel } : {},
708
1209
  ts: message.ts
709
1210
  });
710
1211
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -719,6 +1220,13 @@ function hubTools(hub, registry, options) {
719
1220
  }
720
1221
  return herdr;
721
1222
  };
1223
+ const requireManager = (peer, action, sessionId) => {
1224
+ if (registry.tokenFor(sessionId)?.role === "manager") return;
1225
+ const managers = options.managerPeers ?? "all";
1226
+ if (managers !== "all" && !managers.has(peer)) {
1227
+ throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
1228
+ }
1229
+ };
722
1230
  const asStatuses = (value) => {
723
1231
  if (!Array.isArray(value)) return void 0;
724
1232
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
@@ -731,42 +1239,112 @@ function hubTools(hub, registry, options) {
731
1239
  return [
732
1240
  {
733
1241
  name: "bridge_register",
734
- 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.',
735
- inputSchema: schema({ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars.") }, ["peerId"]),
1242
+ 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.',
1243
+ inputSchema: schema({
1244
+ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars."),
1245
+ alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
1246
+ }, ["peerId"]),
736
1247
  handler: async (args, sessionId) => {
737
1248
  const peerId = String(args.peerId);
738
- if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
1249
+ if (!PEER_ID_PATTERN.test(peerId)) {
739
1250
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
740
1251
  }
1252
+ if (peerId === BROADCAST) {
1253
+ throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
1254
+ }
1255
+ const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
1256
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1257
+ if (tokenPeer !== void 0 && peerId !== tokenPeer) {
1258
+ throw new Error(`your identity is bound to your access token ('${tokenPeer}') \u2014 ask a hub manager to rename you`);
1259
+ }
741
1260
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
742
1261
  throw new Error(`peer already registered by another connection: ${peerId}`);
743
1262
  }
744
1263
  const current = registry.peerFor(sessionId);
745
1264
  if (current !== void 0 && current !== peerId) {
746
- registry.unbindPeer(sessionId);
747
- if (registry.attachedCount(current) === 0) hub.unregister(current);
1265
+ if (registry.attachedCount(current) === 1) {
1266
+ hub.renamePeer(current, peerId);
1267
+ registry.rebindPeerId(current, peerId);
1268
+ } else {
1269
+ registry.unbindPeer(sessionId);
1270
+ if (registry.attachedCount(current) === 0) hub.unregister(current);
1271
+ }
748
1272
  }
749
- registry.bindPeer(sessionId, peerId);
750
1273
  if (!hub.has(peerId)) hub.register(peerId);
1274
+ registry.bindPeer(sessionId, peerId);
751
1275
  registry.clearSuppress(sessionId);
752
1276
  hub.touch(peerId);
753
- return { ok: true, peerId, peers: hub.peers() };
1277
+ if (alias !== void 0) hub.setAlias(peerId, alias);
1278
+ const finalAlias = hub.profileOf(peerId)?.alias;
1279
+ return { ok: true, peerId, ...finalAlias !== void 0 ? { alias: finalAlias } : {}, peers: hub.peers() };
754
1280
  }
755
1281
  },
756
1282
  {
757
1283
  name: "bridge_unregister",
758
- 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.",
759
- inputSchema: schema({}),
760
- handler: async (_args, sessionId) => {
761
- const peer = registry.peerFor(sessionId);
762
- if (peer !== void 0) {
763
- registry.unbindPeer(sessionId);
764
- if (registry.attachedCount(peer) === 0) hub.unregister(peer);
1284
+ 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.",
1285
+ inputSchema: schema({
1286
+ peer: optStr("Kick this peer instead of yourself. Requires manager rights (hub managerPeers).")
1287
+ }),
1288
+ handler: async (args, sessionId) => {
1289
+ if (args.peer === void 0) {
1290
+ const peer = registry.peerFor(sessionId);
1291
+ if (peer !== void 0) {
1292
+ registry.unbindPeer(sessionId);
1293
+ if (registry.attachedCount(peer) === 0) hub.unregister(peer);
1294
+ }
1295
+ registry.suppressAuto(sessionId);
1296
+ return { ok: true, peerId: peer ?? null };
1297
+ }
1298
+ const caller = registry.peerFor(sessionId);
1299
+ if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
1300
+ const target = String(args.peer);
1301
+ requireManager(caller, `kicking '${target}'`, sessionId);
1302
+ if (!hub.has(target)) {
1303
+ return { ok: true, peerId: null, kicked: false };
765
1304
  }
766
- registry.suppressAuto(sessionId);
767
- return { ok: true, peerId: peer ?? null };
1305
+ hub.unregister(target);
1306
+ const detached = registry.unbindPeerId(target, { suppress: true });
1307
+ return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
768
1308
  }
769
1309
  },
1310
+ {
1311
+ name: "bridge_rename",
1312
+ 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).",
1313
+ inputSchema: schema({
1314
+ alias: optStr("New display name (1-64 chars, spaces/CJK allowed; empty string clears back to no alias)."),
1315
+ peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
1316
+ peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
1317
+ }),
1318
+ handler: wrap(true, async (args, peer, sessionId) => {
1319
+ const hasAlias = args.alias !== void 0;
1320
+ const hasPeerId = args.peerId !== void 0;
1321
+ if (!hasAlias && !hasPeerId) {
1322
+ throw new Error("nothing to rename: pass alias and/or peerId");
1323
+ }
1324
+ let target = args.peer === void 0 ? peer : String(args.peer);
1325
+ if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
1326
+ let previousId;
1327
+ if (hasPeerId) {
1328
+ if (args.peer === void 0) {
1329
+ throw new Error("peerId requires peer \u2014 to change your own id, call bridge_register(newId)");
1330
+ }
1331
+ const newId = String(args.peerId);
1332
+ if (!PEER_ID_PATTERN.test(newId)) {
1333
+ throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
1334
+ }
1335
+ previousId = target;
1336
+ hub.renamePeer(target, newId);
1337
+ registry.rebindPeerId(target, newId);
1338
+ target = newId;
1339
+ }
1340
+ if (hasAlias) {
1341
+ const alias = String(args.alias).trim();
1342
+ hub.setAlias(target, alias === "" ? void 0 : sanitizeAlias(alias));
1343
+ }
1344
+ const applied = hub.profileOf(target)?.alias;
1345
+ return { ok: true, peerId: target, ...previousId !== void 0 ? { previousId } : {}, ...applied !== void 0 ? { alias: applied } : {} };
1346
+ })
1347
+ },
770
1348
  {
771
1349
  name: "bridge_chat",
772
1350
  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.',
@@ -837,23 +1415,106 @@ function hubTools(hub, registry, options) {
837
1415
  },
838
1416
  {
839
1417
  name: "bridge_peers",
840
- description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
1418
+ 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.",
841
1419
  inputSchema: schema({}),
842
- handler: wrap(true, async () => {
843
- const status = hub.status(livePeersFor(registry));
844
- return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
845
- })
1420
+ handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
846
1421
  },
847
1422
  {
848
1423
  name: "bridge_history",
849
- 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.',
850
- inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself.'), limit: int("How many messages to return (default 20).") }),
851
- handler: wrap(true, async (args, peer) => {
1424
+ 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).',
1425
+ 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).") }),
1426
+ handler: wrap(true, async (args, peer, sessionId) => {
852
1427
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
853
- const messages = args.peer === "all" ? hub.historyAll(limit) : hub.history(args.peer === void 0 ? peer : String(args.peer), limit);
1428
+ const target = args.peer === void 0 ? peer : String(args.peer);
1429
+ if (target !== peer) {
1430
+ requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
1431
+ }
1432
+ if (args.channel !== void 0) {
1433
+ const groupId = String(args.channel);
1434
+ const group = hub.groupOf(groupId);
1435
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1436
+ if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
1437
+ return { messages: hub.historyChannel(groupId, limit).map(present) };
1438
+ }
1439
+ const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
854
1440
  return { messages: messages.map(present) };
855
1441
  })
856
1442
  },
1443
+ // ---- group tools (group channel) ---------------------------------------------
1444
+ // A group routes one message to several members; the message carries
1445
+ // channel = group id. Offline members are skipped (they read history) —
1446
+ // unlike direct sends, which fail on unknown recipients.
1447
+ {
1448
+ name: "bridge_group_create",
1449
+ 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).",
1450
+ inputSchema: schema(
1451
+ {
1452
+ group: str("Unique group id: letters/digits/._:- , 1-64 chars."),
1453
+ name: optStr("Optional display name."),
1454
+ members: { type: "array", items: { type: "string" }, description: "Member peerIds (you are added automatically; duplicates ignored)." }
1455
+ },
1456
+ ["group"]
1457
+ ),
1458
+ handler: wrap(true, async (args, peer) => {
1459
+ const groupId = String(args.group);
1460
+ const members = Array.isArray(args.members) ? args.members.map(String) : [];
1461
+ const group = hub.createGroup(groupId, members, peer, args.name === void 0 ? void 0 : String(args.name));
1462
+ return { ok: true, group: { id: group.id, ...group.name !== void 0 ? { name: group.name } : {}, members: group.members, createdBy: group.createdBy } };
1463
+ })
1464
+ },
1465
+ {
1466
+ name: "bridge_group_send",
1467
+ 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.",
1468
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), message: str("The message text.") }, ["group", "message"]),
1469
+ handler: wrap(true, async (args, peer) => receipt(hub.sendToGroup(peer, String(args.group), String(args.message))))
1470
+ },
1471
+ {
1472
+ name: "bridge_group_list",
1473
+ description: "List every group channel with its members and creator.",
1474
+ inputSchema: schema({}),
1475
+ handler: wrap(true, async () => ({ groups: hub.allGroups() }))
1476
+ },
1477
+ {
1478
+ name: "bridge_group_delete",
1479
+ description: "Delete a group channel (history rows are kept). The creator or a hub manager may delete.",
1480
+ inputSchema: schema({ group: str("Group id to delete.") }, ["group"]),
1481
+ handler: wrap(true, async (args, peer, sessionId) => {
1482
+ const groupId = String(args.group);
1483
+ const group = hub.groupOf(groupId);
1484
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1485
+ if (group.createdBy !== peer) requireManager(peer, `deleting group '${groupId}'`, sessionId);
1486
+ hub.deleteGroup(groupId);
1487
+ return { ok: true, group: groupId };
1488
+ })
1489
+ },
1490
+ {
1491
+ name: "bridge_group_add_member",
1492
+ 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.",
1493
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId of the new member.") }, ["group", "member"]),
1494
+ handler: wrap(true, async (args, peer, sessionId) => {
1495
+ const groupId = String(args.group);
1496
+ const member = String(args.member);
1497
+ const group = hub.groupOf(groupId);
1498
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1499
+ if (group.createdBy !== peer) requireManager(peer, `adding a member to group '${groupId}'`, sessionId);
1500
+ const updated = hub.addGroupMember(groupId, member);
1501
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1502
+ })
1503
+ },
1504
+ {
1505
+ name: "bridge_group_remove_member",
1506
+ 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.",
1507
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId to remove.") }, ["group", "member"]),
1508
+ handler: wrap(true, async (args, peer, sessionId) => {
1509
+ const groupId = String(args.group);
1510
+ const member = String(args.member);
1511
+ const group = hub.groupOf(groupId);
1512
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1513
+ if (group.createdBy !== peer) requireManager(peer, `removing a member from group '${groupId}'`, sessionId);
1514
+ const updated = hub.removeGroupMember(groupId, member);
1515
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1516
+ })
1517
+ },
857
1518
  // ---- herdr control tools ------------------------------------------
858
1519
  // These type into real agent terminals via the herdr runtime. They are
859
1520
  // gated by checkControl and documented as physical input: unlike
@@ -1056,6 +1717,16 @@ function hubTools(hub, registry, options) {
1056
1717
 
1057
1718
  // src/mcp-server.ts
1058
1719
  import { randomUUID as randomUUID2 } from "node:crypto";
1720
+ import { readFileSync as readFileSync2 } from "node:fs";
1721
+ var ADMIN_PATH = "/admin";
1722
+ var ADMIN_API_PATH = "/admin/api";
1723
+ var adminPageCache;
1724
+ function adminPage() {
1725
+ if (adminPageCache === void 0) {
1726
+ adminPageCache = readFileSync2(new URL("../assets/admin.html", import.meta.url), "utf8");
1727
+ }
1728
+ return adminPageCache;
1729
+ }
1059
1730
  var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1060
1731
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
1061
1732
  var MAX_BODY_BYTES = 1048576;
@@ -1096,15 +1767,23 @@ var SessionRegistry = class {
1096
1767
  }
1097
1768
  /** Client-reported name per session (from the initialize clientInfo). */
1098
1769
  clientNames = /* @__PURE__ */ new Map();
1099
- /** Remember the client name reported by a session (on initialize). */
1100
- noteClient(sessionId, name) {
1770
+ /** Client-reported version per session (undefined when not reported). */
1771
+ clientVersions = /* @__PURE__ */ new Map();
1772
+ /** Remember the client name/version reported by a session (on initialize). */
1773
+ noteClient(sessionId, name, version) {
1101
1774
  this.clientNames.set(sessionId, name);
1775
+ if (version !== void 0) this.clientVersions.set(sessionId, version);
1102
1776
  }
1103
1777
  /** Client-reported name for a session, if any. */
1104
1778
  clientName(sessionId) {
1105
1779
  if (sessionId === void 0) return void 0;
1106
1780
  return this.clientNames.get(sessionId);
1107
1781
  }
1782
+ /** Client-reported version for a session, if any. */
1783
+ clientVersion(sessionId) {
1784
+ if (sessionId === void 0) return void 0;
1785
+ return this.clientVersions.get(sessionId);
1786
+ }
1108
1787
  /** Sessions whose owner explicitly unregistered; auto-registration is
1109
1788
  * suppressed for them until an explicit `bridge_register`. */
1110
1789
  suppressedAuto = /* @__PURE__ */ new Set();
@@ -1120,8 +1799,24 @@ var SessionRegistry = class {
1120
1799
  isSuppressed(sessionId) {
1121
1800
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
1122
1801
  }
1802
+ /** Session → client IP (remote mode: the admin roster shows where a peer
1803
+ * connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
1804
+ sessionIps = /* @__PURE__ */ new Map();
1123
1805
  /** Sessions with a live SSE stream (server→client channel). */
1124
1806
  liveStreams = /* @__PURE__ */ new Set();
1807
+ /** Session → token identity (remote auth mode only; see src/auth.ts).
1808
+ * The TOKEN, not the client-reported name, decides the peer id, so two
1809
+ * users running the same client get distinct peers. */
1810
+ tokenBindings = /* @__PURE__ */ new Map();
1811
+ /** Bind a session to its token identity (at initialize / SSE open). */
1812
+ bindToken(sessionId, identity) {
1813
+ this.tokenBindings.set(sessionId, identity);
1814
+ }
1815
+ /** Token identity bound to a session, if remote auth is in use. */
1816
+ tokenFor(sessionId) {
1817
+ if (sessionId === void 0) return void 0;
1818
+ return this.tokenBindings.get(sessionId);
1819
+ }
1125
1820
  /** Mark a session's SSE stream open (server→client channel alive). */
1126
1821
  markSseOpen(sessionId) {
1127
1822
  this.liveStreams.add(sessionId);
@@ -1134,11 +1829,39 @@ var SessionRegistry = class {
1134
1829
  liveSessions() {
1135
1830
  return this.liveStreams;
1136
1831
  }
1137
- /** Drop every binding that points at `peerId` (used by the idle GC). */
1138
- unbindPeerId(peerId) {
1832
+ /** Drop every binding that points at `peerId` (used by the idle GC). With
1833
+ * `suppress`, the detached sessions also stop auto-re-registering — the
1834
+ * manager-kick path, so a kicked agent stays out until it explicitly
1835
+ * `bridge_register`s or reconnects with a fresh session. */
1836
+ unbindPeerId(peerId, options) {
1837
+ const detached = [];
1139
1838
  for (const [sessionId, bound] of this.peerBindings) {
1140
- if (bound === peerId) this.peerBindings.delete(sessionId);
1839
+ if (bound === peerId) {
1840
+ this.peerBindings.delete(sessionId);
1841
+ detached.push(sessionId);
1842
+ if (options?.suppress === true) this.suppressedAuto.add(sessionId);
1843
+ }
1844
+ }
1845
+ return detached;
1846
+ }
1847
+ /** Session ids currently attached to `peerId` (any binding direction). */
1848
+ sessionsForPeer(peerId) {
1849
+ const sessions = [];
1850
+ for (const [sessionId, bound] of this.peerBindings) {
1851
+ if (bound === peerId) sessions.push(sessionId);
1852
+ }
1853
+ return sessions;
1854
+ }
1855
+ /** Re-point every session bound to `oldId` at `newId` (true rename). */
1856
+ rebindPeerId(oldId, newId) {
1857
+ let count = 0;
1858
+ for (const [sessionId, bound] of this.peerBindings) {
1859
+ if (bound === oldId) {
1860
+ this.peerBindings.set(sessionId, newId);
1861
+ count++;
1862
+ }
1141
1863
  }
1864
+ return count;
1142
1865
  }
1143
1866
  /** How many sessions are currently attached to `peerId`. */
1144
1867
  attachedCount(peerId) {
@@ -1152,18 +1875,29 @@ var SessionRegistry = class {
1152
1875
  var McpStreamableHttpServer = class {
1153
1876
  constructor(tools, info, registry, log2 = () => {
1154
1877
  }, onInitialize = () => {
1155
- }) {
1878
+ }, authenticate, adminApi) {
1156
1879
  this.tools = tools;
1157
1880
  this.info = info;
1158
1881
  this.registry = registry;
1159
1882
  this.log = log2;
1160
1883
  this.onInitialize = onInitialize;
1884
+ this.authenticate = authenticate;
1885
+ this.adminApi = adminApi;
1161
1886
  }
1162
1887
  sseStreams = /* @__PURE__ */ new Map();
1163
1888
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
1164
1889
  attach(server, path2) {
1165
1890
  server.on("request", (req, res) => {
1166
1891
  const url = new URL(req.url ?? "/", "http://localhost");
1892
+ if (url.pathname === ADMIN_PATH && req.method === "GET") {
1893
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
1894
+ res.end(adminPage());
1895
+ return;
1896
+ }
1897
+ if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
1898
+ void this.handleAdminApi(req, res, url);
1899
+ return;
1900
+ }
1167
1901
  if (url.pathname !== path2) {
1168
1902
  res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
1169
1903
  return;
@@ -1188,8 +1922,39 @@ var McpStreamableHttpServer = class {
1188
1922
  for (const stream of this.sseStreams.values()) stream.end();
1189
1923
  this.sseStreams.clear();
1190
1924
  }
1925
+ /** Push a JSON-RPC notification to every open SSE stream (roster events are
1926
+ * public — any peer could poll bridge_peers for the same information).
1927
+ * Write failures drop the dead stream silently; SSE is best-effort and
1928
+ * long-poll (bridge_wait) remains the reliable delivery path. */
1929
+ notifyAll(method, params) {
1930
+ this.notify([...this.sseStreams.keys()], method, params);
1931
+ }
1932
+ /** Push a JSON-RPC notification to the SSE streams of specific sessions. */
1933
+ notify(sessionIds, method, params) {
1934
+ const frame = `event: message
1935
+ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1936
+
1937
+ `;
1938
+ for (const sessionId of sessionIds) {
1939
+ const stream = this.sseStreams.get(sessionId);
1940
+ if (stream === void 0) continue;
1941
+ try {
1942
+ stream.write(frame);
1943
+ } catch {
1944
+ this.sseStreams.delete(sessionId);
1945
+ this.registry.markSseClosed(sessionId);
1946
+ }
1947
+ }
1948
+ }
1191
1949
  handleGet(req, res) {
1950
+ if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
1951
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1952
+ res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
1953
+ return;
1954
+ }
1192
1955
  const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
1956
+ const identity = this.authenticate?.(req);
1957
+ if (identity !== void 0) this.registry.bindToken(sessionId, identity);
1193
1958
  res.writeHead(200, {
1194
1959
  ...corsHeaders(),
1195
1960
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -1205,7 +1970,79 @@ var McpStreamableHttpServer = class {
1205
1970
  this.registry.markSseClosed(sessionId);
1206
1971
  });
1207
1972
  }
1973
+ /** Reject a request without a valid token (remote auth mode). */
1974
+ unauthorized(res, id, why) {
1975
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1976
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
1977
+ }
1978
+ /** Management API for the bundled admin page: issue/revoke tokens and list
1979
+ * them. Like everything else, each call is authenticated by `authenticate`
1980
+ * and must resolve to a MANAGER role — an agent token gets 403. */
1981
+ async handleAdminApi(req, res, url) {
1982
+ const respond = (status, body) => {
1983
+ res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1984
+ res.end(JSON.stringify(body));
1985
+ };
1986
+ const identity = this.authenticate?.(req);
1987
+ if (identity === void 0) {
1988
+ respond(401, { error: "unauthorized: manager token required" });
1989
+ return;
1990
+ }
1991
+ if (identity.role !== "manager") {
1992
+ respond(403, { error: "forbidden: this API needs a manager-role token" });
1993
+ return;
1994
+ }
1995
+ const api = this.adminApi;
1996
+ const seg = url.pathname.slice(ADMIN_API_PATH.length).replace(/^\/+|\/+$/g, "").split("/");
1997
+ if (req.method === "GET" && seg[0] === "tokens") {
1998
+ respond(200, { tokens: api.listTokens(url.searchParams.get("reveal") === "1") });
1999
+ return;
2000
+ }
2001
+ if (req.method === "POST" && seg[0] === "tokens" && seg[1] === "add") {
2002
+ let input;
2003
+ try {
2004
+ input = JSON.parse((await readBody(req)).toString("utf8"));
2005
+ } catch (error) {
2006
+ respond(400, { error: `bad json: ${error.message}` });
2007
+ return;
2008
+ }
2009
+ try {
2010
+ const created = api.addToken({
2011
+ peer: String(input.peer ?? ""),
2012
+ role: String(input.role ?? "agent"),
2013
+ owner: input.owner === void 0 ? void 0 : String(input.owner)
2014
+ });
2015
+ respond(200, { created });
2016
+ } catch (error) {
2017
+ respond(400, { error: error.message });
2018
+ }
2019
+ return;
2020
+ }
2021
+ if (req.method === "DELETE" && seg[0] === "tokens" && seg[1] === "remove" && seg[2] !== void 0) {
2022
+ respond(200, { removed: api.removeToken(decodeURIComponent(seg[2])) });
2023
+ return;
2024
+ }
2025
+ respond(404, { error: "not found" });
2026
+ }
1208
2027
  async handlePost(req, res) {
2028
+ let authIdentity;
2029
+ if (this.authenticate !== void 0) {
2030
+ authIdentity = this.authenticate(req);
2031
+ if (authIdentity === void 0) {
2032
+ this.unauthorized(res, null, "missing or invalid bearer token");
2033
+ return;
2034
+ }
2035
+ const existingSession = this.registry.sessionIdFor(req);
2036
+ const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
2037
+ const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
2038
+ if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
2039
+ this.unauthorized(res, null, "token does not match this session");
2040
+ return;
2041
+ }
2042
+ if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
2043
+ this.registry.bindToken(existingSession, authIdentity);
2044
+ }
2045
+ }
1209
2046
  let body;
1210
2047
  try {
1211
2048
  body = await readBody(req);
@@ -1226,12 +2063,13 @@ var McpStreamableHttpServer = class {
1226
2063
  }
1227
2064
  const id = message.id ?? null;
1228
2065
  const sessionId = this.registry.sessionIdFor(req);
2066
+ if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
1229
2067
  if (id === null) {
1230
2068
  res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
1231
2069
  return;
1232
2070
  }
1233
2071
  try {
1234
- const { result, extraHeaders } = await this.dispatch(message, sessionId);
2072
+ const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
1235
2073
  res.writeHead(200, {
1236
2074
  ...corsHeaders(),
1237
2075
  "Content-Type": "application/json; charset=utf-8",
@@ -1244,17 +2082,21 @@ var McpStreamableHttpServer = class {
1244
2082
  this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
1245
2083
  }
1246
2084
  }
1247
- async dispatch(message, sessionId) {
2085
+ async dispatch(message, sessionId, authIdentity) {
1248
2086
  const method = message.method ?? "";
1249
2087
  switch (method) {
1250
2088
  case "initialize": {
1251
2089
  const newSessionId = this.registry.ensureSession(sessionId);
1252
2090
  const clientInfo = message.params?.clientInfo;
1253
2091
  const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
2092
+ const clientVersion = typeof clientInfo?.version === "string" && clientInfo.version !== "" ? clientInfo.version : void 0;
1254
2093
  if (clientName !== void 0) {
1255
- this.registry.noteClient(newSessionId, clientName);
2094
+ this.registry.noteClient(newSessionId, clientName, clientVersion);
1256
2095
  }
1257
- this.onInitialize(newSessionId, clientName);
2096
+ if (authIdentity !== void 0) {
2097
+ this.registry.bindToken(newSessionId, authIdentity);
2098
+ }
2099
+ this.onInitialize(newSessionId, clientName, clientVersion);
1258
2100
  const requested = message.params?.protocolVersion;
1259
2101
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
1260
2102
  return {
@@ -1317,6 +2159,14 @@ function rpcError(code, message) {
1317
2159
  function messageProtocolVersion(req) {
1318
2160
  return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
1319
2161
  }
2162
+ function clientIp(req) {
2163
+ const forwarded = req.headers["x-forwarded-for"];
2164
+ const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
2165
+ if (typeof raw === "string" && raw.trim() !== "") {
2166
+ return raw.split(",")[0].trim();
2167
+ }
2168
+ return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
2169
+ }
1320
2170
  function corsHeaders() {
1321
2171
  return {
1322
2172
  "Access-Control-Allow-Origin": "*",
@@ -1342,9 +2192,149 @@ function readBody(req) {
1342
2192
  });
1343
2193
  }
1344
2194
 
2195
+ // src/persist.ts
2196
+ import { DatabaseSync } from "node:sqlite";
2197
+ var KIND_NAMES = ["chat", "task", "notice", "ack"];
2198
+ function messageToRow(message) {
2199
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
2200
+ }
2201
+ function rowToMessage(row) {
2202
+ const id = row.id;
2203
+ const from = row.from_peer;
2204
+ const to = row.to_peer;
2205
+ const kind = row.kind;
2206
+ const content = row.content;
2207
+ const ts = row.ts;
2208
+ if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof content !== "string" || typeof ts !== "number") return void 0;
2209
+ if (!KIND_NAMES.includes(kind)) return void 0;
2210
+ return {
2211
+ id,
2212
+ from,
2213
+ to,
2214
+ kind,
2215
+ content,
2216
+ ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2217
+ ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2218
+ ts
2219
+ };
2220
+ }
2221
+ var SQLiteStateStore = class {
2222
+ constructor(file, historyLimit, log2) {
2223
+ this.historyLimit = historyLimit;
2224
+ this.db = new DatabaseSync(file);
2225
+ this.db.exec("PRAGMA journal_mode = WAL");
2226
+ this.db.exec(`
2227
+ CREATE TABLE IF NOT EXISTS profiles (
2228
+ id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
2229
+ );
2230
+ CREATE TABLE IF NOT EXISTS messages (
2231
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2232
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2233
+ );
2234
+ CREATE TABLE IF NOT EXISTS mailboxes (
2235
+ peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2236
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2237
+ );
2238
+ CREATE TABLE IF NOT EXISTS groups (
2239
+ id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2240
+ );
2241
+ `);
2242
+ log2.warn(`sqlite state open: ${file}`);
2243
+ }
2244
+ db;
2245
+ buffer = [];
2246
+ /** Buffer one history message (called per message via the hub hook). */
2247
+ appendMessage(message) {
2248
+ this.buffer.push(message);
2249
+ }
2250
+ /** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
2251
+ flush(snapshot) {
2252
+ const insert = this.db.prepare(
2253
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
2254
+ );
2255
+ for (const message of this.buffer.splice(0, this.buffer.length)) {
2256
+ insert.run(...messageToRow(message));
2257
+ }
2258
+ this.db.exec("DELETE FROM mailboxes");
2259
+ const insertMail = this.db.prepare(
2260
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2261
+ );
2262
+ for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2263
+ for (const message of queue) insertMail.run(peer, ...messageToRow(message));
2264
+ }
2265
+ this.db.exec("DELETE FROM profiles");
2266
+ const insertProfile = this.db.prepare(
2267
+ "INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
2268
+ );
2269
+ for (const profile of snapshot.profiles) {
2270
+ insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
2271
+ }
2272
+ this.db.exec("DELETE FROM groups");
2273
+ const insertGroup = this.db.prepare(
2274
+ "INSERT INTO groups (id, name, members, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
2275
+ );
2276
+ for (const group of snapshot.groups) {
2277
+ insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
2278
+ }
2279
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
2280
+ const total = Number(row.n);
2281
+ if (total > this.historyLimit * 2) {
2282
+ this.db.exec(`DELETE FROM messages WHERE seq <= (SELECT MAX(seq) FROM messages) - ${this.historyLimit}`);
2283
+ }
2284
+ }
2285
+ /** Read everything persisted so far (boot restore). */
2286
+ load() {
2287
+ const profiles = [];
2288
+ for (const row of this.db.prepare("SELECT * FROM profiles").all()) {
2289
+ if (typeof row.id !== "string") continue;
2290
+ profiles.push({
2291
+ id: row.id,
2292
+ ...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
2293
+ ...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
2294
+ ...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
2295
+ registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
2296
+ });
2297
+ }
2298
+ const messages = [];
2299
+ for (const row of this.db.prepare("SELECT * FROM messages ORDER BY seq").all()) {
2300
+ const message = rowToMessage(row);
2301
+ if (message !== void 0) messages.push(message);
2302
+ }
2303
+ const mailboxes = {};
2304
+ for (const row of this.db.prepare("SELECT * FROM mailboxes ORDER BY seq").all()) {
2305
+ if (typeof row.peer !== "string") continue;
2306
+ const message = rowToMessage(row);
2307
+ if (message === void 0) continue;
2308
+ (mailboxes[row.peer] ??= []).push(message);
2309
+ }
2310
+ const groups = [];
2311
+ for (const row of this.db.prepare("SELECT * FROM groups").all()) {
2312
+ if (typeof row.id !== "string" || typeof row.members !== "string") continue;
2313
+ try {
2314
+ const members = JSON.parse(row.members);
2315
+ if (!Array.isArray(members) || members.length === 0) continue;
2316
+ groups.push({
2317
+ id: row.id,
2318
+ members: members.filter((m) => typeof m === "string"),
2319
+ createdBy: typeof row.created_by === "string" ? row.created_by : "",
2320
+ createdAt: typeof row.created_at === "number" ? row.created_at : Date.now(),
2321
+ ...typeof row.name === "string" && row.name !== "" ? { name: row.name } : {}
2322
+ });
2323
+ } catch {
2324
+ }
2325
+ }
2326
+ return { profiles, messages, mailboxes, groups };
2327
+ }
2328
+ /** Final flush + close (hub shutdown). */
2329
+ close(snapshot) {
2330
+ this.flush(snapshot);
2331
+ this.db.close();
2332
+ }
2333
+ };
2334
+
1345
2335
  // src/index.ts
1346
2336
  var SERVER_NAME = "agent-comm-hub";
1347
- var SERVER_VERSION = "0.5.0";
2337
+ var SERVER_VERSION = "0.7.0";
1348
2338
  var DEFAULT_HOST = "127.0.0.1";
1349
2339
  var DEFAULT_PORT = 18764;
1350
2340
  var DEFAULT_PATH = "/mcp";
@@ -1360,11 +2350,107 @@ var DEFAULT_CONFIG = {
1360
2350
  waitTimeoutMs: 6e4,
1361
2351
  defaultWaitMs: 3e4,
1362
2352
  connectedWindowMs: 3e4,
1363
- peerIdleTimeoutMs: 6e5
2353
+ peerIdleTimeoutMs: 6e5,
2354
+ // The companion desktop app (and `agent-comm-hub status` probe) registers
2355
+ // as `agent-hub-cli`; it is the natural roster manager. Agents themselves
2356
+ // are NOT managers — they keep chatting, a GUI manages.
2357
+ managerPeers: ["agent-hub-cli"]
1364
2358
  };
2359
+ function authTokensAdmin(file, reloadAuth) {
2360
+ if (file === void 0) return void 0;
2361
+ return {
2362
+ listTokens: (reveal) => readTokens(file).map((r) => ({
2363
+ token: reveal ? r.token : `${r.token.slice(0, 6)}\u2026${r.token.slice(-4)}`,
2364
+ peer: r.peer,
2365
+ role: r.role,
2366
+ ...r.owner !== void 0 ? { owner: r.owner } : {}
2367
+ })),
2368
+ addToken: (input) => {
2369
+ const row = addToken(file, { peer: input.peer, role: input.role, owner: input.owner });
2370
+ reloadAuth?.();
2371
+ return { token: row.token, peer: row.peer, role: row.role, ...row.owner !== void 0 ? { owner: row.owner } : {} };
2372
+ },
2373
+ removeToken: (peer) => {
2374
+ const ok = removeToken(file, peer);
2375
+ if (ok) reloadAuth?.();
2376
+ return ok;
2377
+ },
2378
+ reloadAuth: () => reloadAuth?.()
2379
+ };
2380
+ }
2381
+ function writeRosterFile(file, profiles) {
2382
+ mkdirSync2(dirname2(file), { recursive: true });
2383
+ const tmp = `${file}.tmp`;
2384
+ writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2385
+ renameSync2(tmp, file);
2386
+ }
1365
2387
  function startHub(config = {}, log2 = console) {
1366
2388
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1367
2389
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
2390
+ let mcp;
2391
+ let dbStore;
2392
+ const pushRoster = () => {
2393
+ mcp.notifyAll("notifications/message", {
2394
+ level: "info",
2395
+ logger: "bridge",
2396
+ data: { event: "peers_changed", peers: rosterFor(hub, registry) }
2397
+ });
2398
+ };
2399
+ const pushQueued = (message, target) => {
2400
+ mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
2401
+ level: "info",
2402
+ logger: "bridge",
2403
+ data: { event: "message", message: present(message) }
2404
+ });
2405
+ };
2406
+ const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
2407
+ const pushRelay = (message) => {
2408
+ const managers = [];
2409
+ for (const sessionId of registry.liveSessions()) {
2410
+ const peer = registry.peerFor(sessionId);
2411
+ if (peer === void 0) continue;
2412
+ if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
2413
+ managers.push(sessionId);
2414
+ }
2415
+ }
2416
+ if (managers.length > 0) {
2417
+ mcp.notify(managers, "notifications/message", {
2418
+ level: "info",
2419
+ logger: "bridge",
2420
+ data: { event: "message", message: present(message) }
2421
+ });
2422
+ }
2423
+ };
2424
+ let saveTimer;
2425
+ let rosterDirty = false;
2426
+ let dbDirty = false;
2427
+ const flushSaves = () => {
2428
+ if (rosterDirty && resolved.stateFile !== void 0) {
2429
+ rosterDirty = false;
2430
+ try {
2431
+ writeRosterFile(resolved.stateFile, hub.exportProfiles());
2432
+ } catch (error) {
2433
+ log2.warn(`roster save failed: ${error.message}`);
2434
+ }
2435
+ }
2436
+ if (dbDirty && dbStore !== void 0) {
2437
+ dbDirty = false;
2438
+ try {
2439
+ dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2440
+ } catch (error) {
2441
+ log2.warn(`sqlite flush failed: ${error.message}`);
2442
+ }
2443
+ }
2444
+ };
2445
+ const scheduleSave = () => {
2446
+ if (resolved.stateFile === void 0 && dbStore === void 0) return;
2447
+ if (saveTimer !== void 0) return;
2448
+ saveTimer = setTimeout(() => {
2449
+ saveTimer = void 0;
2450
+ flushSaves();
2451
+ }, 500);
2452
+ saveTimer.unref?.();
2453
+ };
1368
2454
  const hub = new AgentHub({
1369
2455
  maxQueue: resolved.maxQueue,
1370
2456
  historyLimit: resolved.historyLimit,
@@ -1372,10 +2458,94 @@ function startHub(config = {}, log2 = console) {
1372
2458
  connectedWindowMs: resolved.connectedWindowMs,
1373
2459
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1374
2460
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
2461
+ // Remote persistence: buffer every history append; re-snapshot when a
2462
+ // mailbox is consumed so drained messages cannot resurrect on restart.
2463
+ // Also relay every message to manager sessions (the relay stream view) —
2464
+ // best-effort; a notification failure must never break routing.
2465
+ onMessage: (message) => {
2466
+ if (dbStore !== void 0) {
2467
+ dbStore.appendMessage(message);
2468
+ dbDirty = true;
2469
+ }
2470
+ try {
2471
+ pushRelay(message);
2472
+ } catch (error) {
2473
+ log2.warn(`relay notification failed: ${error.message}`);
2474
+ }
2475
+ },
2476
+ onMailboxesChanged: () => {
2477
+ if (dbStore === void 0) return;
2478
+ dbDirty = true;
2479
+ },
2480
+ onGroupsChanged: () => {
2481
+ if (dbStore === void 0) return;
2482
+ dbDirty = true;
2483
+ scheduleSave();
2484
+ },
1375
2485
  // The idle GC must never evict a peer whose session has a live SSE channel.
1376
- isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
2486
+ isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
2487
+ // SSE event push: roster changes and queued-mail hints. Best-effort — a
2488
+ // notification failure must never break routing, so swallow and log.
2489
+ onPeersChanged: () => {
2490
+ try {
2491
+ pushRoster();
2492
+ } catch (error) {
2493
+ log2.warn(`peers_changed notification failed: ${error.message}`);
2494
+ }
2495
+ rosterDirty = true;
2496
+ dbDirty = true;
2497
+ scheduleSave();
2498
+ },
2499
+ onQueued: (message, target) => {
2500
+ try {
2501
+ pushQueued(message, target);
2502
+ } catch (error) {
2503
+ log2.warn(`message notification failed: ${error.message}`);
2504
+ }
2505
+ }
1377
2506
  });
1378
2507
  const registry = new SessionRegistry();
2508
+ if (resolved.db !== void 0) {
2509
+ try {
2510
+ dbStore = new SQLiteStateStore(resolved.db, resolved.historyLimit, log2);
2511
+ const state = dbStore.load();
2512
+ const importedProfiles = hub.importProfiles(state.profiles);
2513
+ if (state.profiles.length === 0 && resolved.stateFile !== void 0) {
2514
+ try {
2515
+ const legacy = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
2516
+ const migrated = hub.importProfiles(legacy.profiles);
2517
+ if (migrated > 0) log2.info(`roster migrated: ${migrated} profile(s) from ${resolved.stateFile} into ${resolved.db}`);
2518
+ } catch (error) {
2519
+ if (error.code !== "ENOENT") {
2520
+ log2.warn(`legacy roster import failed: ${error.message}`);
2521
+ }
2522
+ }
2523
+ } else if (importedProfiles > 0) {
2524
+ log2.info(`roster restored: ${importedProfiles} profile(s) from ${resolved.db}`);
2525
+ }
2526
+ const restoredMessages = hub.importHistory(state.messages);
2527
+ const restoredQueued = hub.importMailboxes(state.mailboxes);
2528
+ const restoredGroups = hub.importGroups(state.groups);
2529
+ if (restoredMessages + restoredQueued + restoredGroups > 0) {
2530
+ log2.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
2531
+ dbDirty = true;
2532
+ }
2533
+ } catch (error) {
2534
+ dbStore = void 0;
2535
+ log2.warn(`sqlite state unavailable (${error.message}); running memory-only`);
2536
+ }
2537
+ }
2538
+ if (dbStore === void 0 && resolved.stateFile !== void 0) {
2539
+ try {
2540
+ const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
2541
+ const imported = hub.importProfiles(parsed.profiles);
2542
+ if (imported > 0) log2.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
2543
+ } catch (error) {
2544
+ if (error.code !== "ENOENT") {
2545
+ log2.warn(`roster load failed: ${error.message}`);
2546
+ }
2547
+ }
2548
+ }
1379
2549
  const herdr = new HerdrCtl({
1380
2550
  bin: resolved.herdrBin,
1381
2551
  baseArgs: resolved.herdrBaseArgs,
@@ -1383,24 +2553,58 @@ function startHub(config = {}, log2 = console) {
1383
2553
  socketPath: resolved.herdrSocketPath,
1384
2554
  sendRequest: resolved.herdrSendRequest
1385
2555
  });
1386
- const mcp = new McpStreamableHttpServer(
2556
+ let auth;
2557
+ let authReload;
2558
+ if (resolved.authTokens !== void 0) {
2559
+ const table = watchTokenFile(resolved.authTokens, {
2560
+ info: (message) => log2.info(message),
2561
+ warn: (message) => log2.warn(message)
2562
+ });
2563
+ authReload = table.reload;
2564
+ auth = (req) => {
2565
+ const presented = bearerFromHeaders(req.headers);
2566
+ if (presented !== void 0) return table.authenticate(presented);
2567
+ if (resolved.allowJoin === true) {
2568
+ return { peer: ANON_JOIN_PEER, role: "agent", anonymous: true };
2569
+ }
2570
+ return void 0;
2571
+ };
2572
+ } else if (resolved.allowJoin === true) {
2573
+ auth = (req) => ({ peer: ANON_JOIN_PEER, role: "agent", anonymous: true });
2574
+ }
2575
+ mcp = new McpStreamableHttpServer(
1387
2576
  hubTools(hub, registry, {
1388
2577
  defaultWaitMs: resolved.defaultWaitMs,
1389
2578
  waitTimeoutMs: resolved.waitTimeoutMs,
1390
2579
  herdr,
1391
- herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
2580
+ herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
2581
+ managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
1392
2582
  }),
1393
2583
  { name: SERVER_NAME, version: SERVER_VERSION },
1394
2584
  registry,
1395
2585
  (message) => log2.warn(message),
1396
- (sessionId, clientName) => {
2586
+ (sessionId, clientName, clientVersion) => {
1397
2587
  try {
1398
- const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
1399
- if (peer !== void 0) log2.info(`peer joined: ${peer}`);
2588
+ const identity = registry.tokenFor(sessionId);
2589
+ if (identity?.anonymous === true && identity.peer === ANON_JOIN_PEER) {
2590
+ let candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2591
+ while (hub.has(candidate)) {
2592
+ candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2593
+ }
2594
+ registry.bindToken(sessionId, { ...identity, peer: candidate });
2595
+ }
2596
+ const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
2597
+ if (peer !== void 0) {
2598
+ const owner = registry.tokenFor(sessionId)?.owner;
2599
+ const anon = registry.tokenFor(sessionId)?.anonymous === true ? " (anonymous join)" : "";
2600
+ log2.info(`peer joined: ${peer}${owner !== void 0 ? ` (owner: ${owner})` : ""}${anon}`);
2601
+ }
1400
2602
  } catch (error) {
1401
2603
  log2.warn(`auto-register failed: ${error.message}`);
1402
2604
  }
1403
- }
2605
+ },
2606
+ auth,
2607
+ authTokensAdmin(resolved.authTokens, authReload)
1404
2608
  );
1405
2609
  const server = createServer();
1406
2610
  mcp.attach(server, resolved.path);
@@ -1416,6 +2620,19 @@ function startHub(config = {}, log2 = console) {
1416
2620
  close: () => {
1417
2621
  hub.dispose();
1418
2622
  mcp.close();
2623
+ if (saveTimer !== void 0) {
2624
+ clearTimeout(saveTimer);
2625
+ saveTimer = void 0;
2626
+ }
2627
+ flushSaves();
2628
+ if (dbStore !== void 0) {
2629
+ try {
2630
+ dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2631
+ } catch (error) {
2632
+ log2.warn(`sqlite close failed: ${error.message}`);
2633
+ }
2634
+ dbStore = void 0;
2635
+ }
1419
2636
  server.closeAllConnections?.();
1420
2637
  server.close();
1421
2638
  }
@@ -1424,19 +2641,19 @@ function startHub(config = {}, log2 = console) {
1424
2641
 
1425
2642
  // src/setup.ts
1426
2643
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1427
- import { existsSync as existsSync2 } from "node:fs";
2644
+ import { existsSync as existsSync3 } from "node:fs";
1428
2645
  import { homedir as homedir2 } from "node:os";
1429
- import { dirname as dirname2, join as join2 } from "node:path";
2646
+ import { dirname as dirname4, join as join2 } from "node:path";
1430
2647
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1431
2648
 
1432
2649
  // src/discover.ts
1433
2650
  import { execFileSync } from "node:child_process";
1434
- import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
2651
+ import { existsSync as existsSync2, readdirSync, accessSync, readFileSync as readFileSync4, constants as fsConstants } from "node:fs";
1435
2652
  import { homedir } from "node:os";
1436
- import { dirname, join, sep } from "node:path";
2653
+ import { dirname as dirname3, join, sep } from "node:path";
1437
2654
  import { fileURLToPath } from "node:url";
1438
2655
  function registryFile() {
1439
- return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
2656
+ return join(dirname3(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1440
2657
  }
1441
2658
  function expandHome(file, home) {
1442
2659
  return file.startsWith("~/") ? join(home, file.slice(2)) : file;
@@ -1448,7 +2665,7 @@ function expandConfigFile(file, home) {
1448
2665
  const prefix = expanded.slice(0, star);
1449
2666
  const suffix = expanded.slice(star + 1);
1450
2667
  const base = prefix.slice(0, prefix.lastIndexOf(sep));
1451
- if (!existsSync(base)) return [];
2668
+ if (!existsSync2(base)) return [];
1452
2669
  return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
1453
2670
  }
1454
2671
  function validateRegistry(registry) {
@@ -1492,7 +2709,7 @@ function validateRegistry(registry) {
1492
2709
  }
1493
2710
  }
1494
2711
  function loadRegistry(file = registryFile()) {
1495
- const registry = JSON.parse(readFileSync(file, "utf8"));
2712
+ const registry = JSON.parse(readFileSync4(file, "utf8"));
1496
2713
  validateRegistry(registry);
1497
2714
  return registry;
1498
2715
  }
@@ -1543,7 +2760,7 @@ function npmFallbackRoots(home, platform) {
1543
2760
  return [
1544
2761
  "/usr/local/lib/node_modules",
1545
2762
  "/usr/lib/node_modules",
1546
- ...existsSync(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
2763
+ ...existsSync2(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
1547
2764
  ];
1548
2765
  }
1549
2766
  function discover(registry, options = {}) {
@@ -1554,15 +2771,15 @@ function discover(registry, options = {}) {
1554
2771
  let npmNames = null;
1555
2772
  if (!options.noNpm) {
1556
2773
  if (options.npmRoot !== void 0 && options.npmRoot !== null) {
1557
- npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
2774
+ npmNames = existsSync2(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
1558
2775
  } else if (options.npmRoot !== null) {
1559
2776
  const root = npmGlobalRoot(platform);
1560
- if (root && existsSync(root)) {
2777
+ if (root && existsSync2(root)) {
1561
2778
  npmNames = readNpmNames(root);
1562
2779
  } else {
1563
2780
  npmNames = [];
1564
2781
  for (const fallback of npmFallbackRoots(home, platform)) {
1565
- if (existsSync(fallback)) {
2782
+ if (existsSync2(fallback)) {
1566
2783
  npmNames = readNpmNames(fallback);
1567
2784
  break;
1568
2785
  }
@@ -1575,7 +2792,7 @@ function discover(registry, options = {}) {
1575
2792
  const configFiles = [];
1576
2793
  for (const config of agent.configs) {
1577
2794
  for (const file of expandConfigFile(config.file, home)) {
1578
- if (existsSync(file)) {
2795
+ if (existsSync2(file)) {
1579
2796
  configFiles.push(file);
1580
2797
  if (source === "none") source = "config";
1581
2798
  }
@@ -1606,7 +2823,7 @@ function runDiscover(options = {}) {
1606
2823
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
1607
2824
  var DEFAULT_SERVER = "agent-hub";
1608
2825
  function defaultSkillSrc() {
1609
- return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
2826
+ return join2(dirname4(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1610
2827
  }
1611
2828
  function stamp() {
1612
2829
  const now = /* @__PURE__ */ new Date();
@@ -1614,7 +2831,7 @@ function stamp() {
1614
2831
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1615
2832
  }
1616
2833
  async function readJson(file) {
1617
- if (!existsSync2(file)) return null;
2834
+ if (!existsSync3(file)) return null;
1618
2835
  try {
1619
2836
  return JSON.parse(await readFile(file, "utf8"));
1620
2837
  } catch (error) {
@@ -1622,7 +2839,7 @@ async function readJson(file) {
1622
2839
  }
1623
2840
  }
1624
2841
  async function writeJsonNoBom(file, doc) {
1625
- await mkdir(dirname2(file), { recursive: true });
2842
+ await mkdir(dirname4(file), { recursive: true });
1626
2843
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
1627
2844
  }
1628
2845
  async function backup(file) {
@@ -1643,7 +2860,7 @@ function resolveSection(doc, section) {
1643
2860
  return node;
1644
2861
  }
1645
2862
  async function mergeJsonServer(file, section, entry, opts) {
1646
- if (!existsSync2(file)) return "skipped";
2863
+ if (!existsSync3(file)) return "skipped";
1647
2864
  const doc = await readJson(file);
1648
2865
  if (doc === null) return "skipped";
1649
2866
  const servers = resolveSection(doc, section);
@@ -1665,7 +2882,7 @@ async function mergeJsonServer(file, section, entry, opts) {
1665
2882
  return "changed";
1666
2883
  }
1667
2884
  async function mergeTomlSection(file, opts) {
1668
- if (!existsSync2(file)) return "skipped";
2885
+ if (!existsSync3(file)) return "skipped";
1669
2886
  const text = await readFile(file, "utf8");
1670
2887
  const marker = `[mcp_servers.${opts.serverName}]`;
1671
2888
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
@@ -1690,12 +2907,13 @@ function escapeRegExp(value) {
1690
2907
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1691
2908
  }
1692
2909
  var DSH_PATCH_MARKER = "# \u2500\u2500 agent-comm-hub MCP client";
2910
+ var DSH_PLUGIN_NAME = "@deepseek-ai/dsh-mcp-client";
1693
2911
  function dshPatchBlock(url, serverName) {
1694
2912
  return `
1695
2913
  ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --remove\`) \u2500
1696
2914
  - insert:
1697
2915
  - id: ${serverName}
1698
- name: '@deepseek-ai/dsh-mcp-client'
2916
+ name: '${DSH_PLUGIN_NAME}'
1699
2917
  config:
1700
2918
  serverName: ${serverName}
1701
2919
  transport: streamable-http
@@ -1703,64 +2921,56 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
1703
2921
  `;
1704
2922
  }
1705
2923
  async function mergeDshPatch(file, opts) {
1706
- if (!existsSync2(file)) return "skipped";
2924
+ if (!existsSync3(file)) return "skipped";
1707
2925
  const text = await readFile(file, "utf8");
1708
2926
  const lines = text.split("\n");
1709
- const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
1710
- const hasBlock = markerLine >= 0;
1711
- const blockRange = () => {
1712
- let entryStart = -1;
1713
- for (let i = markerLine + 1; i < lines.length; i++) {
1714
- if (/^- /.test(lines[i])) {
1715
- entryStart = i;
1716
- break;
1717
- }
1718
- }
1719
- let end = lines.length;
1720
- if (entryStart >= 0) {
1721
- for (let i = entryStart + 1; i < lines.length; i++) {
1722
- if (/^- /.test(lines[i])) {
1723
- end = i;
1724
- break;
1725
- }
1726
- }
2927
+ const hubEntryRanges = () => {
2928
+ const tops = [];
2929
+ for (let i = 0; i < lines.length; i++) {
2930
+ if (/^- /.test(lines[i])) tops.push(i);
2931
+ }
2932
+ const ranges2 = [];
2933
+ for (let k = 0; k < tops.length; k++) {
2934
+ const end = k + 1 < tops.length ? tops[k + 1] : lines.length;
2935
+ if (!lines.slice(tops[k], end).some((line) => line.includes(DSH_PLUGIN_NAME))) continue;
2936
+ let start = tops[k];
2937
+ while (start > 0 && lines[start - 1].trim() === "") start--;
2938
+ if (start > 0 && lines[start - 1].includes(DSH_PATCH_MARKER)) start--;
2939
+ ranges2.push({ start, end });
1727
2940
  }
1728
- let start = markerLine;
1729
- while (start > 0 && lines[start - 1].trim() === "") start--;
1730
- return { start, end };
2941
+ return ranges2;
1731
2942
  };
1732
- const withoutBlock = () => {
1733
- const { start, end } = blockRange();
1734
- return lines.slice(0, start).concat(lines.slice(end)).join("\n");
2943
+ const withoutHubEntries = () => {
2944
+ let out = lines;
2945
+ for (const range of hubEntryRanges().reverse()) {
2946
+ out = out.slice(0, range.start).concat(out.slice(range.end));
2947
+ }
2948
+ return out.join("\n");
1735
2949
  };
1736
2950
  if (opts.remove) {
1737
- if (!hasBlock) return "absent";
1738
- const cleaned = withoutBlock();
2951
+ if (hubEntryRanges().length === 0) return "absent";
1739
2952
  await backup(file);
1740
- await writeFile(file, cleaned, "utf8");
2953
+ await writeFile(file, withoutHubEntries(), "utf8");
1741
2954
  return "removed";
1742
2955
  }
1743
- if (hasBlock) {
1744
- if (lines.some((line) => line.includes(`url: ${opts.url}`))) return "unchanged";
1745
- const replaced = withoutBlock();
1746
- await backup(file);
1747
- await writeFile(file, replaced.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
1748
- return "changed";
2956
+ const ranges = hubEntryRanges();
2957
+ if (ranges.length === 1 && lines.slice(ranges[0].start, ranges[0].end).some((line) => line.includes(`url: ${opts.url}`))) {
2958
+ return "unchanged";
1749
2959
  }
1750
2960
  await backup(file);
1751
- await writeFile(file, text.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
2961
+ await writeFile(file, withoutHubEntries().trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
1752
2962
  return "changed";
1753
2963
  }
1754
2964
  async function syncSkill(skillDir, skillSrc, remove, log2) {
1755
2965
  if (remove) {
1756
- if (existsSync2(skillDir)) {
1757
- await mkdir(dirname2(skillDir), { recursive: true });
2966
+ if (existsSync3(skillDir)) {
2967
+ await mkdir(dirname4(skillDir), { recursive: true });
1758
2968
  await rmRecursive(skillDir);
1759
2969
  log2(` skill removed: ${skillDir}`);
1760
2970
  }
1761
2971
  return;
1762
2972
  }
1763
- if (!existsSync2(skillSrc)) {
2973
+ if (!existsSync3(skillSrc)) {
1764
2974
  log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
1765
2975
  return;
1766
2976
  }
@@ -1846,9 +3056,9 @@ async function runSetup(options = {}) {
1846
3056
  // src/ops.ts
1847
3057
  import { execFileSync as execFileSync2 } from "node:child_process";
1848
3058
  import { request } from "node:http";
1849
- import { mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
3059
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
1850
3060
  import { homedir as homedir3 } from "node:os";
1851
- import { dirname as dirname3, join as join3 } from "node:path";
3061
+ import { dirname as dirname5, join as join3 } from "node:path";
1852
3062
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1853
3063
  async function runStatus(options = {}) {
1854
3064
  const host = options.host ?? "127.0.0.1";
@@ -1901,8 +3111,8 @@ async function runStatus(options = {}) {
1901
3111
  function runUpdate() {
1902
3112
  const messages = [];
1903
3113
  try {
1904
- const pkgFile = join3(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
1905
- const before = JSON.parse(readFileSync2(pkgFile, "utf8")).version ?? "?";
3114
+ const pkgFile = join3(dirname5(fileURLToPath3(import.meta.url)), "..", "package.json");
3115
+ const before = JSON.parse(readFileSync5(pkgFile, "utf8")).version ?? "?";
1906
3116
  messages.push(`current version: ${before}`);
1907
3117
  const script = [
1908
3118
  "import { execFileSync } from 'node:child_process'",
@@ -1958,8 +3168,8 @@ function runService(options) {
1958
3168
  messages.push(`[dry-run] write ${vbs}`);
1959
3169
  messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
1960
3170
  } else {
1961
- mkdirSync(launcherDir, { recursive: true });
1962
- writeFileSync(vbs, vbsContent);
3171
+ mkdirSync3(launcherDir, { recursive: true });
3172
+ writeFileSync3(vbs, vbsContent);
1963
3173
  execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1964
3174
  messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
1965
3175
  messages.push(`start it now with: wscript.exe "${vbs}"`);
@@ -1998,8 +3208,8 @@ WantedBy=default.target
1998
3208
  messages.push(`[dry-run] would write ${unitFile}`);
1999
3209
  messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
2000
3210
  } else {
2001
- mkdirSync(unitDir, { recursive: true });
2002
- writeFileSync(unitFile, unit);
3211
+ mkdirSync3(unitDir, { recursive: true });
3212
+ writeFileSync3(unitFile, unit);
2003
3213
  run("systemctl", ["--user", "daemon-reload"], false);
2004
3214
  const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
2005
3215
  messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
@@ -2052,8 +3262,8 @@ WantedBy=default.target
2052
3262
  messages.push(`[dry-run] write ${plist}`);
2053
3263
  messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
2054
3264
  } else {
2055
- mkdirSync(launchAgentsDir, { recursive: true });
2056
- writeFileSync(plist, plistContent);
3265
+ mkdirSync3(launchAgentsDir, { recursive: true });
3266
+ writeFileSync3(plist, plistContent);
2057
3267
  try {
2058
3268
  execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
2059
3269
  } catch {
@@ -2088,17 +3298,23 @@ WantedBy=default.target
2088
3298
  }
2089
3299
 
2090
3300
  // src/cli.ts
3301
+ import { homedir as homedir4 } from "node:os";
3302
+ import { join as join4 } from "node:path";
3303
+ var STRING_FLAGS = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
3304
+ var DEFAULT_STATE_FILE = join4(homedir4(), ".agent-comm-hub", "roster.json");
3305
+ var DEFAULT_DB = join4(homedir4(), ".agent-comm-hub", "state.db");
3306
+ var DEFAULT_AUTH_FILE = join4(homedir4(), ".agent-comm-hub", "tokens.json");
2091
3307
  function parseArgs(argv) {
2092
3308
  const args = {};
2093
3309
  const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--herdr-timeout-ms"]);
2094
- const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin"]);
3310
+ const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
2095
3311
  for (let i = 0; i < argv.length; i++) {
2096
3312
  const flag = argv[i];
2097
3313
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
2098
3314
  args[flag] = true;
2099
3315
  continue;
2100
3316
  }
2101
- if (flag === "--remove" || flag === "--dry-run") {
3317
+ if (flag === "--remove" || flag === "--dry-run" || flag === "--reveal" || flag === "--allow-join") {
2102
3318
  args[flag] = true;
2103
3319
  continue;
2104
3320
  }
@@ -2137,6 +3353,10 @@ Usage:
2137
3353
  agent-comm-hub update self-update from the npm registry
2138
3354
  (files updated in place; restart
2139
3355
  the hub afterwards)
3356
+ agent-comm-hub auth add <peer> [flags] manage the remote-mode token table
3357
+ agent-comm-hub auth remove <peer> (add prints the generated token ONCE)
3358
+ agent-comm-hub auth list [--reveal]
3359
+ agent-comm-hub auth gen print a fresh random token
2140
3360
 
2141
3361
  Hub options:
2142
3362
  --host <addr> Bind address (default 127.0.0.1)
@@ -2151,6 +3371,34 @@ Hub options:
2151
3371
  --herdr-bin <path> herdr CLI binary for bridge_agent_* control tools
2152
3372
  (default herdr, resolved via PATH)
2153
3373
  --herdr-timeout-ms <n> Default cap for one herdr call in ms (default 30000)
3374
+ --manager-peers <ids> Comma-separated peer ids allowed to manage the
3375
+ roster (rename others / kick), or "all".
3376
+ (default agent-hub-cli \u2014 the desktop GUI identity)
3377
+ --state-file <path> Persist the peer roster (aliases, client info) to
3378
+ this JSON file so identities survive restarts
3379
+ (default ~/.agent-comm-hub/roster.json; "off"
3380
+ keeps everything in memory)
3381
+ --auth-tokens <file> REMOTE MODE: require "Authorization: Bearer <token>"
3382
+ on every MCP request; each token maps to a fixed
3383
+ peer id + role (agent|manager). See server/README.md
3384
+ for the token table format and deployment recipes.
3385
+ --db <path> REMOTE MODE: SQLite state DB persisting history,
3386
+ mailboxes, profiles and groups across restarts
3387
+ (default ~/.agent-comm-hub/state.db; "off" = memory)
3388
+
3389
+ Auth options (agent-comm-hub auth <action>):
3390
+ --file <path> Token table to manage
3391
+ (default ~/.agent-comm-hub/tokens.json)
3392
+ --role <r> auth add: agent | manager (default agent)
3393
+ --owner <name> auth add: optional owner label
3394
+ --token <t> auth add: supply your own token instead of
3395
+ generating one (16-128 chars [A-Za-z0-9._:-])
3396
+ --allow-join Start the hub in allow-join mode: agents without
3397
+ a token may connect (LAN/VPN only; keep OFF on
3398
+ the public internet). Each gets a unique peer id
3399
+ + source IP shown in the admin roster for you to
3400
+ claim (rename) or issue a token to.
3401
+ --reveal auth list: include the plaintext tokens
2154
3402
 
2155
3403
  Setup options:
2156
3404
  --url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
@@ -2208,7 +3456,8 @@ try {
2208
3456
  console.log("no peers online yet \u2014 start an agent session to see it appear");
2209
3457
  } else {
2210
3458
  for (const peer of result.peers) {
2211
- console.log(` ${peer.id.padEnd(32)} ${peer.connected ? "connected" : "offline"}`);
3459
+ const label = peer.alias !== void 0 && peer.alias !== peer.id ? `${peer.id} (${peer.alias})` : peer.id;
3460
+ console.log(` ${label.padEnd(32)} ${peer.connected ? "connected" : "offline"}`);
2212
3461
  }
2213
3462
  }
2214
3463
  process.exit(0);
@@ -2234,6 +3483,77 @@ try {
2234
3483
  }
2235
3484
  process.exit(0);
2236
3485
  }
3486
+ if (command === "auth") {
3487
+ const [action, ...authRest] = rest;
3488
+ const splitArgs = (args2) => {
3489
+ const positional = [];
3490
+ const flags = [];
3491
+ for (let i = 0; i < args2.length; i++) {
3492
+ if (args2[i].startsWith("--")) {
3493
+ flags.push(args2[i]);
3494
+ if (STRING_FLAGS.has(args2[i]) && args2[i + 1] !== void 0 && !args2[i + 1].startsWith("--")) flags.push(args2[++i]);
3495
+ } else positional.push(args2[i]);
3496
+ }
3497
+ return { positional, flags };
3498
+ };
3499
+ const file = () => {
3500
+ const { flags } = splitArgs(authRest);
3501
+ return parseArgs(flags)["--file"] ?? DEFAULT_AUTH_FILE;
3502
+ };
3503
+ if (action === "add") {
3504
+ const { positional, flags } = splitArgs(authRest);
3505
+ const args2 = parseArgs(flags);
3506
+ const peerId = positional[0];
3507
+ if (peerId === void 0) {
3508
+ console.error("auth add: missing <peer> \u2014 usage: agent-comm-hub auth add <peer> [--role agent|manager] [--owner name]");
3509
+ process.exit(1);
3510
+ }
3511
+ const role = args2["--role"] ?? "agent";
3512
+ if (role !== "agent" && role !== "manager") {
3513
+ console.error(`auth add: invalid role '${role}' (expected agent | manager)`);
3514
+ process.exit(1);
3515
+ }
3516
+ const row = addToken(file(), {
3517
+ peer: peerId,
3518
+ role,
3519
+ owner: args2["--owner"],
3520
+ token: args2["--token"]
3521
+ });
3522
+ console.log(`token created for ${peerId}${row.owner ? ` (${row.owner})` : ""} [${row.role}]`);
3523
+ console.log(` ${row.token}`);
3524
+ console.log("The MCP config to pass to that agent (Authorization header) \u2014 not echoed here;");
3525
+ console.log(`Table written to ${file()} (hub hot-reloads; takes effect in ~2s)`);
3526
+ process.exit(0);
3527
+ }
3528
+ if (action === "remove") {
3529
+ const peerId = splitArgs(authRest).positional[0];
3530
+ if (peerId === void 0) {
3531
+ console.error("auth remove: missing <peer>");
3532
+ process.exit(1);
3533
+ }
3534
+ console.log(removeToken(file(), peerId) ? `removed peer ${peerId}` : `peer ${peerId} not found`);
3535
+ process.exit(0);
3536
+ }
3537
+ if (action === "list") {
3538
+ const args2 = parseArgs(splitArgs(authRest).flags);
3539
+ const reveal = args2["--reveal"] === true;
3540
+ const rows = readTokens(file());
3541
+ if (rows.length === 0) {
3542
+ console.log("token table is empty \u2014 add one with: agent-comm-hub auth add <peer>");
3543
+ }
3544
+ for (const row of rows) {
3545
+ const t = reveal ? row.token : row.token.slice(0, 6) + "\u2026" + row.token.slice(-4);
3546
+ console.log(`${row.peer.padEnd(24)} ${row.role.padEnd(8)} ${row.owner ?? ""} ${t}`);
3547
+ }
3548
+ process.exit(0);
3549
+ }
3550
+ if (action === "gen") {
3551
+ console.log(generateToken());
3552
+ process.exit(0);
3553
+ }
3554
+ console.error(`auth: unknown action '${action ?? ""}' (expected add | remove | list | gen)`);
3555
+ process.exit(1);
3556
+ }
2237
3557
  if (command === "update") {
2238
3558
  const args2 = parseArgs(rest);
2239
3559
  if (args2["--help"] || args2["-h"]) {
@@ -2268,7 +3588,12 @@ try {
2268
3588
  connectedWindowMs: args["--connected-window-ms"],
2269
3589
  peerIdleTimeoutMs: args["--peer-idle-timeout-ms"],
2270
3590
  herdrBin: args["--herdr-bin"],
2271
- herdrTimeoutMs: args["--herdr-timeout-ms"]
3591
+ herdrTimeoutMs: args["--herdr-timeout-ms"],
3592
+ managerPeers: args["--manager-peers"] === void 0 ? void 0 : args["--manager-peers"] === "all" ? "all" : String(args["--manager-peers"]).split(",").map((id) => id.trim()).filter((id) => id !== ""),
3593
+ stateFile: args["--state-file"] === void 0 ? DEFAULT_STATE_FILE : args["--state-file"] === "off" ? void 0 : String(args["--state-file"]),
3594
+ authTokens: args["--auth-tokens"] === void 0 ? void 0 : String(args["--auth-tokens"]),
3595
+ allowJoin: args["--allow-join"] === true,
3596
+ db: args["--db"] === void 0 ? DEFAULT_DB : args["--db"] === "off" ? void 0 : String(args["--db"])
2272
3597
  }, log);
2273
3598
  const shutdown = () => {
2274
3599
  log.info("agent-comm-hub shutting down");