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