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