agent-comm-hub 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createServer } from "node:http";
5
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
- import { dirname } from "node:path";
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";
7
8
 
8
9
  // src/herdr-ctl.ts
9
10
  import { execFile } from "node:child_process";
@@ -420,6 +421,7 @@ function socketSend(socketPath, method, params) {
420
421
  import { randomUUID } from "node:crypto";
421
422
 
422
423
  // src/protocol.ts
424
+ var KINDS = ["chat", "task", "notice", "ack"];
423
425
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
424
426
  var BROADCAST = "all";
425
427
  function decodeContent(kind, content) {
@@ -450,6 +452,8 @@ var AgentHub = class {
450
452
  lastSeen = /* @__PURE__ */ new Map();
451
453
  /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
452
454
  profiles = /* @__PURE__ */ new Map();
455
+ /** Named channels (group channel), keyed by group id. */
456
+ groups = /* @__PURE__ */ new Map();
453
457
  gcTimer;
454
458
  /** Stop the idle GC (call when the hub shuts down). */
455
459
  dispose() {
@@ -489,6 +493,7 @@ var AgentHub = class {
489
493
  /** Remove a peer and its queued messages; pending waiters resolve as
490
494
  * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
491
495
  unregister(peerId) {
496
+ if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
492
497
  this.queues.delete(peerId);
493
498
  for (const waiter of this.waiters.get(peerId) ?? []) {
494
499
  clearTimeout(waiter.timer);
@@ -629,9 +634,20 @@ var AgentHub = class {
629
634
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
630
635
  return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
631
636
  }
632
- /** 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. */
633
639
  history(peerId, limit) {
634
- 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));
635
651
  return filtered.slice(-Math.max(0, limit)).reverse();
636
652
  }
637
653
  /** Most recent messages across every peer, unfiltered (newest first).
@@ -640,10 +656,175 @@ var AgentHub = class {
640
656
  historyAll(limit) {
641
657
  return this.historyRing.slice(-Math.max(0, limit)).reverse();
642
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
+ }
643
824
  /**
644
- * Live summary for the status tool. `livePeers` (sessions with a live SSE
645
- * stream) count as connected even without recent tool activity.
646
- */
825
+ * Live summary for the status tool. `livePeers` (sessions with a live SSE
826
+ * stream) count as connected even without recent tool activity.
827
+ */
647
828
  status(livePeers) {
648
829
  const now = Date.now();
649
830
  return {
@@ -667,7 +848,7 @@ var AgentHub = class {
667
848
  poll(peerId, from) {
668
849
  const queue = this.queues.get(peerId) ?? [];
669
850
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
670
- for (const message of drained) this.remember(message);
851
+ if (drained.length > 0) this.options.onMailboxesChanged?.();
671
852
  return drained;
672
853
  }
673
854
  /**
@@ -750,12 +931,15 @@ var AgentHub = class {
750
931
  if (queue.length >= this.options.maxQueue) queue.shift();
751
932
  queue.push(message);
752
933
  this.queues.set(target, queue);
934
+ this.remember(message);
753
935
  this.options.onQueued?.(message, target);
754
936
  }
755
- /** 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). */
756
939
  remember(message) {
757
940
  this.historyRing.push(message);
758
941
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
942
+ this.options.onMessage?.(message);
759
943
  }
760
944
  };
761
945
  function drainFrom(queue, from) {
@@ -773,13 +957,154 @@ function omit(value, key) {
773
957
  return rest;
774
958
  }
775
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
+ }
1099
+
776
1100
  // src/hub-tools.ts
777
1101
  var DEFAULT_WAIT_MS = 3e4;
778
1102
  function present(message) {
779
- const { ref, ...rest } = message;
1103
+ const { ref, channel, ...rest } = message;
780
1104
  return {
781
1105
  ...rest,
782
1106
  ...ref !== void 0 ? { ref } : {},
1107
+ ...channel !== void 0 ? { channel } : {},
783
1108
  content: decodeContent(message.kind, message.content)
784
1109
  };
785
1110
  }
@@ -799,7 +1124,8 @@ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
799
1124
  const bound = registry.peerFor(sessionId);
800
1125
  if (bound !== void 0) return bound;
801
1126
  if (registry.isSuppressed(sessionId)) return void 0;
802
- const peerId = sanitizePeerId(clientName ?? "agent");
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");
803
1129
  if (!hub.has(peerId)) {
804
1130
  hub.register(peerId, {
805
1131
  ...clientName !== void 0 ? { name: clientName } : {},
@@ -820,16 +1146,31 @@ function livePeersFor(registry) {
820
1146
  }
821
1147
  function rosterFor(hub, registry) {
822
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
+ }
823
1161
  return status.peers.map((peer) => {
824
1162
  const profile = hub.profileOf(peer.id);
825
1163
  const alias = profile?.alias;
826
1164
  const clientName = profile?.clientName;
827
1165
  const clientVersion = profile?.clientVersion;
1166
+ const ips = peerIps.get(peer.id);
828
1167
  return {
829
1168
  id: peer.id,
830
1169
  ...alias !== void 0 ? { alias } : {},
831
1170
  ...clientName !== void 0 ? { clientName } : {},
832
1171
  ...clientVersion !== void 0 ? { clientVersion } : {},
1172
+ ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1173
+ ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
833
1174
  connected: peer.connected,
834
1175
  lastSeenMs: peer.lastSeenMs
835
1176
  };
@@ -864,6 +1205,7 @@ function hubTools(hub, registry, options) {
864
1205
  from: message.from,
865
1206
  to: message.to,
866
1207
  kind: message.kind,
1208
+ ...message.channel !== void 0 ? { channel: message.channel } : {},
867
1209
  ts: message.ts
868
1210
  });
869
1211
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -878,7 +1220,8 @@ function hubTools(hub, registry, options) {
878
1220
  }
879
1221
  return herdr;
880
1222
  };
881
- const requireManager = (peer, action) => {
1223
+ const requireManager = (peer, action, sessionId) => {
1224
+ if (registry.tokenFor(sessionId)?.role === "manager") return;
882
1225
  const managers = options.managerPeers ?? "all";
883
1226
  if (managers !== "all" && !managers.has(peer)) {
884
1227
  throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
@@ -910,6 +1253,10 @@ function hubTools(hub, registry, options) {
910
1253
  throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
911
1254
  }
912
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
+ }
913
1260
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
914
1261
  throw new Error(`peer already registered by another connection: ${peerId}`);
915
1262
  }
@@ -951,7 +1298,7 @@ function hubTools(hub, registry, options) {
951
1298
  const caller = registry.peerFor(sessionId);
952
1299
  if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
953
1300
  const target = String(args.peer);
954
- requireManager(caller, `kicking '${target}'`);
1301
+ requireManager(caller, `kicking '${target}'`, sessionId);
955
1302
  if (!hub.has(target)) {
956
1303
  return { ok: true, peerId: null, kicked: false };
957
1304
  }
@@ -968,14 +1315,14 @@ function hubTools(hub, registry, options) {
968
1315
  peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
969
1316
  peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
970
1317
  }),
971
- handler: wrap(true, async (args, peer) => {
1318
+ handler: wrap(true, async (args, peer, sessionId) => {
972
1319
  const hasAlias = args.alias !== void 0;
973
1320
  const hasPeerId = args.peerId !== void 0;
974
1321
  if (!hasAlias && !hasPeerId) {
975
1322
  throw new Error("nothing to rename: pass alias and/or peerId");
976
1323
  }
977
1324
  let target = args.peer === void 0 ? peer : String(args.peer);
978
- if (target !== peer) requireManager(peer, `renaming '${target}'`);
1325
+ if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
979
1326
  let previousId;
980
1327
  if (hasPeerId) {
981
1328
  if (args.peer === void 0) {
@@ -1076,16 +1423,98 @@ function hubTools(hub, registry, options) {
1076
1423
  name: "bridge_history",
1077
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).',
1078
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).") }),
1079
- handler: wrap(true, async (args, peer) => {
1426
+ handler: wrap(true, async (args, peer, sessionId) => {
1080
1427
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
1081
1428
  const target = args.peer === void 0 ? peer : String(args.peer);
1082
1429
  if (target !== peer) {
1083
- requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`} requires manager rights`);
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) };
1084
1438
  }
1085
1439
  const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
1086
1440
  return { messages: messages.map(present) };
1087
1441
  })
1088
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
+ },
1089
1518
  // ---- herdr control tools ------------------------------------------
1090
1519
  // These type into real agent terminals via the herdr runtime. They are
1091
1520
  // gated by checkControl and documented as physical input: unlike
@@ -1288,6 +1717,16 @@ function hubTools(hub, registry, options) {
1288
1717
 
1289
1718
  // src/mcp-server.ts
1290
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
+ }
1291
1730
  var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1292
1731
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
1293
1732
  var MAX_BODY_BYTES = 1048576;
@@ -1360,8 +1799,24 @@ var SessionRegistry = class {
1360
1799
  isSuppressed(sessionId) {
1361
1800
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
1362
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();
1363
1805
  /** Sessions with a live SSE stream (server→client channel). */
1364
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
+ }
1365
1820
  /** Mark a session's SSE stream open (server→client channel alive). */
1366
1821
  markSseOpen(sessionId) {
1367
1822
  this.liveStreams.add(sessionId);
@@ -1420,18 +1875,29 @@ var SessionRegistry = class {
1420
1875
  var McpStreamableHttpServer = class {
1421
1876
  constructor(tools, info, registry, log2 = () => {
1422
1877
  }, onInitialize = () => {
1423
- }) {
1878
+ }, authenticate, adminApi) {
1424
1879
  this.tools = tools;
1425
1880
  this.info = info;
1426
1881
  this.registry = registry;
1427
1882
  this.log = log2;
1428
1883
  this.onInitialize = onInitialize;
1884
+ this.authenticate = authenticate;
1885
+ this.adminApi = adminApi;
1429
1886
  }
1430
1887
  sseStreams = /* @__PURE__ */ new Map();
1431
1888
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
1432
1889
  attach(server, path2) {
1433
1890
  server.on("request", (req, res) => {
1434
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
+ }
1435
1901
  if (url.pathname !== path2) {
1436
1902
  res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
1437
1903
  return;
@@ -1481,7 +1947,14 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1481
1947
  }
1482
1948
  }
1483
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
+ }
1484
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);
1485
1958
  res.writeHead(200, {
1486
1959
  ...corsHeaders(),
1487
1960
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -1497,7 +1970,79 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1497
1970
  this.registry.markSseClosed(sessionId);
1498
1971
  });
1499
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
+ }
1500
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
+ }
1501
2046
  let body;
1502
2047
  try {
1503
2048
  body = await readBody(req);
@@ -1518,12 +2063,13 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1518
2063
  }
1519
2064
  const id = message.id ?? null;
1520
2065
  const sessionId = this.registry.sessionIdFor(req);
2066
+ if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
1521
2067
  if (id === null) {
1522
2068
  res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
1523
2069
  return;
1524
2070
  }
1525
2071
  try {
1526
- const { result, extraHeaders } = await this.dispatch(message, sessionId);
2072
+ const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
1527
2073
  res.writeHead(200, {
1528
2074
  ...corsHeaders(),
1529
2075
  "Content-Type": "application/json; charset=utf-8",
@@ -1536,7 +2082,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1536
2082
  this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
1537
2083
  }
1538
2084
  }
1539
- async dispatch(message, sessionId) {
2085
+ async dispatch(message, sessionId, authIdentity) {
1540
2086
  const method = message.method ?? "";
1541
2087
  switch (method) {
1542
2088
  case "initialize": {
@@ -1547,6 +2093,9 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1547
2093
  if (clientName !== void 0) {
1548
2094
  this.registry.noteClient(newSessionId, clientName, clientVersion);
1549
2095
  }
2096
+ if (authIdentity !== void 0) {
2097
+ this.registry.bindToken(newSessionId, authIdentity);
2098
+ }
1550
2099
  this.onInitialize(newSessionId, clientName, clientVersion);
1551
2100
  const requested = message.params?.protocolVersion;
1552
2101
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
@@ -1610,6 +2159,14 @@ function rpcError(code, message) {
1610
2159
  function messageProtocolVersion(req) {
1611
2160
  return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
1612
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
+ }
1613
2170
  function corsHeaders() {
1614
2171
  return {
1615
2172
  "Access-Control-Allow-Origin": "*",
@@ -1635,9 +2192,149 @@ function readBody(req) {
1635
2192
  });
1636
2193
  }
1637
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.info(`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
+
1638
2335
  // src/index.ts
1639
2336
  var SERVER_NAME = "agent-comm-hub";
1640
- var SERVER_VERSION = "0.6.0";
2337
+ var SERVER_VERSION = "0.7.1";
1641
2338
  var DEFAULT_HOST = "127.0.0.1";
1642
2339
  var DEFAULT_PORT = 18764;
1643
2340
  var DEFAULT_PATH = "/mcp";
@@ -1659,16 +2356,39 @@ var DEFAULT_CONFIG = {
1659
2356
  // are NOT managers — they keep chatting, a GUI manages.
1660
2357
  managerPeers: ["agent-hub-cli"]
1661
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
+ }
1662
2381
  function writeRosterFile(file, profiles) {
1663
- mkdirSync(dirname(file), { recursive: true });
2382
+ mkdirSync2(dirname2(file), { recursive: true });
1664
2383
  const tmp = `${file}.tmp`;
1665
- writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
1666
- renameSync(tmp, file);
2384
+ writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2385
+ renameSync2(tmp, file);
1667
2386
  }
1668
2387
  function startHub(config = {}, log2 = console) {
1669
2388
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1670
2389
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
1671
2390
  let mcp;
2391
+ let dbStore;
1672
2392
  const pushRoster = () => {
1673
2393
  mcp.notifyAll("notifications/message", {
1674
2394
  level: "info",
@@ -1683,24 +2403,51 @@ function startHub(config = {}, log2 = console) {
1683
2403
  data: { event: "message", message: present(message) }
1684
2404
  });
1685
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
+ };
1686
2424
  let saveTimer;
1687
2425
  let rosterDirty = false;
1688
- const flushRoster = () => {
1689
- if (resolved.stateFile === void 0 || !rosterDirty) return;
1690
- rosterDirty = false;
1691
- try {
1692
- writeRosterFile(resolved.stateFile, hub.exportProfiles());
1693
- } catch (error) {
1694
- log2.warn(`roster save failed: ${error.message}`);
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
+ }
1695
2443
  }
1696
2444
  };
1697
- const scheduleRosterSave = () => {
1698
- if (resolved.stateFile === void 0) return;
1699
- rosterDirty = true;
2445
+ const scheduleSave = () => {
2446
+ if (resolved.stateFile === void 0 && dbStore === void 0) return;
1700
2447
  if (saveTimer !== void 0) return;
1701
2448
  saveTimer = setTimeout(() => {
1702
2449
  saveTimer = void 0;
1703
- flushRoster();
2450
+ flushSaves();
1704
2451
  }, 500);
1705
2452
  saveTimer.unref?.();
1706
2453
  };
@@ -1711,6 +2458,30 @@ function startHub(config = {}, log2 = console) {
1711
2458
  connectedWindowMs: resolved.connectedWindowMs,
1712
2459
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1713
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
+ },
1714
2485
  // The idle GC must never evict a peer whose session has a live SSE channel.
1715
2486
  isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
1716
2487
  // SSE event push: roster changes and queued-mail hints. Best-effort — a
@@ -1721,7 +2492,9 @@ function startHub(config = {}, log2 = console) {
1721
2492
  } catch (error) {
1722
2493
  log2.warn(`peers_changed notification failed: ${error.message}`);
1723
2494
  }
1724
- scheduleRosterSave();
2495
+ rosterDirty = true;
2496
+ dbDirty = true;
2497
+ scheduleSave();
1725
2498
  },
1726
2499
  onQueued: (message, target) => {
1727
2500
  try {
@@ -1732,9 +2505,39 @@ function startHub(config = {}, log2 = console) {
1732
2505
  }
1733
2506
  });
1734
2507
  const registry = new SessionRegistry();
1735
- if (resolved.stateFile !== void 0) {
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) {
1736
2539
  try {
1737
- const parsed = JSON.parse(readFileSync(resolved.stateFile, "utf8"));
2540
+ const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
1738
2541
  const imported = hub.importProfiles(parsed.profiles);
1739
2542
  if (imported > 0) log2.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
1740
2543
  } catch (error) {
@@ -1750,6 +2553,25 @@ function startHub(config = {}, log2 = console) {
1750
2553
  socketPath: resolved.herdrSocketPath,
1751
2554
  sendRequest: resolved.herdrSendRequest
1752
2555
  });
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
+ }
1753
2575
  mcp = new McpStreamableHttpServer(
1754
2576
  hubTools(hub, registry, {
1755
2577
  defaultWaitMs: resolved.defaultWaitMs,
@@ -1763,12 +2585,26 @@ function startHub(config = {}, log2 = console) {
1763
2585
  (message) => log2.warn(message),
1764
2586
  (sessionId, clientName, clientVersion) => {
1765
2587
  try {
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
+ }
1766
2596
  const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
1767
- if (peer !== void 0) log2.info(`peer joined: ${peer}`);
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
+ }
1768
2602
  } catch (error) {
1769
2603
  log2.warn(`auto-register failed: ${error.message}`);
1770
2604
  }
1771
- }
2605
+ },
2606
+ auth,
2607
+ authTokensAdmin(resolved.authTokens, authReload)
1772
2608
  );
1773
2609
  const server = createServer();
1774
2610
  mcp.attach(server, resolved.path);
@@ -1788,7 +2624,15 @@ function startHub(config = {}, log2 = console) {
1788
2624
  clearTimeout(saveTimer);
1789
2625
  saveTimer = void 0;
1790
2626
  }
1791
- flushRoster();
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
+ }
1792
2636
  server.closeAllConnections?.();
1793
2637
  server.close();
1794
2638
  }
@@ -1797,19 +2641,19 @@ function startHub(config = {}, log2 = console) {
1797
2641
 
1798
2642
  // src/setup.ts
1799
2643
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1800
- import { existsSync as existsSync2 } from "node:fs";
2644
+ import { existsSync as existsSync3 } from "node:fs";
1801
2645
  import { homedir as homedir2 } from "node:os";
1802
- import { dirname as dirname3, join as join2 } from "node:path";
2646
+ import { dirname as dirname4, join as join2 } from "node:path";
1803
2647
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1804
2648
 
1805
2649
  // src/discover.ts
1806
2650
  import { execFileSync } from "node:child_process";
1807
- import { existsSync, readdirSync, accessSync, readFileSync as readFileSync2, constants as fsConstants } from "node:fs";
2651
+ import { existsSync as existsSync2, readdirSync, accessSync, readFileSync as readFileSync4, constants as fsConstants } from "node:fs";
1808
2652
  import { homedir } from "node:os";
1809
- import { dirname as dirname2, join, sep } from "node:path";
2653
+ import { dirname as dirname3, join, sep } from "node:path";
1810
2654
  import { fileURLToPath } from "node:url";
1811
2655
  function registryFile() {
1812
- return join(dirname2(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
2656
+ return join(dirname3(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1813
2657
  }
1814
2658
  function expandHome(file, home) {
1815
2659
  return file.startsWith("~/") ? join(home, file.slice(2)) : file;
@@ -1821,7 +2665,7 @@ function expandConfigFile(file, home) {
1821
2665
  const prefix = expanded.slice(0, star);
1822
2666
  const suffix = expanded.slice(star + 1);
1823
2667
  const base = prefix.slice(0, prefix.lastIndexOf(sep));
1824
- if (!existsSync(base)) return [];
2668
+ if (!existsSync2(base)) return [];
1825
2669
  return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
1826
2670
  }
1827
2671
  function validateRegistry(registry) {
@@ -1865,7 +2709,7 @@ function validateRegistry(registry) {
1865
2709
  }
1866
2710
  }
1867
2711
  function loadRegistry(file = registryFile()) {
1868
- const registry = JSON.parse(readFileSync2(file, "utf8"));
2712
+ const registry = JSON.parse(readFileSync4(file, "utf8"));
1869
2713
  validateRegistry(registry);
1870
2714
  return registry;
1871
2715
  }
@@ -1916,7 +2760,7 @@ function npmFallbackRoots(home, platform) {
1916
2760
  return [
1917
2761
  "/usr/local/lib/node_modules",
1918
2762
  "/usr/lib/node_modules",
1919
- ...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")) : []
1920
2764
  ];
1921
2765
  }
1922
2766
  function discover(registry, options = {}) {
@@ -1927,15 +2771,15 @@ function discover(registry, options = {}) {
1927
2771
  let npmNames = null;
1928
2772
  if (!options.noNpm) {
1929
2773
  if (options.npmRoot !== void 0 && options.npmRoot !== null) {
1930
- npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
2774
+ npmNames = existsSync2(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
1931
2775
  } else if (options.npmRoot !== null) {
1932
2776
  const root = npmGlobalRoot(platform);
1933
- if (root && existsSync(root)) {
2777
+ if (root && existsSync2(root)) {
1934
2778
  npmNames = readNpmNames(root);
1935
2779
  } else {
1936
2780
  npmNames = [];
1937
2781
  for (const fallback of npmFallbackRoots(home, platform)) {
1938
- if (existsSync(fallback)) {
2782
+ if (existsSync2(fallback)) {
1939
2783
  npmNames = readNpmNames(fallback);
1940
2784
  break;
1941
2785
  }
@@ -1948,7 +2792,7 @@ function discover(registry, options = {}) {
1948
2792
  const configFiles = [];
1949
2793
  for (const config of agent.configs) {
1950
2794
  for (const file of expandConfigFile(config.file, home)) {
1951
- if (existsSync(file)) {
2795
+ if (existsSync2(file)) {
1952
2796
  configFiles.push(file);
1953
2797
  if (source === "none") source = "config";
1954
2798
  }
@@ -1979,7 +2823,7 @@ function runDiscover(options = {}) {
1979
2823
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
1980
2824
  var DEFAULT_SERVER = "agent-hub";
1981
2825
  function defaultSkillSrc() {
1982
- return join2(dirname3(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
2826
+ return join2(dirname4(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1983
2827
  }
1984
2828
  function stamp() {
1985
2829
  const now = /* @__PURE__ */ new Date();
@@ -1987,7 +2831,7 @@ function stamp() {
1987
2831
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1988
2832
  }
1989
2833
  async function readJson(file) {
1990
- if (!existsSync2(file)) return null;
2834
+ if (!existsSync3(file)) return null;
1991
2835
  try {
1992
2836
  return JSON.parse(await readFile(file, "utf8"));
1993
2837
  } catch (error) {
@@ -1995,7 +2839,7 @@ async function readJson(file) {
1995
2839
  }
1996
2840
  }
1997
2841
  async function writeJsonNoBom(file, doc) {
1998
- await mkdir(dirname3(file), { recursive: true });
2842
+ await mkdir(dirname4(file), { recursive: true });
1999
2843
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
2000
2844
  }
2001
2845
  async function backup(file) {
@@ -2016,7 +2860,7 @@ function resolveSection(doc, section) {
2016
2860
  return node;
2017
2861
  }
2018
2862
  async function mergeJsonServer(file, section, entry, opts) {
2019
- if (!existsSync2(file)) return "skipped";
2863
+ if (!existsSync3(file)) return "skipped";
2020
2864
  const doc = await readJson(file);
2021
2865
  if (doc === null) return "skipped";
2022
2866
  const servers = resolveSection(doc, section);
@@ -2038,7 +2882,7 @@ async function mergeJsonServer(file, section, entry, opts) {
2038
2882
  return "changed";
2039
2883
  }
2040
2884
  async function mergeTomlSection(file, opts) {
2041
- if (!existsSync2(file)) return "skipped";
2885
+ if (!existsSync3(file)) return "skipped";
2042
2886
  const text = await readFile(file, "utf8");
2043
2887
  const marker = `[mcp_servers.${opts.serverName}]`;
2044
2888
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
@@ -2077,7 +2921,7 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
2077
2921
  `;
2078
2922
  }
2079
2923
  async function mergeDshPatch(file, opts) {
2080
- if (!existsSync2(file)) return "skipped";
2924
+ if (!existsSync3(file)) return "skipped";
2081
2925
  const text = await readFile(file, "utf8");
2082
2926
  const lines = text.split("\n");
2083
2927
  const hubEntryRanges = () => {
@@ -2119,14 +2963,14 @@ async function mergeDshPatch(file, opts) {
2119
2963
  }
2120
2964
  async function syncSkill(skillDir, skillSrc, remove, log2) {
2121
2965
  if (remove) {
2122
- if (existsSync2(skillDir)) {
2123
- await mkdir(dirname3(skillDir), { recursive: true });
2966
+ if (existsSync3(skillDir)) {
2967
+ await mkdir(dirname4(skillDir), { recursive: true });
2124
2968
  await rmRecursive(skillDir);
2125
2969
  log2(` skill removed: ${skillDir}`);
2126
2970
  }
2127
2971
  return;
2128
2972
  }
2129
- if (!existsSync2(skillSrc)) {
2973
+ if (!existsSync3(skillSrc)) {
2130
2974
  log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
2131
2975
  return;
2132
2976
  }
@@ -2212,9 +3056,9 @@ async function runSetup(options = {}) {
2212
3056
  // src/ops.ts
2213
3057
  import { execFileSync as execFileSync2 } from "node:child_process";
2214
3058
  import { request } from "node:http";
2215
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
3059
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
2216
3060
  import { homedir as homedir3 } from "node:os";
2217
- import { dirname as dirname4, join as join3 } from "node:path";
3061
+ import { dirname as dirname5, join as join3 } from "node:path";
2218
3062
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2219
3063
  async function runStatus(options = {}) {
2220
3064
  const host = options.host ?? "127.0.0.1";
@@ -2267,8 +3111,8 @@ async function runStatus(options = {}) {
2267
3111
  function runUpdate() {
2268
3112
  const messages = [];
2269
3113
  try {
2270
- const pkgFile = join3(dirname4(fileURLToPath3(import.meta.url)), "..", "package.json");
2271
- const before = JSON.parse(readFileSync3(pkgFile, "utf8")).version ?? "?";
3114
+ const pkgFile = join3(dirname5(fileURLToPath3(import.meta.url)), "..", "package.json");
3115
+ const before = JSON.parse(readFileSync5(pkgFile, "utf8")).version ?? "?";
2272
3116
  messages.push(`current version: ${before}`);
2273
3117
  const script = [
2274
3118
  "import { execFileSync } from 'node:child_process'",
@@ -2324,8 +3168,8 @@ function runService(options) {
2324
3168
  messages.push(`[dry-run] write ${vbs}`);
2325
3169
  messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
2326
3170
  } else {
2327
- mkdirSync2(launcherDir, { recursive: true });
2328
- writeFileSync2(vbs, vbsContent);
3171
+ mkdirSync3(launcherDir, { recursive: true });
3172
+ writeFileSync3(vbs, vbsContent);
2329
3173
  execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
2330
3174
  messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
2331
3175
  messages.push(`start it now with: wscript.exe "${vbs}"`);
@@ -2364,8 +3208,8 @@ WantedBy=default.target
2364
3208
  messages.push(`[dry-run] would write ${unitFile}`);
2365
3209
  messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
2366
3210
  } else {
2367
- mkdirSync2(unitDir, { recursive: true });
2368
- writeFileSync2(unitFile, unit);
3211
+ mkdirSync3(unitDir, { recursive: true });
3212
+ writeFileSync3(unitFile, unit);
2369
3213
  run("systemctl", ["--user", "daemon-reload"], false);
2370
3214
  const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
2371
3215
  messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
@@ -2418,8 +3262,8 @@ WantedBy=default.target
2418
3262
  messages.push(`[dry-run] write ${plist}`);
2419
3263
  messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
2420
3264
  } else {
2421
- mkdirSync2(launchAgentsDir, { recursive: true });
2422
- writeFileSync2(plist, plistContent);
3265
+ mkdirSync3(launchAgentsDir, { recursive: true });
3266
+ writeFileSync3(plist, plistContent);
2423
3267
  try {
2424
3268
  execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
2425
3269
  } catch {
@@ -2456,18 +3300,21 @@ WantedBy=default.target
2456
3300
  // src/cli.ts
2457
3301
  import { homedir as homedir4 } from "node:os";
2458
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"]);
2459
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");
2460
3307
  function parseArgs(argv) {
2461
3308
  const args = {};
2462
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"]);
2463
- const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file"]);
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"]);
2464
3311
  for (let i = 0; i < argv.length; i++) {
2465
3312
  const flag = argv[i];
2466
3313
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
2467
3314
  args[flag] = true;
2468
3315
  continue;
2469
3316
  }
2470
- if (flag === "--remove" || flag === "--dry-run") {
3317
+ if (flag === "--remove" || flag === "--dry-run" || flag === "--reveal" || flag === "--allow-join") {
2471
3318
  args[flag] = true;
2472
3319
  continue;
2473
3320
  }
@@ -2506,6 +3353,10 @@ Usage:
2506
3353
  agent-comm-hub update self-update from the npm registry
2507
3354
  (files updated in place; restart
2508
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
2509
3360
 
2510
3361
  Hub options:
2511
3362
  --host <addr> Bind address (default 127.0.0.1)
@@ -2527,6 +3378,27 @@ Hub options:
2527
3378
  this JSON file so identities survive restarts
2528
3379
  (default ~/.agent-comm-hub/roster.json; "off"
2529
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
2530
3402
 
2531
3403
  Setup options:
2532
3404
  --url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
@@ -2611,6 +3483,77 @@ try {
2611
3483
  }
2612
3484
  process.exit(0);
2613
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
+ }
2614
3557
  if (command === "update") {
2615
3558
  const args2 = parseArgs(rest);
2616
3559
  if (args2["--help"] || args2["-h"]) {
@@ -2647,7 +3590,10 @@ try {
2647
3590
  herdrBin: args["--herdr-bin"],
2648
3591
  herdrTimeoutMs: args["--herdr-timeout-ms"],
2649
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 !== ""),
2650
- stateFile: args["--state-file"] === void 0 ? DEFAULT_STATE_FILE : args["--state-file"] === "off" ? void 0 : String(args["--state-file"])
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"])
2651
3597
  }, log);
2652
3598
  const shutdown = () => {
2653
3599
  log.info("agent-comm-hub shutting down");