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/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/index.ts
2
2
  import { createServer } from "node:http";
3
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
- import { dirname } from "node:path";
3
+ import { randomBytes as randomBytes2 } from "node:crypto";
4
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
5
+ import { dirname as dirname2 } from "node:path";
5
6
 
6
7
  // src/herdr-ctl.ts
7
8
  import { execFile } from "node:child_process";
@@ -452,6 +453,8 @@ var AgentHub = class {
452
453
  lastSeen = /* @__PURE__ */ new Map();
453
454
  /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
454
455
  profiles = /* @__PURE__ */ new Map();
456
+ /** Named channels (group channel), keyed by group id. */
457
+ groups = /* @__PURE__ */ new Map();
455
458
  gcTimer;
456
459
  /** Stop the idle GC (call when the hub shuts down). */
457
460
  dispose() {
@@ -491,6 +494,7 @@ var AgentHub = class {
491
494
  /** Remove a peer and its queued messages; pending waiters resolve as
492
495
  * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
493
496
  unregister(peerId) {
497
+ if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
494
498
  this.queues.delete(peerId);
495
499
  for (const waiter of this.waiters.get(peerId) ?? []) {
496
500
  clearTimeout(waiter.timer);
@@ -631,9 +635,20 @@ var AgentHub = class {
631
635
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
632
636
  return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
633
637
  }
634
- /** Recent messages involving `peer` (inbound and outbound), newest first. */
638
+ /** Recent messages involving `peer` (inbound and outbound, plus group
639
+ * channels the peer belongs to), newest first. */
635
640
  history(peerId, limit) {
636
- const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
641
+ const memberOf = new Set(
642
+ [...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
643
+ );
644
+ const filtered = this.historyRing.filter(
645
+ (message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
646
+ );
647
+ return filtered.slice(-Math.max(0, limit)).reverse();
648
+ }
649
+ /** Recent messages of one group channel, newest first. */
650
+ historyChannel(groupId, limit) {
651
+ const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
637
652
  return filtered.slice(-Math.max(0, limit)).reverse();
638
653
  }
639
654
  /** Most recent messages across every peer, unfiltered (newest first).
@@ -642,10 +657,175 @@ var AgentHub = class {
642
657
  historyAll(limit) {
643
658
  return this.historyRing.slice(-Math.max(0, limit)).reverse();
644
659
  }
660
+ // ---- groups (group channel) ------------------------------------------------
661
+ /** Create a channel; throws when the id is taken or a member is unknown. */
662
+ createGroup(id, members, createdBy, name) {
663
+ if (this.groups.has(id)) throw new Error(`group already exists: ${id}`);
664
+ if (!PEER_ID_PATTERN.test(id)) throw new Error(`invalid group id: ${id} (expected [A-Za-z0-9._:-]{1,64})`);
665
+ const unique = [...new Set(members)];
666
+ if (unique.length === 0) throw new Error("a group needs at least one member");
667
+ for (const member of unique) {
668
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
669
+ }
670
+ if (!unique.includes(createdBy)) unique.unshift(createdBy);
671
+ const group = { id, members: unique, createdBy, createdAt: Date.now(), ...name !== void 0 && name !== "" ? { name } : {} };
672
+ this.groups.set(id, group);
673
+ this.options.onGroupsChanged?.();
674
+ return group;
675
+ }
676
+ /** Remove a channel (its history rows stay — attribution is unchanged). */
677
+ deleteGroup(id) {
678
+ if (!this.groups.delete(id)) return false;
679
+ this.options.onGroupsChanged?.();
680
+ return true;
681
+ }
682
+ groupOf(id) {
683
+ return this.groups.get(id);
684
+ }
685
+ /** Add a registered peer to an existing channel, so they can send and read
686
+ * it like any other member. Throws when the group is unknown, the peer is
687
+ * not registered, or it is already inside. */
688
+ addGroupMember(groupId, member) {
689
+ const group = this.groups.get(groupId);
690
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
691
+ if (group.members.includes(member)) throw new Error(`peer '${member}' is already a member of group '${groupId}'`);
692
+ if (!this.lastSeen.has(member)) throw new Error(`unknown member peer: ${member}`);
693
+ group.members.push(member);
694
+ this.options.onGroupsChanged?.();
695
+ return group;
696
+ }
697
+ /** Remove a member from a channel. The creator cannot be removed (it would
698
+ * orphan the channel's management). Throws when the group or the membership
699
+ * is missing. */
700
+ removeGroupMember(groupId, member) {
701
+ const group = this.groups.get(groupId);
702
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
703
+ if (member === group.createdBy) throw new Error(`cannot remove the group creator '${member}'`);
704
+ const index = group.members.indexOf(member);
705
+ if (index === -1) throw new Error(`peer '${member}' is not a member of group '${groupId}'`);
706
+ group.members.splice(index, 1);
707
+ this.options.onGroupsChanged?.();
708
+ return group;
709
+ }
710
+ /** All groups (snapshot copy), insertion order. */
711
+ allGroups() {
712
+ return [...this.groups.values()].map((group) => ({ ...group, members: [...group.members] }));
713
+ }
714
+ /** Send a chat message to every member of a group except the sender;
715
+ * members that went offline are skipped (they read the group history).
716
+ * The message carries `to` and `channel` = the group id. */
717
+ sendToGroup(from, groupId, content) {
718
+ const group = this.groups.get(groupId);
719
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
720
+ if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
721
+ const message = {
722
+ id: randomUUID(),
723
+ from,
724
+ to: groupId,
725
+ kind: "chat",
726
+ content,
727
+ channel: groupId,
728
+ ts: Date.now()
729
+ };
730
+ this.lastSeen.set(from, message.ts);
731
+ for (const member of group.members) {
732
+ if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
733
+ }
734
+ return message;
735
+ }
736
+ /** Snapshot of every group for persistence. */
737
+ exportGroups() {
738
+ return this.allGroups();
739
+ }
740
+ /** Load persisted groups at boot (same validation as createGroup, minus
741
+ * the "members must be online" rule — members reconnect later). */
742
+ importGroups(groups) {
743
+ if (!Array.isArray(groups)) return 0;
744
+ let count = 0;
745
+ for (const raw of groups) {
746
+ if (typeof raw !== "object" || raw === null) continue;
747
+ const candidate = raw;
748
+ if (typeof candidate.id !== "string" || !PEER_ID_PATTERN.test(candidate.id) || this.groups.has(candidate.id)) continue;
749
+ if (!Array.isArray(candidate.members) || candidate.members.length === 0) continue;
750
+ const members = [...new Set(candidate.members.filter((m) => typeof m === "string" && PEER_ID_PATTERN.test(m)))];
751
+ if (members.length === 0) continue;
752
+ this.groups.set(candidate.id, {
753
+ id: candidate.id,
754
+ members,
755
+ createdBy: typeof candidate.createdBy === "string" && PEER_ID_PATTERN.test(candidate.createdBy) ? candidate.createdBy : members[0],
756
+ createdAt: typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt) ? candidate.createdAt : Date.now(),
757
+ ...typeof candidate.name === "string" && candidate.name !== "" ? { name: candidate.name } : {}
758
+ });
759
+ count++;
760
+ }
761
+ return count;
762
+ }
763
+ // ---- persistence import/export (remote-mode SQLite store) -----------
764
+ /** Restore the history ring at boot. Shape-invalid rows are dropped; only
765
+ * the newest {@link HubOptions.historyLimit} survive. */
766
+ importHistory(messages) {
767
+ if (!Array.isArray(messages)) return 0;
768
+ const valid = [];
769
+ for (const raw of messages) {
770
+ if (typeof raw !== "object" || raw === null) continue;
771
+ const m = raw;
772
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
773
+ if (KINDS.includes(m.kind) === false) continue;
774
+ valid.push({
775
+ id: m.id,
776
+ from: m.from,
777
+ to: m.to,
778
+ kind: m.kind,
779
+ content: m.content,
780
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
781
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
782
+ ts: m.ts
783
+ });
784
+ }
785
+ for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
786
+ return Math.min(valid.length, this.options.historyLimit);
787
+ }
788
+ /** Snapshot of every offline mailbox, keyed by peer (persistence). */
789
+ exportMailboxes() {
790
+ const out = {};
791
+ for (const [peer, queue] of this.queues) {
792
+ if (queue.length > 0) out[peer] = queue.map((message) => ({ ...message }));
793
+ }
794
+ return out;
795
+ }
796
+ /** Restore offline mailboxes at boot (peer reconnects and drains later). */
797
+ importMailboxes(mailboxes) {
798
+ if (typeof mailboxes !== "object" || mailboxes === null) return 0;
799
+ let count = 0;
800
+ for (const [peer, queue] of Object.entries(mailboxes)) {
801
+ if (!Array.isArray(queue) || queue.length === 0) continue;
802
+ const restored = [];
803
+ for (const raw of queue) {
804
+ if (typeof raw !== "object" || raw === null) continue;
805
+ const m = raw;
806
+ if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
807
+ restored.push({
808
+ id: m.id,
809
+ from: m.from,
810
+ to: typeof m.to === "string" ? m.to : peer,
811
+ kind: KINDS.includes(m.kind) ? m.kind : "chat",
812
+ content: m.content,
813
+ ...typeof m.ref === "string" ? { ref: m.ref } : {},
814
+ ...typeof m.channel === "string" ? { channel: m.channel } : {},
815
+ ts: m.ts
816
+ });
817
+ }
818
+ if (restored.length > 0) {
819
+ this.queues.set(peer, restored.slice(-this.options.maxQueue));
820
+ count += restored.length;
821
+ }
822
+ }
823
+ return count;
824
+ }
645
825
  /**
646
- * Live summary for the status tool. `livePeers` (sessions with a live SSE
647
- * stream) count as connected even without recent tool activity.
648
- */
826
+ * Live summary for the status tool. `livePeers` (sessions with a live SSE
827
+ * stream) count as connected even without recent tool activity.
828
+ */
649
829
  status(livePeers) {
650
830
  const now = Date.now();
651
831
  return {
@@ -669,7 +849,7 @@ var AgentHub = class {
669
849
  poll(peerId, from) {
670
850
  const queue = this.queues.get(peerId) ?? [];
671
851
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
672
- for (const message of drained) this.remember(message);
852
+ if (drained.length > 0) this.options.onMailboxesChanged?.();
673
853
  return drained;
674
854
  }
675
855
  /**
@@ -752,12 +932,15 @@ var AgentHub = class {
752
932
  if (queue.length >= this.options.maxQueue) queue.shift();
753
933
  queue.push(message);
754
934
  this.queues.set(target, queue);
935
+ this.remember(message);
755
936
  this.options.onQueued?.(message, target);
756
937
  }
757
- /** Append a delivered message to the history ring. */
938
+ /** Append a delivered message to the history ring (and fire the
939
+ * persistence hook — one call per message, delivered or queued). */
758
940
  remember(message) {
759
941
  this.historyRing.push(message);
760
942
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
943
+ this.options.onMessage?.(message);
761
944
  }
762
945
  };
763
946
  function drainFrom(queue, from) {
@@ -775,13 +958,159 @@ function omit(value, key) {
775
958
  return rest;
776
959
  }
777
960
 
961
+ // src/auth.ts
962
+ import { randomBytes } from "node:crypto";
963
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
964
+ import { dirname } from "node:path";
965
+ var ANON_JOIN_PEER = "__join__";
966
+ var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
967
+ function loadTokenFile(file) {
968
+ let parsed;
969
+ try {
970
+ parsed = JSON.parse(readFileSync(file, "utf8"));
971
+ } catch (error) {
972
+ throw new Error(`auth token file unreadable: ${file} (${error.message})`);
973
+ }
974
+ const rows = parsed?.tokens;
975
+ if (!Array.isArray(rows)) throw new Error(`auth token file ${file}: expected {"tokens":[...]}`);
976
+ const table = /* @__PURE__ */ new Map();
977
+ const peers = /* @__PURE__ */ new Set();
978
+ rows.forEach((row, index) => {
979
+ const msg = (why) => `auth token file ${file}: entry #${index + 1} ${why}`;
980
+ if (typeof row !== "object" || row === null) throw new Error(msg("is not an object"));
981
+ const { token, peer, role, owner } = row;
982
+ if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
983
+ throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
984
+ }
985
+ if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
986
+ throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
987
+ }
988
+ if (role !== "agent" && role !== "manager") {
989
+ throw new Error(msg(`has an invalid role '${String(role)}' (expected 'agent' | 'manager')`));
990
+ }
991
+ if (owner !== void 0 && typeof owner !== "string") throw new Error(msg("has a non-string owner"));
992
+ if (table.has(token)) throw new Error(msg("duplicates a token"));
993
+ if (peers.has(peer)) {
994
+ throw new Error(msg(`maps a second token to peer '${peer}' (two tokens sharing a peer share its mailbox)`));
995
+ }
996
+ table.set(token, { peer, role, ...typeof owner === "string" && owner !== "" ? { owner } : {} });
997
+ peers.add(peer);
998
+ });
999
+ if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
1000
+ return table;
1001
+ }
1002
+ function tokenTable(map) {
1003
+ return {
1004
+ authenticate: (token) => token === void 0 ? void 0 : map.get(token)
1005
+ };
1006
+ }
1007
+ function watchTokenFile(file, log) {
1008
+ let table = loadTokenFile(file);
1009
+ const reload = () => {
1010
+ try {
1011
+ const next = loadTokenFile(file);
1012
+ table = next;
1013
+ log.info(`auth tokens reloaded: ${table.size} entr(ies)`);
1014
+ } catch (error) {
1015
+ log.warn(`auth token reload ignored, keeping previous table: ${error.message}`);
1016
+ }
1017
+ };
1018
+ log.info(`auth tokens loaded: ${table.size} entr(ies) from ${file}`);
1019
+ let mtime = statMtime(file);
1020
+ const timer = setInterval(() => {
1021
+ const current = statMtime(file);
1022
+ if (current === mtime) return;
1023
+ mtime = current;
1024
+ reload();
1025
+ }, 2e3);
1026
+ timer.unref?.();
1027
+ return { authenticate: (token) => token === void 0 ? void 0 : table.get(token), reload };
1028
+ }
1029
+ function statMtime(file) {
1030
+ try {
1031
+ return statSync(file).mtimeMs;
1032
+ } catch {
1033
+ return 0;
1034
+ }
1035
+ }
1036
+ function bearerFromHeaders(headers) {
1037
+ const raw = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization;
1038
+ if (raw === void 0) return void 0;
1039
+ const match = /^Bearer\s+(.+)$/.exec(raw.trim());
1040
+ return match === null ? void 0 : match[1];
1041
+ }
1042
+ function generateToken() {
1043
+ return randomBytes(24).toString("base64url");
1044
+ }
1045
+ function readTokens(file) {
1046
+ if (!existsSync(file)) return [];
1047
+ try {
1048
+ return [...loadTokenFile(file).entries()].map(([token, id]) => ({
1049
+ token,
1050
+ peer: id.peer,
1051
+ role: id.role,
1052
+ ...id.owner !== void 0 ? { owner: id.owner } : {}
1053
+ }));
1054
+ } catch (error) {
1055
+ try {
1056
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1057
+ if (Array.isArray(parsed.tokens) && parsed.tokens.length === 0) return [];
1058
+ } catch {
1059
+ }
1060
+ throw error;
1061
+ }
1062
+ }
1063
+ function writeTokenFile(file, rows) {
1064
+ mkdirSync(dirname(file), { recursive: true });
1065
+ const tmp = `${file}.tmp`;
1066
+ writeFileSync(tmp, JSON.stringify({ tokens: rows }, null, 2) + "\n", "utf8");
1067
+ renameSync(tmp, file);
1068
+ }
1069
+ function addToken(file, row) {
1070
+ const token = row.token ?? generateToken();
1071
+ if (!TOKEN_PATTERN.test(token)) {
1072
+ throw new Error(`invalid token provided (expect 16-128 chars of [A-Za-z0-9._:-])`);
1073
+ }
1074
+ const peers = new Set(readTokens(file).map((t) => t.peer));
1075
+ if (peers.has(row.peer)) {
1076
+ throw new Error(`peer '${row.peer}' already has a token \u2014 remove it first to rotate (see \`auth remove\`)`);
1077
+ }
1078
+ const owner = row.owner !== void 0 && row.owner !== "" ? row.owner : void 0;
1079
+ const identity = { token, peer: row.peer, role: row.role, ...owner !== void 0 ? { owner } : {} };
1080
+ const next = [...readTokens(file), identity];
1081
+ loadTokenFileVoid(next);
1082
+ writeTokenFile(file, next);
1083
+ return identity;
1084
+ }
1085
+ function removeToken(file, peer) {
1086
+ const tokens = readTokens(file);
1087
+ const kept = tokens.filter((t) => t.peer !== peer);
1088
+ if (kept.length === tokens.length) return false;
1089
+ writeTokenFile(file, kept);
1090
+ return true;
1091
+ }
1092
+ function loadTokenFileVoid(rows) {
1093
+ const table = /* @__PURE__ */ new Map();
1094
+ const peers = /* @__PURE__ */ new Set();
1095
+ for (const { token, peer, role, owner } of rows) {
1096
+ if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
1097
+ if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
1098
+ if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
1099
+ if (table.has(token)) throw new Error("duplicate token");
1100
+ if (peers.has(peer)) throw new Error(`duplicate peer '${peer}'`);
1101
+ table.set(token, { peer, role, ...owner !== void 0 && owner !== "" ? { owner } : {} });
1102
+ peers.add(peer);
1103
+ }
1104
+ }
1105
+
778
1106
  // src/hub-tools.ts
779
1107
  var DEFAULT_WAIT_MS = 3e4;
780
1108
  function present(message) {
781
- const { ref, ...rest } = message;
1109
+ const { ref, channel, ...rest } = message;
782
1110
  return {
783
1111
  ...rest,
784
1112
  ...ref !== void 0 ? { ref } : {},
1113
+ ...channel !== void 0 ? { channel } : {},
785
1114
  content: decodeContent(message.kind, message.content)
786
1115
  };
787
1116
  }
@@ -801,7 +1130,8 @@ function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
801
1130
  const bound = registry.peerFor(sessionId);
802
1131
  if (bound !== void 0) return bound;
803
1132
  if (registry.isSuppressed(sessionId)) return void 0;
804
- const peerId = sanitizePeerId(clientName ?? "agent");
1133
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1134
+ const peerId = tokenPeer === ANON_JOIN_PEER ? `join-${sanitizePeerId(clientName ?? "agent")}-${Math.random().toString(36).slice(2, 6)}` : tokenPeer ?? sanitizePeerId(clientName ?? "agent");
805
1135
  if (!hub.has(peerId)) {
806
1136
  hub.register(peerId, {
807
1137
  ...clientName !== void 0 ? { name: clientName } : {},
@@ -822,16 +1152,31 @@ function livePeersFor(registry) {
822
1152
  }
823
1153
  function rosterFor(hub, registry) {
824
1154
  const status = hub.status(livePeersFor(registry));
1155
+ const peerIps = /* @__PURE__ */ new Map();
1156
+ const peerAnon = /* @__PURE__ */ new Map();
1157
+ for (const [session, peer] of registry.peerBindings) {
1158
+ const ip = registry.sessionIps.get(session);
1159
+ if (ip !== void 0) {
1160
+ const list = peerIps.get(peer) ?? [];
1161
+ if (!list.includes(ip)) list.push(ip);
1162
+ peerIps.set(peer, list);
1163
+ }
1164
+ const identity = registry.tokenFor(session);
1165
+ if (identity?.anonymous === true) peerAnon.set(peer, true);
1166
+ }
825
1167
  return status.peers.map((peer) => {
826
1168
  const profile = hub.profileOf(peer.id);
827
1169
  const alias = profile?.alias;
828
1170
  const clientName = profile?.clientName;
829
1171
  const clientVersion = profile?.clientVersion;
1172
+ const ips = peerIps.get(peer.id);
830
1173
  return {
831
1174
  id: peer.id,
832
1175
  ...alias !== void 0 ? { alias } : {},
833
1176
  ...clientName !== void 0 ? { clientName } : {},
834
1177
  ...clientVersion !== void 0 ? { clientVersion } : {},
1178
+ ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1179
+ ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
835
1180
  connected: peer.connected,
836
1181
  lastSeenMs: peer.lastSeenMs
837
1182
  };
@@ -866,6 +1211,7 @@ function hubTools(hub, registry, options) {
866
1211
  from: message.from,
867
1212
  to: message.to,
868
1213
  kind: message.kind,
1214
+ ...message.channel !== void 0 ? { channel: message.channel } : {},
869
1215
  ts: message.ts
870
1216
  });
871
1217
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -880,7 +1226,8 @@ function hubTools(hub, registry, options) {
880
1226
  }
881
1227
  return herdr;
882
1228
  };
883
- const requireManager = (peer, action) => {
1229
+ const requireManager = (peer, action, sessionId) => {
1230
+ if (registry.tokenFor(sessionId)?.role === "manager") return;
884
1231
  const managers = options.managerPeers ?? "all";
885
1232
  if (managers !== "all" && !managers.has(peer)) {
886
1233
  throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
@@ -912,6 +1259,10 @@ function hubTools(hub, registry, options) {
912
1259
  throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
913
1260
  }
914
1261
  const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
1262
+ const tokenPeer = registry.tokenFor(sessionId)?.peer;
1263
+ if (tokenPeer !== void 0 && peerId !== tokenPeer) {
1264
+ throw new Error(`your identity is bound to your access token ('${tokenPeer}') \u2014 ask a hub manager to rename you`);
1265
+ }
915
1266
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
916
1267
  throw new Error(`peer already registered by another connection: ${peerId}`);
917
1268
  }
@@ -953,7 +1304,7 @@ function hubTools(hub, registry, options) {
953
1304
  const caller = registry.peerFor(sessionId);
954
1305
  if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
955
1306
  const target = String(args.peer);
956
- requireManager(caller, `kicking '${target}'`);
1307
+ requireManager(caller, `kicking '${target}'`, sessionId);
957
1308
  if (!hub.has(target)) {
958
1309
  return { ok: true, peerId: null, kicked: false };
959
1310
  }
@@ -970,14 +1321,14 @@ function hubTools(hub, registry, options) {
970
1321
  peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
971
1322
  peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
972
1323
  }),
973
- handler: wrap(true, async (args, peer) => {
1324
+ handler: wrap(true, async (args, peer, sessionId) => {
974
1325
  const hasAlias = args.alias !== void 0;
975
1326
  const hasPeerId = args.peerId !== void 0;
976
1327
  if (!hasAlias && !hasPeerId) {
977
1328
  throw new Error("nothing to rename: pass alias and/or peerId");
978
1329
  }
979
1330
  let target = args.peer === void 0 ? peer : String(args.peer);
980
- if (target !== peer) requireManager(peer, `renaming '${target}'`);
1331
+ if (target !== peer) requireManager(peer, `renaming '${target}'`, sessionId);
981
1332
  let previousId;
982
1333
  if (hasPeerId) {
983
1334
  if (args.peer === void 0) {
@@ -1078,16 +1429,98 @@ function hubTools(hub, registry, options) {
1078
1429
  name: "bridge_history",
1079
1430
  description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
1080
1431
  inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
1081
- handler: wrap(true, async (args, peer) => {
1432
+ handler: wrap(true, async (args, peer, sessionId) => {
1082
1433
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
1083
1434
  const target = args.peer === void 0 ? peer : String(args.peer);
1084
1435
  if (target !== peer) {
1085
- requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`} requires manager rights`);
1436
+ requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
1437
+ }
1438
+ if (args.channel !== void 0) {
1439
+ const groupId = String(args.channel);
1440
+ const group = hub.groupOf(groupId);
1441
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1442
+ if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
1443
+ return { messages: hub.historyChannel(groupId, limit).map(present) };
1086
1444
  }
1087
1445
  const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
1088
1446
  return { messages: messages.map(present) };
1089
1447
  })
1090
1448
  },
1449
+ // ---- group tools (group channel) ---------------------------------------------
1450
+ // A group routes one message to several members; the message carries
1451
+ // channel = group id. Offline members are skipped (they read history) —
1452
+ // unlike direct sends, which fail on unknown recipients.
1453
+ {
1454
+ name: "bridge_group_create",
1455
+ description: "Create a group channel. You become a member automatically; `members` lists additional peerIds (each must be registered). Members exchange messages via bridge_group_send; anyone in the group reads its history with bridge_history { channel }. Use bridge_group_delete to remove (creator or manager).",
1456
+ inputSchema: schema(
1457
+ {
1458
+ group: str("Unique group id: letters/digits/._:- , 1-64 chars."),
1459
+ name: optStr("Optional display name."),
1460
+ members: { type: "array", items: { type: "string" }, description: "Member peerIds (you are added automatically; duplicates ignored)." }
1461
+ },
1462
+ ["group"]
1463
+ ),
1464
+ handler: wrap(true, async (args, peer) => {
1465
+ const groupId = String(args.group);
1466
+ const members = Array.isArray(args.members) ? args.members.map(String) : [];
1467
+ const group = hub.createGroup(groupId, members, peer, args.name === void 0 ? void 0 : String(args.name));
1468
+ return { ok: true, group: { id: group.id, ...group.name !== void 0 ? { name: group.name } : {}, members: group.members, createdBy: group.createdBy } };
1469
+ })
1470
+ },
1471
+ {
1472
+ name: "bridge_group_send",
1473
+ description: "Send a chat message to every member of a group channel except yourself. Offline members are skipped (they catch up via bridge_history { channel }). You must be a member.",
1474
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), message: str("The message text.") }, ["group", "message"]),
1475
+ handler: wrap(true, async (args, peer) => receipt(hub.sendToGroup(peer, String(args.group), String(args.message))))
1476
+ },
1477
+ {
1478
+ name: "bridge_group_list",
1479
+ description: "List every group channel with its members and creator.",
1480
+ inputSchema: schema({}),
1481
+ handler: wrap(true, async () => ({ groups: hub.allGroups() }))
1482
+ },
1483
+ {
1484
+ name: "bridge_group_delete",
1485
+ description: "Delete a group channel (history rows are kept). The creator or a hub manager may delete.",
1486
+ inputSchema: schema({ group: str("Group id to delete.") }, ["group"]),
1487
+ handler: wrap(true, async (args, peer, sessionId) => {
1488
+ const groupId = String(args.group);
1489
+ const group = hub.groupOf(groupId);
1490
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1491
+ if (group.createdBy !== peer) requireManager(peer, `deleting group '${groupId}'`, sessionId);
1492
+ hub.deleteGroup(groupId);
1493
+ return { ok: true, group: groupId };
1494
+ })
1495
+ },
1496
+ {
1497
+ name: "bridge_group_add_member",
1498
+ description: "Add a registered peer to a group channel; the new member can then send to it and read its history. The creator or a hub manager may add.",
1499
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId of the new member.") }, ["group", "member"]),
1500
+ handler: wrap(true, async (args, peer, sessionId) => {
1501
+ const groupId = String(args.group);
1502
+ const member = String(args.member);
1503
+ const group = hub.groupOf(groupId);
1504
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1505
+ if (group.createdBy !== peer) requireManager(peer, `adding a member to group '${groupId}'`, sessionId);
1506
+ const updated = hub.addGroupMember(groupId, member);
1507
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1508
+ })
1509
+ },
1510
+ {
1511
+ name: "bridge_group_remove_member",
1512
+ description: "Remove a member from a group channel; they stop receiving its messages (history rows are kept). The creator or a hub manager may remove; the creator itself cannot be removed.",
1513
+ inputSchema: schema({ group: str("Group id from bridge_group_create / bridge_group_list."), member: str("peerId to remove.") }, ["group", "member"]),
1514
+ handler: wrap(true, async (args, peer, sessionId) => {
1515
+ const groupId = String(args.group);
1516
+ const member = String(args.member);
1517
+ const group = hub.groupOf(groupId);
1518
+ if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1519
+ if (group.createdBy !== peer) requireManager(peer, `removing a member from group '${groupId}'`, sessionId);
1520
+ const updated = hub.removeGroupMember(groupId, member);
1521
+ return { ok: true, group: { id: updated.id, members: updated.members } };
1522
+ })
1523
+ },
1091
1524
  // ---- herdr control tools ------------------------------------------
1092
1525
  // These type into real agent terminals via the herdr runtime. They are
1093
1526
  // gated by checkControl and documented as physical input: unlike
@@ -1290,6 +1723,16 @@ function hubTools(hub, registry, options) {
1290
1723
 
1291
1724
  // src/mcp-server.ts
1292
1725
  import { randomUUID as randomUUID2 } from "node:crypto";
1726
+ import { readFileSync as readFileSync2 } from "node:fs";
1727
+ var ADMIN_PATH = "/admin";
1728
+ var ADMIN_API_PATH = "/admin/api";
1729
+ var adminPageCache;
1730
+ function adminPage() {
1731
+ if (adminPageCache === void 0) {
1732
+ adminPageCache = readFileSync2(new URL("../assets/admin.html", import.meta.url), "utf8");
1733
+ }
1734
+ return adminPageCache;
1735
+ }
1293
1736
  var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1294
1737
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
1295
1738
  var MAX_BODY_BYTES = 1048576;
@@ -1362,8 +1805,24 @@ var SessionRegistry = class {
1362
1805
  isSuppressed(sessionId) {
1363
1806
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
1364
1807
  }
1808
+ /** Session → client IP (remote mode: the admin roster shows where a peer
1809
+ * connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
1810
+ sessionIps = /* @__PURE__ */ new Map();
1365
1811
  /** Sessions with a live SSE stream (server→client channel). */
1366
1812
  liveStreams = /* @__PURE__ */ new Set();
1813
+ /** Session → token identity (remote auth mode only; see src/auth.ts).
1814
+ * The TOKEN, not the client-reported name, decides the peer id, so two
1815
+ * users running the same client get distinct peers. */
1816
+ tokenBindings = /* @__PURE__ */ new Map();
1817
+ /** Bind a session to its token identity (at initialize / SSE open). */
1818
+ bindToken(sessionId, identity) {
1819
+ this.tokenBindings.set(sessionId, identity);
1820
+ }
1821
+ /** Token identity bound to a session, if remote auth is in use. */
1822
+ tokenFor(sessionId) {
1823
+ if (sessionId === void 0) return void 0;
1824
+ return this.tokenBindings.get(sessionId);
1825
+ }
1367
1826
  /** Mark a session's SSE stream open (server→client channel alive). */
1368
1827
  markSseOpen(sessionId) {
1369
1828
  this.liveStreams.add(sessionId);
@@ -1422,18 +1881,29 @@ var SessionRegistry = class {
1422
1881
  var McpStreamableHttpServer = class {
1423
1882
  constructor(tools, info, registry, log = () => {
1424
1883
  }, onInitialize = () => {
1425
- }) {
1884
+ }, authenticate, adminApi) {
1426
1885
  this.tools = tools;
1427
1886
  this.info = info;
1428
1887
  this.registry = registry;
1429
1888
  this.log = log;
1430
1889
  this.onInitialize = onInitialize;
1890
+ this.authenticate = authenticate;
1891
+ this.adminApi = adminApi;
1431
1892
  }
1432
1893
  sseStreams = /* @__PURE__ */ new Map();
1433
1894
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
1434
1895
  attach(server, path2) {
1435
1896
  server.on("request", (req, res) => {
1436
1897
  const url = new URL(req.url ?? "/", "http://localhost");
1898
+ if (url.pathname === ADMIN_PATH && req.method === "GET") {
1899
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
1900
+ res.end(adminPage());
1901
+ return;
1902
+ }
1903
+ if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
1904
+ void this.handleAdminApi(req, res, url);
1905
+ return;
1906
+ }
1437
1907
  if (url.pathname !== path2) {
1438
1908
  res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
1439
1909
  return;
@@ -1483,7 +1953,14 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1483
1953
  }
1484
1954
  }
1485
1955
  handleGet(req, res) {
1956
+ if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
1957
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1958
+ res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
1959
+ return;
1960
+ }
1486
1961
  const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
1962
+ const identity = this.authenticate?.(req);
1963
+ if (identity !== void 0) this.registry.bindToken(sessionId, identity);
1487
1964
  res.writeHead(200, {
1488
1965
  ...corsHeaders(),
1489
1966
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -1499,7 +1976,79 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1499
1976
  this.registry.markSseClosed(sessionId);
1500
1977
  });
1501
1978
  }
1979
+ /** Reject a request without a valid token (remote auth mode). */
1980
+ unauthorized(res, id, why) {
1981
+ res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1982
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
1983
+ }
1984
+ /** Management API for the bundled admin page: issue/revoke tokens and list
1985
+ * them. Like everything else, each call is authenticated by `authenticate`
1986
+ * and must resolve to a MANAGER role — an agent token gets 403. */
1987
+ async handleAdminApi(req, res, url) {
1988
+ const respond = (status, body) => {
1989
+ res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
1990
+ res.end(JSON.stringify(body));
1991
+ };
1992
+ const identity = this.authenticate?.(req);
1993
+ if (identity === void 0) {
1994
+ respond(401, { error: "unauthorized: manager token required" });
1995
+ return;
1996
+ }
1997
+ if (identity.role !== "manager") {
1998
+ respond(403, { error: "forbidden: this API needs a manager-role token" });
1999
+ return;
2000
+ }
2001
+ const api = this.adminApi;
2002
+ const seg = url.pathname.slice(ADMIN_API_PATH.length).replace(/^\/+|\/+$/g, "").split("/");
2003
+ if (req.method === "GET" && seg[0] === "tokens") {
2004
+ respond(200, { tokens: api.listTokens(url.searchParams.get("reveal") === "1") });
2005
+ return;
2006
+ }
2007
+ if (req.method === "POST" && seg[0] === "tokens" && seg[1] === "add") {
2008
+ let input;
2009
+ try {
2010
+ input = JSON.parse((await readBody(req)).toString("utf8"));
2011
+ } catch (error) {
2012
+ respond(400, { error: `bad json: ${error.message}` });
2013
+ return;
2014
+ }
2015
+ try {
2016
+ const created = api.addToken({
2017
+ peer: String(input.peer ?? ""),
2018
+ role: String(input.role ?? "agent"),
2019
+ owner: input.owner === void 0 ? void 0 : String(input.owner)
2020
+ });
2021
+ respond(200, { created });
2022
+ } catch (error) {
2023
+ respond(400, { error: error.message });
2024
+ }
2025
+ return;
2026
+ }
2027
+ if (req.method === "DELETE" && seg[0] === "tokens" && seg[1] === "remove" && seg[2] !== void 0) {
2028
+ respond(200, { removed: api.removeToken(decodeURIComponent(seg[2])) });
2029
+ return;
2030
+ }
2031
+ respond(404, { error: "not found" });
2032
+ }
1502
2033
  async handlePost(req, res) {
2034
+ let authIdentity;
2035
+ if (this.authenticate !== void 0) {
2036
+ authIdentity = this.authenticate(req);
2037
+ if (authIdentity === void 0) {
2038
+ this.unauthorized(res, null, "missing or invalid bearer token");
2039
+ return;
2040
+ }
2041
+ const existingSession = this.registry.sessionIdFor(req);
2042
+ const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
2043
+ const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
2044
+ if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
2045
+ this.unauthorized(res, null, "token does not match this session");
2046
+ return;
2047
+ }
2048
+ if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
2049
+ this.registry.bindToken(existingSession, authIdentity);
2050
+ }
2051
+ }
1503
2052
  let body;
1504
2053
  try {
1505
2054
  body = await readBody(req);
@@ -1520,12 +2069,13 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1520
2069
  }
1521
2070
  const id = message.id ?? null;
1522
2071
  const sessionId = this.registry.sessionIdFor(req);
2072
+ if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
1523
2073
  if (id === null) {
1524
2074
  res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
1525
2075
  return;
1526
2076
  }
1527
2077
  try {
1528
- const { result, extraHeaders } = await this.dispatch(message, sessionId);
2078
+ const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
1529
2079
  res.writeHead(200, {
1530
2080
  ...corsHeaders(),
1531
2081
  "Content-Type": "application/json; charset=utf-8",
@@ -1538,7 +2088,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1538
2088
  this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
1539
2089
  }
1540
2090
  }
1541
- async dispatch(message, sessionId) {
2091
+ async dispatch(message, sessionId, authIdentity) {
1542
2092
  const method = message.method ?? "";
1543
2093
  switch (method) {
1544
2094
  case "initialize": {
@@ -1549,6 +2099,9 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
1549
2099
  if (clientName !== void 0) {
1550
2100
  this.registry.noteClient(newSessionId, clientName, clientVersion);
1551
2101
  }
2102
+ if (authIdentity !== void 0) {
2103
+ this.registry.bindToken(newSessionId, authIdentity);
2104
+ }
1552
2105
  this.onInitialize(newSessionId, clientName, clientVersion);
1553
2106
  const requested = message.params?.protocolVersion;
1554
2107
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
@@ -1612,6 +2165,14 @@ function rpcError(code, message) {
1612
2165
  function messageProtocolVersion(req) {
1613
2166
  return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
1614
2167
  }
2168
+ function clientIp(req) {
2169
+ const forwarded = req.headers["x-forwarded-for"];
2170
+ const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
2171
+ if (typeof raw === "string" && raw.trim() !== "") {
2172
+ return raw.split(",")[0].trim();
2173
+ }
2174
+ return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
2175
+ }
1615
2176
  function corsHeaders() {
1616
2177
  return {
1617
2178
  "Access-Control-Allow-Origin": "*",
@@ -1637,9 +2198,149 @@ function readBody(req) {
1637
2198
  });
1638
2199
  }
1639
2200
 
2201
+ // src/persist.ts
2202
+ import { DatabaseSync } from "node:sqlite";
2203
+ var KIND_NAMES = ["chat", "task", "notice", "ack"];
2204
+ function messageToRow(message) {
2205
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
2206
+ }
2207
+ function rowToMessage(row) {
2208
+ const id = row.id;
2209
+ const from = row.from_peer;
2210
+ const to = row.to_peer;
2211
+ const kind = row.kind;
2212
+ const content = row.content;
2213
+ const ts = row.ts;
2214
+ if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof content !== "string" || typeof ts !== "number") return void 0;
2215
+ if (!KIND_NAMES.includes(kind)) return void 0;
2216
+ return {
2217
+ id,
2218
+ from,
2219
+ to,
2220
+ kind,
2221
+ content,
2222
+ ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2223
+ ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2224
+ ts
2225
+ };
2226
+ }
2227
+ var SQLiteStateStore = class {
2228
+ constructor(file, historyLimit, log) {
2229
+ this.historyLimit = historyLimit;
2230
+ this.db = new DatabaseSync(file);
2231
+ this.db.exec("PRAGMA journal_mode = WAL");
2232
+ this.db.exec(`
2233
+ CREATE TABLE IF NOT EXISTS profiles (
2234
+ id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
2235
+ );
2236
+ CREATE TABLE IF NOT EXISTS messages (
2237
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2238
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2239
+ );
2240
+ CREATE TABLE IF NOT EXISTS mailboxes (
2241
+ peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2242
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2243
+ );
2244
+ CREATE TABLE IF NOT EXISTS groups (
2245
+ id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2246
+ );
2247
+ `);
2248
+ log.info(`sqlite state open: ${file}`);
2249
+ }
2250
+ db;
2251
+ buffer = [];
2252
+ /** Buffer one history message (called per message via the hub hook). */
2253
+ appendMessage(message) {
2254
+ this.buffer.push(message);
2255
+ }
2256
+ /** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
2257
+ flush(snapshot) {
2258
+ const insert = this.db.prepare(
2259
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
2260
+ );
2261
+ for (const message of this.buffer.splice(0, this.buffer.length)) {
2262
+ insert.run(...messageToRow(message));
2263
+ }
2264
+ this.db.exec("DELETE FROM mailboxes");
2265
+ const insertMail = this.db.prepare(
2266
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2267
+ );
2268
+ for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2269
+ for (const message of queue) insertMail.run(peer, ...messageToRow(message));
2270
+ }
2271
+ this.db.exec("DELETE FROM profiles");
2272
+ const insertProfile = this.db.prepare(
2273
+ "INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
2274
+ );
2275
+ for (const profile of snapshot.profiles) {
2276
+ insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
2277
+ }
2278
+ this.db.exec("DELETE FROM groups");
2279
+ const insertGroup = this.db.prepare(
2280
+ "INSERT INTO groups (id, name, members, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
2281
+ );
2282
+ for (const group of snapshot.groups) {
2283
+ insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
2284
+ }
2285
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
2286
+ const total = Number(row.n);
2287
+ if (total > this.historyLimit * 2) {
2288
+ this.db.exec(`DELETE FROM messages WHERE seq <= (SELECT MAX(seq) FROM messages) - ${this.historyLimit}`);
2289
+ }
2290
+ }
2291
+ /** Read everything persisted so far (boot restore). */
2292
+ load() {
2293
+ const profiles = [];
2294
+ for (const row of this.db.prepare("SELECT * FROM profiles").all()) {
2295
+ if (typeof row.id !== "string") continue;
2296
+ profiles.push({
2297
+ id: row.id,
2298
+ ...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
2299
+ ...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
2300
+ ...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
2301
+ registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
2302
+ });
2303
+ }
2304
+ const messages = [];
2305
+ for (const row of this.db.prepare("SELECT * FROM messages ORDER BY seq").all()) {
2306
+ const message = rowToMessage(row);
2307
+ if (message !== void 0) messages.push(message);
2308
+ }
2309
+ const mailboxes = {};
2310
+ for (const row of this.db.prepare("SELECT * FROM mailboxes ORDER BY seq").all()) {
2311
+ if (typeof row.peer !== "string") continue;
2312
+ const message = rowToMessage(row);
2313
+ if (message === void 0) continue;
2314
+ (mailboxes[row.peer] ??= []).push(message);
2315
+ }
2316
+ const groups = [];
2317
+ for (const row of this.db.prepare("SELECT * FROM groups").all()) {
2318
+ if (typeof row.id !== "string" || typeof row.members !== "string") continue;
2319
+ try {
2320
+ const members = JSON.parse(row.members);
2321
+ if (!Array.isArray(members) || members.length === 0) continue;
2322
+ groups.push({
2323
+ id: row.id,
2324
+ members: members.filter((m) => typeof m === "string"),
2325
+ createdBy: typeof row.created_by === "string" ? row.created_by : "",
2326
+ createdAt: typeof row.created_at === "number" ? row.created_at : Date.now(),
2327
+ ...typeof row.name === "string" && row.name !== "" ? { name: row.name } : {}
2328
+ });
2329
+ } catch {
2330
+ }
2331
+ }
2332
+ return { profiles, messages, mailboxes, groups };
2333
+ }
2334
+ /** Final flush + close (hub shutdown). */
2335
+ close(snapshot) {
2336
+ this.flush(snapshot);
2337
+ this.db.close();
2338
+ }
2339
+ };
2340
+
1640
2341
  // src/index.ts
1641
2342
  var SERVER_NAME = "agent-comm-hub";
1642
- var SERVER_VERSION = "0.6.0";
2343
+ var SERVER_VERSION = "0.7.1";
1643
2344
  var DEFAULT_HOST = "127.0.0.1";
1644
2345
  var DEFAULT_PORT = 18764;
1645
2346
  var DEFAULT_PATH = "/mcp";
@@ -1661,16 +2362,39 @@ var DEFAULT_CONFIG = {
1661
2362
  // are NOT managers — they keep chatting, a GUI manages.
1662
2363
  managerPeers: ["agent-hub-cli"]
1663
2364
  };
2365
+ function authTokensAdmin(file, reloadAuth) {
2366
+ if (file === void 0) return void 0;
2367
+ return {
2368
+ listTokens: (reveal) => readTokens(file).map((r) => ({
2369
+ token: reveal ? r.token : `${r.token.slice(0, 6)}\u2026${r.token.slice(-4)}`,
2370
+ peer: r.peer,
2371
+ role: r.role,
2372
+ ...r.owner !== void 0 ? { owner: r.owner } : {}
2373
+ })),
2374
+ addToken: (input) => {
2375
+ const row = addToken(file, { peer: input.peer, role: input.role, owner: input.owner });
2376
+ reloadAuth?.();
2377
+ return { token: row.token, peer: row.peer, role: row.role, ...row.owner !== void 0 ? { owner: row.owner } : {} };
2378
+ },
2379
+ removeToken: (peer) => {
2380
+ const ok = removeToken(file, peer);
2381
+ if (ok) reloadAuth?.();
2382
+ return ok;
2383
+ },
2384
+ reloadAuth: () => reloadAuth?.()
2385
+ };
2386
+ }
1664
2387
  function writeRosterFile(file, profiles) {
1665
- mkdirSync(dirname(file), { recursive: true });
2388
+ mkdirSync2(dirname2(file), { recursive: true });
1666
2389
  const tmp = `${file}.tmp`;
1667
- writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
1668
- renameSync(tmp, file);
2390
+ writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2391
+ renameSync2(tmp, file);
1669
2392
  }
1670
2393
  function startHub(config = {}, log = console) {
1671
2394
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1672
2395
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
1673
2396
  let mcp;
2397
+ let dbStore;
1674
2398
  const pushRoster = () => {
1675
2399
  mcp.notifyAll("notifications/message", {
1676
2400
  level: "info",
@@ -1685,24 +2409,51 @@ function startHub(config = {}, log = console) {
1685
2409
  data: { event: "message", message: present(message) }
1686
2410
  });
1687
2411
  };
2412
+ const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
2413
+ const pushRelay = (message) => {
2414
+ const managers = [];
2415
+ for (const sessionId of registry.liveSessions()) {
2416
+ const peer = registry.peerFor(sessionId);
2417
+ if (peer === void 0) continue;
2418
+ if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
2419
+ managers.push(sessionId);
2420
+ }
2421
+ }
2422
+ if (managers.length > 0) {
2423
+ mcp.notify(managers, "notifications/message", {
2424
+ level: "info",
2425
+ logger: "bridge",
2426
+ data: { event: "message", message: present(message) }
2427
+ });
2428
+ }
2429
+ };
1688
2430
  let saveTimer;
1689
2431
  let rosterDirty = false;
1690
- const flushRoster = () => {
1691
- if (resolved.stateFile === void 0 || !rosterDirty) return;
1692
- rosterDirty = false;
1693
- try {
1694
- writeRosterFile(resolved.stateFile, hub.exportProfiles());
1695
- } catch (error) {
1696
- log.warn(`roster save failed: ${error.message}`);
2432
+ let dbDirty = false;
2433
+ const flushSaves = () => {
2434
+ if (rosterDirty && resolved.stateFile !== void 0) {
2435
+ rosterDirty = false;
2436
+ try {
2437
+ writeRosterFile(resolved.stateFile, hub.exportProfiles());
2438
+ } catch (error) {
2439
+ log.warn(`roster save failed: ${error.message}`);
2440
+ }
2441
+ }
2442
+ if (dbDirty && dbStore !== void 0) {
2443
+ dbDirty = false;
2444
+ try {
2445
+ dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2446
+ } catch (error) {
2447
+ log.warn(`sqlite flush failed: ${error.message}`);
2448
+ }
1697
2449
  }
1698
2450
  };
1699
- const scheduleRosterSave = () => {
1700
- if (resolved.stateFile === void 0) return;
1701
- rosterDirty = true;
2451
+ const scheduleSave = () => {
2452
+ if (resolved.stateFile === void 0 && dbStore === void 0) return;
1702
2453
  if (saveTimer !== void 0) return;
1703
2454
  saveTimer = setTimeout(() => {
1704
2455
  saveTimer = void 0;
1705
- flushRoster();
2456
+ flushSaves();
1706
2457
  }, 500);
1707
2458
  saveTimer.unref?.();
1708
2459
  };
@@ -1713,6 +2464,30 @@ function startHub(config = {}, log = console) {
1713
2464
  connectedWindowMs: resolved.connectedWindowMs,
1714
2465
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1715
2466
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
2467
+ // Remote persistence: buffer every history append; re-snapshot when a
2468
+ // mailbox is consumed so drained messages cannot resurrect on restart.
2469
+ // Also relay every message to manager sessions (the relay stream view) —
2470
+ // best-effort; a notification failure must never break routing.
2471
+ onMessage: (message) => {
2472
+ if (dbStore !== void 0) {
2473
+ dbStore.appendMessage(message);
2474
+ dbDirty = true;
2475
+ }
2476
+ try {
2477
+ pushRelay(message);
2478
+ } catch (error) {
2479
+ log.warn(`relay notification failed: ${error.message}`);
2480
+ }
2481
+ },
2482
+ onMailboxesChanged: () => {
2483
+ if (dbStore === void 0) return;
2484
+ dbDirty = true;
2485
+ },
2486
+ onGroupsChanged: () => {
2487
+ if (dbStore === void 0) return;
2488
+ dbDirty = true;
2489
+ scheduleSave();
2490
+ },
1716
2491
  // The idle GC must never evict a peer whose session has a live SSE channel.
1717
2492
  isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
1718
2493
  // SSE event push: roster changes and queued-mail hints. Best-effort — a
@@ -1723,7 +2498,9 @@ function startHub(config = {}, log = console) {
1723
2498
  } catch (error) {
1724
2499
  log.warn(`peers_changed notification failed: ${error.message}`);
1725
2500
  }
1726
- scheduleRosterSave();
2501
+ rosterDirty = true;
2502
+ dbDirty = true;
2503
+ scheduleSave();
1727
2504
  },
1728
2505
  onQueued: (message, target) => {
1729
2506
  try {
@@ -1734,9 +2511,39 @@ function startHub(config = {}, log = console) {
1734
2511
  }
1735
2512
  });
1736
2513
  const registry = new SessionRegistry();
1737
- if (resolved.stateFile !== void 0) {
2514
+ if (resolved.db !== void 0) {
2515
+ try {
2516
+ dbStore = new SQLiteStateStore(resolved.db, resolved.historyLimit, log);
2517
+ const state = dbStore.load();
2518
+ const importedProfiles = hub.importProfiles(state.profiles);
2519
+ if (state.profiles.length === 0 && resolved.stateFile !== void 0) {
2520
+ try {
2521
+ const legacy = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
2522
+ const migrated = hub.importProfiles(legacy.profiles);
2523
+ if (migrated > 0) log.info(`roster migrated: ${migrated} profile(s) from ${resolved.stateFile} into ${resolved.db}`);
2524
+ } catch (error) {
2525
+ if (error.code !== "ENOENT") {
2526
+ log.warn(`legacy roster import failed: ${error.message}`);
2527
+ }
2528
+ }
2529
+ } else if (importedProfiles > 0) {
2530
+ log.info(`roster restored: ${importedProfiles} profile(s) from ${resolved.db}`);
2531
+ }
2532
+ const restoredMessages = hub.importHistory(state.messages);
2533
+ const restoredQueued = hub.importMailboxes(state.mailboxes);
2534
+ const restoredGroups = hub.importGroups(state.groups);
2535
+ if (restoredMessages + restoredQueued + restoredGroups > 0) {
2536
+ log.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
2537
+ dbDirty = true;
2538
+ }
2539
+ } catch (error) {
2540
+ dbStore = void 0;
2541
+ log.warn(`sqlite state unavailable (${error.message}); running memory-only`);
2542
+ }
2543
+ }
2544
+ if (dbStore === void 0 && resolved.stateFile !== void 0) {
1738
2545
  try {
1739
- const parsed = JSON.parse(readFileSync(resolved.stateFile, "utf8"));
2546
+ const parsed = JSON.parse(readFileSync3(resolved.stateFile, "utf8"));
1740
2547
  const imported = hub.importProfiles(parsed.profiles);
1741
2548
  if (imported > 0) log.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
1742
2549
  } catch (error) {
@@ -1752,6 +2559,25 @@ function startHub(config = {}, log = console) {
1752
2559
  socketPath: resolved.herdrSocketPath,
1753
2560
  sendRequest: resolved.herdrSendRequest
1754
2561
  });
2562
+ let auth;
2563
+ let authReload;
2564
+ if (resolved.authTokens !== void 0) {
2565
+ const table = watchTokenFile(resolved.authTokens, {
2566
+ info: (message) => log.info(message),
2567
+ warn: (message) => log.warn(message)
2568
+ });
2569
+ authReload = table.reload;
2570
+ auth = (req) => {
2571
+ const presented = bearerFromHeaders(req.headers);
2572
+ if (presented !== void 0) return table.authenticate(presented);
2573
+ if (resolved.allowJoin === true) {
2574
+ return { peer: ANON_JOIN_PEER, role: "agent", anonymous: true };
2575
+ }
2576
+ return void 0;
2577
+ };
2578
+ } else if (resolved.allowJoin === true) {
2579
+ auth = (req) => ({ peer: ANON_JOIN_PEER, role: "agent", anonymous: true });
2580
+ }
1755
2581
  mcp = new McpStreamableHttpServer(
1756
2582
  hubTools(hub, registry, {
1757
2583
  defaultWaitMs: resolved.defaultWaitMs,
@@ -1765,12 +2591,26 @@ function startHub(config = {}, log = console) {
1765
2591
  (message) => log.warn(message),
1766
2592
  (sessionId, clientName, clientVersion) => {
1767
2593
  try {
2594
+ const identity = registry.tokenFor(sessionId);
2595
+ if (identity?.anonymous === true && identity.peer === ANON_JOIN_PEER) {
2596
+ let candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2597
+ while (hub.has(candidate)) {
2598
+ candidate = `join-${sanitizePeerId(clientName ?? "agent")}-${randomBytes2(2).toString("hex")}`;
2599
+ }
2600
+ registry.bindToken(sessionId, { ...identity, peer: candidate });
2601
+ }
1768
2602
  const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
1769
- if (peer !== void 0) log.info(`peer joined: ${peer}`);
2603
+ if (peer !== void 0) {
2604
+ const owner = registry.tokenFor(sessionId)?.owner;
2605
+ const anon = registry.tokenFor(sessionId)?.anonymous === true ? " (anonymous join)" : "";
2606
+ log.info(`peer joined: ${peer}${owner !== void 0 ? ` (owner: ${owner})` : ""}${anon}`);
2607
+ }
1770
2608
  } catch (error) {
1771
2609
  log.warn(`auto-register failed: ${error.message}`);
1772
2610
  }
1773
- }
2611
+ },
2612
+ auth,
2613
+ authTokensAdmin(resolved.authTokens, authReload)
1774
2614
  );
1775
2615
  const server = createServer();
1776
2616
  mcp.attach(server, resolved.path);
@@ -1790,7 +2630,15 @@ function startHub(config = {}, log = console) {
1790
2630
  clearTimeout(saveTimer);
1791
2631
  saveTimer = void 0;
1792
2632
  }
1793
- flushRoster();
2633
+ flushSaves();
2634
+ if (dbStore !== void 0) {
2635
+ try {
2636
+ dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2637
+ } catch (error) {
2638
+ log.warn(`sqlite close failed: ${error.message}`);
2639
+ }
2640
+ dbStore = void 0;
2641
+ }
1794
2642
  server.closeAllConnections?.();
1795
2643
  server.close();
1796
2644
  }
@@ -1810,13 +2658,21 @@ export {
1810
2658
  PEER_ID_PATTERN,
1811
2659
  SERVER_NAME,
1812
2660
  SERVER_VERSION,
2661
+ SQLiteStateStore,
1813
2662
  SessionRegistry,
2663
+ addToken,
1814
2664
  autoRegisterPeer,
1815
2665
  decodeContent,
1816
2666
  encodeContent,
2667
+ generateToken,
1817
2668
  hubTools,
2669
+ loadTokenFile,
1818
2670
  present,
2671
+ readTokens,
2672
+ removeToken,
1819
2673
  sanitizeAlias,
1820
2674
  sanitizePeerId,
1821
- startHub
2675
+ startHub,
2676
+ tokenTable,
2677
+ watchTokenFile
1822
2678
  };