agent-comm-hub 0.4.0 → 0.6.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 +472 -415
- package/README.zh.md +327 -289
- package/agents/SKILL.md +3 -1
- package/assets/ach-cn.png +0 -0
- package/assets/ach-en.png +0 -0
- package/lib/cli.js +500 -108
- package/lib/index.js +438 -52
- package/lib/setup.js +27 -34
- package/package.json +44 -46
package/lib/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
3
5
|
|
|
4
6
|
// src/herdr-ctl.ts
|
|
5
7
|
import { execFile } from "node:child_process";
|
|
@@ -433,6 +435,7 @@ function decodeContent(kind, content) {
|
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
// src/hub.ts
|
|
438
|
+
var MAX_PROFILES = 512;
|
|
436
439
|
var AgentHub = class {
|
|
437
440
|
constructor(options) {
|
|
438
441
|
this.options = options;
|
|
@@ -447,6 +450,8 @@ var AgentHub = class {
|
|
|
447
450
|
waiters = /* @__PURE__ */ new Map();
|
|
448
451
|
historyRing = [];
|
|
449
452
|
lastSeen = /* @__PURE__ */ new Map();
|
|
453
|
+
/** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
|
|
454
|
+
profiles = /* @__PURE__ */ new Map();
|
|
450
455
|
gcTimer;
|
|
451
456
|
/** Stop the idle GC (call when the hub shuts down). */
|
|
452
457
|
dispose() {
|
|
@@ -466,13 +471,25 @@ var AgentHub = class {
|
|
|
466
471
|
peers() {
|
|
467
472
|
return [...this.lastSeen.keys()];
|
|
468
473
|
}
|
|
469
|
-
/** Register a peer; throws if the id is already taken.
|
|
470
|
-
|
|
474
|
+
/** Register a peer; throws if the id is already taken. Client metadata
|
|
475
|
+
* (when provided) refreshes the profile — with same-name sessions sharing a
|
|
476
|
+
* peer, the freshest connect wins, which self-heals stale metadata. */
|
|
477
|
+
register(peerId, client) {
|
|
471
478
|
if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
|
|
472
479
|
this.lastSeen.set(peerId, Date.now());
|
|
480
|
+
const existing = this.profiles.get(peerId);
|
|
481
|
+
this.profiles.set(peerId, {
|
|
482
|
+
...existing,
|
|
483
|
+
id: peerId,
|
|
484
|
+
registeredAt: existing?.registeredAt ?? Date.now(),
|
|
485
|
+
...client?.name !== void 0 ? { clientName: client.name } : existing?.clientName !== void 0 ? { clientName: existing.clientName } : {},
|
|
486
|
+
...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
|
|
487
|
+
});
|
|
488
|
+
this.evictOverflowProfiles();
|
|
473
489
|
this.options.onPeersChanged?.(this.peers());
|
|
474
490
|
}
|
|
475
|
-
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
491
|
+
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
492
|
+
* timeouts. The profile is kept so a reconnecting peer keeps its alias. */
|
|
476
493
|
unregister(peerId) {
|
|
477
494
|
this.queues.delete(peerId);
|
|
478
495
|
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
@@ -483,6 +500,110 @@ var AgentHub = class {
|
|
|
483
500
|
this.lastSeen.delete(peerId);
|
|
484
501
|
this.options.onPeersChanged?.(this.peers());
|
|
485
502
|
}
|
|
503
|
+
/** Identity metadata for `peerId`, if the hub ever saw it. */
|
|
504
|
+
profileOf(peerId) {
|
|
505
|
+
return this.profiles.get(peerId);
|
|
506
|
+
}
|
|
507
|
+
/** Snapshot of every known profile (registered or not) for persistence. */
|
|
508
|
+
exportProfiles() {
|
|
509
|
+
return [...this.profiles.values()].map((profile) => ({ ...profile }));
|
|
510
|
+
}
|
|
511
|
+
/** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
|
|
512
|
+
* and non-finite fields are sanitized. Returns the imported count. Silent
|
|
513
|
+
* for roster observers — this runs before the server accepts requests. */
|
|
514
|
+
importProfiles(profiles) {
|
|
515
|
+
if (!Array.isArray(profiles)) return 0;
|
|
516
|
+
let count = 0;
|
|
517
|
+
for (const raw of profiles) {
|
|
518
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
519
|
+
const candidate = raw;
|
|
520
|
+
const id = candidate.id;
|
|
521
|
+
if (typeof id !== "string" || !PEER_ID_PATTERN.test(id) || id === BROADCAST) continue;
|
|
522
|
+
if (this.profiles.has(id)) continue;
|
|
523
|
+
const profile = {
|
|
524
|
+
id,
|
|
525
|
+
registeredAt: typeof candidate.registeredAt === "number" && Number.isFinite(candidate.registeredAt) ? candidate.registeredAt : Date.now()
|
|
526
|
+
};
|
|
527
|
+
if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
|
|
528
|
+
if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
|
|
529
|
+
if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
|
|
530
|
+
this.profiles.set(id, profile);
|
|
531
|
+
count++;
|
|
532
|
+
}
|
|
533
|
+
this.evictOverflowProfiles();
|
|
534
|
+
return count;
|
|
535
|
+
}
|
|
536
|
+
/** Set (or clear with `undefined`) the display alias of a registered peer.
|
|
537
|
+
* Routing state is untouched — this only changes how the peer is shown. */
|
|
538
|
+
setAlias(peerId, alias) {
|
|
539
|
+
if (!this.lastSeen.has(peerId)) throw new Error(`unknown peer: ${peerId}`);
|
|
540
|
+
const profile = this.profiles.get(peerId);
|
|
541
|
+
if (profile !== void 0) {
|
|
542
|
+
this.profiles.set(peerId, alias === void 0 ? omit(profile, "alias") : { ...profile, alias });
|
|
543
|
+
}
|
|
544
|
+
this.options.onPeersChanged?.(this.peers());
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Atomically re-key a registered peer: mailbox, waiters, last-seen,
|
|
548
|
+
* profile, session bindings (via the caller's registry), and history
|
|
549
|
+
* attribution move from `oldId` to `newId` in one step, so queued
|
|
550
|
+
* messages, pending waits, ack routing, and history continuity all
|
|
551
|
+
* survive the rename. All validation happens before the first mutation.
|
|
552
|
+
*/
|
|
553
|
+
renamePeer(oldId, newId) {
|
|
554
|
+
if (oldId === newId) return;
|
|
555
|
+
if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
|
|
556
|
+
if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
|
|
557
|
+
if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
558
|
+
if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
|
|
559
|
+
const queue = this.queues.get(oldId);
|
|
560
|
+
const waiters = this.waiters.get(oldId);
|
|
561
|
+
const seen = this.lastSeen.get(oldId) ?? Date.now();
|
|
562
|
+
const profile = this.profiles.get(oldId);
|
|
563
|
+
const stale = this.profiles.get(newId);
|
|
564
|
+
this.queues.delete(oldId);
|
|
565
|
+
this.waiters.delete(oldId);
|
|
566
|
+
this.lastSeen.delete(oldId);
|
|
567
|
+
this.profiles.delete(oldId);
|
|
568
|
+
if (queue !== void 0) this.queues.set(newId, queue);
|
|
569
|
+
if (waiters !== void 0) this.waiters.set(newId, waiters);
|
|
570
|
+
this.lastSeen.set(newId, seen);
|
|
571
|
+
if (profile !== void 0) {
|
|
572
|
+
const merged = {
|
|
573
|
+
...stale,
|
|
574
|
+
...profile,
|
|
575
|
+
id: newId,
|
|
576
|
+
registeredAt: stale !== void 0 && stale.registeredAt < profile.registeredAt ? stale.registeredAt : profile.registeredAt
|
|
577
|
+
};
|
|
578
|
+
this.profiles.set(newId, merged);
|
|
579
|
+
} else if (stale !== void 0) {
|
|
580
|
+
this.profiles.set(newId, stale);
|
|
581
|
+
} else {
|
|
582
|
+
this.profiles.set(newId, { id: newId, registeredAt: Date.now() });
|
|
583
|
+
}
|
|
584
|
+
for (const list of this.waiters.values()) {
|
|
585
|
+
for (const waiter of list) {
|
|
586
|
+
if (waiter.from === oldId) waiter.from = newId;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const message of this.historyRing) {
|
|
590
|
+
if (message.from === oldId) message.from = newId;
|
|
591
|
+
if (message.to === oldId) message.to = newId;
|
|
592
|
+
}
|
|
593
|
+
this.options.onPeersChanged?.(this.peers());
|
|
594
|
+
}
|
|
595
|
+
/** Keep the profile map bounded: drop the oldest profiles of peers that are
|
|
596
|
+
* NOT currently registered (live peers are never evicted). */
|
|
597
|
+
evictOverflowProfiles() {
|
|
598
|
+
if (this.profiles.size <= MAX_PROFILES) return;
|
|
599
|
+
const evictable = [...this.profiles.values()].filter((profile) => !this.lastSeen.has(profile.id)).sort((a, b) => a.registeredAt - b.registeredAt);
|
|
600
|
+
let overflow = this.profiles.size - MAX_PROFILES;
|
|
601
|
+
for (const profile of evictable) {
|
|
602
|
+
if (overflow <= 0) break;
|
|
603
|
+
this.profiles.delete(profile.id);
|
|
604
|
+
overflow--;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
486
607
|
/** Mark activity for `peer` (called on every tool call from that peer). */
|
|
487
608
|
touch(peerId) {
|
|
488
609
|
if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
|
|
@@ -515,6 +636,12 @@ var AgentHub = class {
|
|
|
515
636
|
const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
|
|
516
637
|
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
517
638
|
}
|
|
639
|
+
/** Most recent messages across every peer, unfiltered (newest first).
|
|
640
|
+
* Backs `bridge_history { peer: "all" }` — lets an archiver (the desktop
|
|
641
|
+
* app) capture peer-to-peer traffic it is not a party of. */
|
|
642
|
+
historyAll(limit) {
|
|
643
|
+
return this.historyRing.slice(-Math.max(0, limit)).reverse();
|
|
644
|
+
}
|
|
518
645
|
/**
|
|
519
646
|
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
520
647
|
* stream) count as connected even without recent tool activity.
|
|
@@ -523,13 +650,17 @@ var AgentHub = class {
|
|
|
523
650
|
const now = Date.now();
|
|
524
651
|
return {
|
|
525
652
|
server: "agent-comm-hub",
|
|
526
|
-
peers: this.peers().map((peer) =>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
653
|
+
peers: this.peers().map((peer) => {
|
|
654
|
+
const alias = this.profiles.get(peer)?.alias;
|
|
655
|
+
return {
|
|
656
|
+
id: peer,
|
|
657
|
+
...alias !== void 0 ? { alias } : {},
|
|
658
|
+
connected: this.isActive(peer) || livePeers?.has(peer) === true,
|
|
659
|
+
lastSeenMs: this.lastSeen.get(peer) ?? 0,
|
|
660
|
+
queued: (this.queues.get(peer) ?? []).length,
|
|
661
|
+
waiting: (this.waiters.get(peer) ?? []).length
|
|
662
|
+
};
|
|
663
|
+
}),
|
|
533
664
|
historyLimit: this.options.historyLimit,
|
|
534
665
|
maxQueue: this.options.maxQueue
|
|
535
666
|
};
|
|
@@ -621,7 +752,7 @@ var AgentHub = class {
|
|
|
621
752
|
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
622
753
|
queue.push(message);
|
|
623
754
|
this.queues.set(target, queue);
|
|
624
|
-
this.options.onQueued?.(message);
|
|
755
|
+
this.options.onQueued?.(message, target);
|
|
625
756
|
}
|
|
626
757
|
/** Append a delivered message to the history ring. */
|
|
627
758
|
remember(message) {
|
|
@@ -639,6 +770,10 @@ function drainFrom(queue, from) {
|
|
|
639
770
|
queue.splice(0, queue.length, ...kept);
|
|
640
771
|
return drained;
|
|
641
772
|
}
|
|
773
|
+
function omit(value, key) {
|
|
774
|
+
const { [key]: _dropped, ...rest } = value;
|
|
775
|
+
return rest;
|
|
776
|
+
}
|
|
642
777
|
|
|
643
778
|
// src/hub-tools.ts
|
|
644
779
|
var DEFAULT_WAIT_MS = 3e4;
|
|
@@ -654,13 +789,25 @@ function sanitizePeerId(name) {
|
|
|
654
789
|
const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
655
790
|
return cleaned === "" ? "agent" : cleaned;
|
|
656
791
|
}
|
|
657
|
-
function
|
|
792
|
+
function sanitizeAlias(alias) {
|
|
793
|
+
const cleaned = alias.trim();
|
|
794
|
+
if (cleaned === "") throw new Error("alias must not be empty");
|
|
795
|
+
if (cleaned.length > 64) throw new Error(`alias too long: ${cleaned.length} chars (max 64)`);
|
|
796
|
+
if (/[\u0000-\u001f\u007f]/.test(cleaned)) throw new Error("alias must not contain control characters");
|
|
797
|
+
return cleaned;
|
|
798
|
+
}
|
|
799
|
+
function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
|
|
658
800
|
if (sessionId === void 0) return void 0;
|
|
659
801
|
const bound = registry.peerFor(sessionId);
|
|
660
802
|
if (bound !== void 0) return bound;
|
|
661
803
|
if (registry.isSuppressed(sessionId)) return void 0;
|
|
662
804
|
const peerId = sanitizePeerId(clientName ?? "agent");
|
|
663
|
-
if (!hub.has(peerId))
|
|
805
|
+
if (!hub.has(peerId)) {
|
|
806
|
+
hub.register(peerId, {
|
|
807
|
+
...clientName !== void 0 ? { name: clientName } : {},
|
|
808
|
+
...clientVersion !== void 0 ? { version: clientVersion } : {}
|
|
809
|
+
});
|
|
810
|
+
}
|
|
664
811
|
registry.bindPeer(sessionId, peerId);
|
|
665
812
|
hub.touch(peerId);
|
|
666
813
|
return peerId;
|
|
@@ -673,6 +820,23 @@ function livePeersFor(registry) {
|
|
|
673
820
|
}
|
|
674
821
|
return live;
|
|
675
822
|
}
|
|
823
|
+
function rosterFor(hub, registry) {
|
|
824
|
+
const status = hub.status(livePeersFor(registry));
|
|
825
|
+
return status.peers.map((peer) => {
|
|
826
|
+
const profile = hub.profileOf(peer.id);
|
|
827
|
+
const alias = profile?.alias;
|
|
828
|
+
const clientName = profile?.clientName;
|
|
829
|
+
const clientVersion = profile?.clientVersion;
|
|
830
|
+
return {
|
|
831
|
+
id: peer.id,
|
|
832
|
+
...alias !== void 0 ? { alias } : {},
|
|
833
|
+
...clientName !== void 0 ? { clientName } : {},
|
|
834
|
+
...clientVersion !== void 0 ? { clientVersion } : {},
|
|
835
|
+
connected: peer.connected,
|
|
836
|
+
lastSeenMs: peer.lastSeenMs
|
|
837
|
+
};
|
|
838
|
+
});
|
|
839
|
+
}
|
|
676
840
|
function hubTools(hub, registry, options) {
|
|
677
841
|
const schema = (properties, required = []) => ({
|
|
678
842
|
type: "object",
|
|
@@ -716,6 +880,12 @@ function hubTools(hub, registry, options) {
|
|
|
716
880
|
}
|
|
717
881
|
return herdr;
|
|
718
882
|
};
|
|
883
|
+
const requireManager = (peer, action) => {
|
|
884
|
+
const managers = options.managerPeers ?? "all";
|
|
885
|
+
if (managers !== "all" && !managers.has(peer)) {
|
|
886
|
+
throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
719
889
|
const asStatuses = (value) => {
|
|
720
890
|
if (!Array.isArray(value)) return void 0;
|
|
721
891
|
const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
|
|
@@ -728,42 +898,108 @@ function hubTools(hub, registry, options) {
|
|
|
728
898
|
return [
|
|
729
899
|
{
|
|
730
900
|
name: "bridge_register",
|
|
731
|
-
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.',
|
|
732
|
-
inputSchema: schema({
|
|
901
|
+
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.',
|
|
902
|
+
inputSchema: schema({
|
|
903
|
+
peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars."),
|
|
904
|
+
alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
|
|
905
|
+
}, ["peerId"]),
|
|
733
906
|
handler: async (args, sessionId) => {
|
|
734
907
|
const peerId = String(args.peerId);
|
|
735
|
-
if (
|
|
908
|
+
if (!PEER_ID_PATTERN.test(peerId)) {
|
|
736
909
|
throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
737
910
|
}
|
|
911
|
+
if (peerId === BROADCAST) {
|
|
912
|
+
throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
|
|
913
|
+
}
|
|
914
|
+
const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
|
|
738
915
|
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
739
916
|
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
740
917
|
}
|
|
741
918
|
const current = registry.peerFor(sessionId);
|
|
742
919
|
if (current !== void 0 && current !== peerId) {
|
|
743
|
-
registry.
|
|
744
|
-
|
|
920
|
+
if (registry.attachedCount(current) === 1) {
|
|
921
|
+
hub.renamePeer(current, peerId);
|
|
922
|
+
registry.rebindPeerId(current, peerId);
|
|
923
|
+
} else {
|
|
924
|
+
registry.unbindPeer(sessionId);
|
|
925
|
+
if (registry.attachedCount(current) === 0) hub.unregister(current);
|
|
926
|
+
}
|
|
745
927
|
}
|
|
746
|
-
registry.bindPeer(sessionId, peerId);
|
|
747
928
|
if (!hub.has(peerId)) hub.register(peerId);
|
|
929
|
+
registry.bindPeer(sessionId, peerId);
|
|
748
930
|
registry.clearSuppress(sessionId);
|
|
749
931
|
hub.touch(peerId);
|
|
750
|
-
|
|
932
|
+
if (alias !== void 0) hub.setAlias(peerId, alias);
|
|
933
|
+
const finalAlias = hub.profileOf(peerId)?.alias;
|
|
934
|
+
return { ok: true, peerId, ...finalAlias !== void 0 ? { alias: finalAlias } : {}, peers: hub.peers() };
|
|
751
935
|
}
|
|
752
936
|
},
|
|
753
937
|
{
|
|
754
938
|
name: "bridge_unregister",
|
|
755
|
-
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.",
|
|
756
|
-
inputSchema: schema({
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
939
|
+
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.",
|
|
940
|
+
inputSchema: schema({
|
|
941
|
+
peer: optStr("Kick this peer instead of yourself. Requires manager rights (hub managerPeers).")
|
|
942
|
+
}),
|
|
943
|
+
handler: async (args, sessionId) => {
|
|
944
|
+
if (args.peer === void 0) {
|
|
945
|
+
const peer = registry.peerFor(sessionId);
|
|
946
|
+
if (peer !== void 0) {
|
|
947
|
+
registry.unbindPeer(sessionId);
|
|
948
|
+
if (registry.attachedCount(peer) === 0) hub.unregister(peer);
|
|
949
|
+
}
|
|
950
|
+
registry.suppressAuto(sessionId);
|
|
951
|
+
return { ok: true, peerId: peer ?? null };
|
|
762
952
|
}
|
|
763
|
-
registry.
|
|
764
|
-
|
|
953
|
+
const caller = registry.peerFor(sessionId);
|
|
954
|
+
if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
955
|
+
const target = String(args.peer);
|
|
956
|
+
requireManager(caller, `kicking '${target}'`);
|
|
957
|
+
if (!hub.has(target)) {
|
|
958
|
+
return { ok: true, peerId: null, kicked: false };
|
|
959
|
+
}
|
|
960
|
+
hub.unregister(target);
|
|
961
|
+
const detached = registry.unbindPeerId(target, { suppress: true });
|
|
962
|
+
return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
|
|
765
963
|
}
|
|
766
964
|
},
|
|
965
|
+
{
|
|
966
|
+
name: "bridge_rename",
|
|
967
|
+
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).",
|
|
968
|
+
inputSchema: schema({
|
|
969
|
+
alias: optStr("New display name (1-64 chars, spaces/CJK allowed; empty string clears back to no alias)."),
|
|
970
|
+
peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
|
|
971
|
+
peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
|
|
972
|
+
}),
|
|
973
|
+
handler: wrap(true, async (args, peer) => {
|
|
974
|
+
const hasAlias = args.alias !== void 0;
|
|
975
|
+
const hasPeerId = args.peerId !== void 0;
|
|
976
|
+
if (!hasAlias && !hasPeerId) {
|
|
977
|
+
throw new Error("nothing to rename: pass alias and/or peerId");
|
|
978
|
+
}
|
|
979
|
+
let target = args.peer === void 0 ? peer : String(args.peer);
|
|
980
|
+
if (target !== peer) requireManager(peer, `renaming '${target}'`);
|
|
981
|
+
let previousId;
|
|
982
|
+
if (hasPeerId) {
|
|
983
|
+
if (args.peer === void 0) {
|
|
984
|
+
throw new Error("peerId requires peer \u2014 to change your own id, call bridge_register(newId)");
|
|
985
|
+
}
|
|
986
|
+
const newId = String(args.peerId);
|
|
987
|
+
if (!PEER_ID_PATTERN.test(newId)) {
|
|
988
|
+
throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
989
|
+
}
|
|
990
|
+
previousId = target;
|
|
991
|
+
hub.renamePeer(target, newId);
|
|
992
|
+
registry.rebindPeerId(target, newId);
|
|
993
|
+
target = newId;
|
|
994
|
+
}
|
|
995
|
+
if (hasAlias) {
|
|
996
|
+
const alias = String(args.alias).trim();
|
|
997
|
+
hub.setAlias(target, alias === "" ? void 0 : sanitizeAlias(alias));
|
|
998
|
+
}
|
|
999
|
+
const applied = hub.profileOf(target)?.alias;
|
|
1000
|
+
return { ok: true, peerId: target, ...previousId !== void 0 ? { previousId } : {}, ...applied !== void 0 ? { alias: applied } : {} };
|
|
1001
|
+
})
|
|
1002
|
+
},
|
|
767
1003
|
{
|
|
768
1004
|
name: "bridge_chat",
|
|
769
1005
|
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.',
|
|
@@ -834,18 +1070,23 @@ function hubTools(hub, registry, options) {
|
|
|
834
1070
|
},
|
|
835
1071
|
{
|
|
836
1072
|
name: "bridge_peers",
|
|
837
|
-
description: "List registered peers and
|
|
1073
|
+
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.",
|
|
838
1074
|
inputSchema: schema({}),
|
|
839
|
-
handler: wrap(true, async () => {
|
|
840
|
-
const status = hub.status(livePeersFor(registry));
|
|
841
|
-
return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
|
|
842
|
-
})
|
|
1075
|
+
handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
|
|
843
1076
|
},
|
|
844
1077
|
{
|
|
845
1078
|
name: "bridge_history",
|
|
846
|
-
description:
|
|
847
|
-
inputSchema: schema({ peer: optStr(
|
|
848
|
-
handler: wrap(true, async (args, peer) =>
|
|
1079
|
+
description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
|
|
1080
|
+
inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
|
|
1081
|
+
handler: wrap(true, async (args, peer) => {
|
|
1082
|
+
const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
|
|
1083
|
+
const target = args.peer === void 0 ? peer : String(args.peer);
|
|
1084
|
+
if (target !== peer) {
|
|
1085
|
+
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`} requires manager rights`);
|
|
1086
|
+
}
|
|
1087
|
+
const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
|
|
1088
|
+
return { messages: messages.map(present) };
|
|
1089
|
+
})
|
|
849
1090
|
},
|
|
850
1091
|
// ---- herdr control tools ------------------------------------------
|
|
851
1092
|
// These type into real agent terminals via the herdr runtime. They are
|
|
@@ -1089,15 +1330,23 @@ var SessionRegistry = class {
|
|
|
1089
1330
|
}
|
|
1090
1331
|
/** Client-reported name per session (from the initialize clientInfo). */
|
|
1091
1332
|
clientNames = /* @__PURE__ */ new Map();
|
|
1092
|
-
/**
|
|
1093
|
-
|
|
1333
|
+
/** Client-reported version per session (undefined when not reported). */
|
|
1334
|
+
clientVersions = /* @__PURE__ */ new Map();
|
|
1335
|
+
/** Remember the client name/version reported by a session (on initialize). */
|
|
1336
|
+
noteClient(sessionId, name, version) {
|
|
1094
1337
|
this.clientNames.set(sessionId, name);
|
|
1338
|
+
if (version !== void 0) this.clientVersions.set(sessionId, version);
|
|
1095
1339
|
}
|
|
1096
1340
|
/** Client-reported name for a session, if any. */
|
|
1097
1341
|
clientName(sessionId) {
|
|
1098
1342
|
if (sessionId === void 0) return void 0;
|
|
1099
1343
|
return this.clientNames.get(sessionId);
|
|
1100
1344
|
}
|
|
1345
|
+
/** Client-reported version for a session, if any. */
|
|
1346
|
+
clientVersion(sessionId) {
|
|
1347
|
+
if (sessionId === void 0) return void 0;
|
|
1348
|
+
return this.clientVersions.get(sessionId);
|
|
1349
|
+
}
|
|
1101
1350
|
/** Sessions whose owner explicitly unregistered; auto-registration is
|
|
1102
1351
|
* suppressed for them until an explicit `bridge_register`. */
|
|
1103
1352
|
suppressedAuto = /* @__PURE__ */ new Set();
|
|
@@ -1127,11 +1376,39 @@ var SessionRegistry = class {
|
|
|
1127
1376
|
liveSessions() {
|
|
1128
1377
|
return this.liveStreams;
|
|
1129
1378
|
}
|
|
1130
|
-
/** Drop every binding that points at `peerId` (used by the idle GC).
|
|
1131
|
-
|
|
1379
|
+
/** Drop every binding that points at `peerId` (used by the idle GC). With
|
|
1380
|
+
* `suppress`, the detached sessions also stop auto-re-registering — the
|
|
1381
|
+
* manager-kick path, so a kicked agent stays out until it explicitly
|
|
1382
|
+
* `bridge_register`s or reconnects with a fresh session. */
|
|
1383
|
+
unbindPeerId(peerId, options) {
|
|
1384
|
+
const detached = [];
|
|
1385
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
1386
|
+
if (bound === peerId) {
|
|
1387
|
+
this.peerBindings.delete(sessionId);
|
|
1388
|
+
detached.push(sessionId);
|
|
1389
|
+
if (options?.suppress === true) this.suppressedAuto.add(sessionId);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return detached;
|
|
1393
|
+
}
|
|
1394
|
+
/** Session ids currently attached to `peerId` (any binding direction). */
|
|
1395
|
+
sessionsForPeer(peerId) {
|
|
1396
|
+
const sessions = [];
|
|
1132
1397
|
for (const [sessionId, bound] of this.peerBindings) {
|
|
1133
|
-
if (bound === peerId)
|
|
1398
|
+
if (bound === peerId) sessions.push(sessionId);
|
|
1134
1399
|
}
|
|
1400
|
+
return sessions;
|
|
1401
|
+
}
|
|
1402
|
+
/** Re-point every session bound to `oldId` at `newId` (true rename). */
|
|
1403
|
+
rebindPeerId(oldId, newId) {
|
|
1404
|
+
let count = 0;
|
|
1405
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
1406
|
+
if (bound === oldId) {
|
|
1407
|
+
this.peerBindings.set(sessionId, newId);
|
|
1408
|
+
count++;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return count;
|
|
1135
1412
|
}
|
|
1136
1413
|
/** How many sessions are currently attached to `peerId`. */
|
|
1137
1414
|
attachedCount(peerId) {
|
|
@@ -1181,6 +1458,30 @@ var McpStreamableHttpServer = class {
|
|
|
1181
1458
|
for (const stream of this.sseStreams.values()) stream.end();
|
|
1182
1459
|
this.sseStreams.clear();
|
|
1183
1460
|
}
|
|
1461
|
+
/** Push a JSON-RPC notification to every open SSE stream (roster events are
|
|
1462
|
+
* public — any peer could poll bridge_peers for the same information).
|
|
1463
|
+
* Write failures drop the dead stream silently; SSE is best-effort and
|
|
1464
|
+
* long-poll (bridge_wait) remains the reliable delivery path. */
|
|
1465
|
+
notifyAll(method, params) {
|
|
1466
|
+
this.notify([...this.sseStreams.keys()], method, params);
|
|
1467
|
+
}
|
|
1468
|
+
/** Push a JSON-RPC notification to the SSE streams of specific sessions. */
|
|
1469
|
+
notify(sessionIds, method, params) {
|
|
1470
|
+
const frame = `event: message
|
|
1471
|
+
data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
1472
|
+
|
|
1473
|
+
`;
|
|
1474
|
+
for (const sessionId of sessionIds) {
|
|
1475
|
+
const stream = this.sseStreams.get(sessionId);
|
|
1476
|
+
if (stream === void 0) continue;
|
|
1477
|
+
try {
|
|
1478
|
+
stream.write(frame);
|
|
1479
|
+
} catch {
|
|
1480
|
+
this.sseStreams.delete(sessionId);
|
|
1481
|
+
this.registry.markSseClosed(sessionId);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1184
1485
|
handleGet(req, res) {
|
|
1185
1486
|
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
1186
1487
|
res.writeHead(200, {
|
|
@@ -1244,10 +1545,11 @@ var McpStreamableHttpServer = class {
|
|
|
1244
1545
|
const newSessionId = this.registry.ensureSession(sessionId);
|
|
1245
1546
|
const clientInfo = message.params?.clientInfo;
|
|
1246
1547
|
const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
|
|
1548
|
+
const clientVersion = typeof clientInfo?.version === "string" && clientInfo.version !== "" ? clientInfo.version : void 0;
|
|
1247
1549
|
if (clientName !== void 0) {
|
|
1248
|
-
this.registry.noteClient(newSessionId, clientName);
|
|
1550
|
+
this.registry.noteClient(newSessionId, clientName, clientVersion);
|
|
1249
1551
|
}
|
|
1250
|
-
this.onInitialize(newSessionId, clientName);
|
|
1552
|
+
this.onInitialize(newSessionId, clientName, clientVersion);
|
|
1251
1553
|
const requested = message.params?.protocolVersion;
|
|
1252
1554
|
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
1253
1555
|
return {
|
|
@@ -1337,7 +1639,7 @@ function readBody(req) {
|
|
|
1337
1639
|
|
|
1338
1640
|
// src/index.ts
|
|
1339
1641
|
var SERVER_NAME = "agent-comm-hub";
|
|
1340
|
-
var SERVER_VERSION = "0.
|
|
1642
|
+
var SERVER_VERSION = "0.6.0";
|
|
1341
1643
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1342
1644
|
var DEFAULT_PORT = 18764;
|
|
1343
1645
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -1346,15 +1648,64 @@ var DEFAULT_CONFIG = {
|
|
|
1346
1648
|
port: DEFAULT_PORT,
|
|
1347
1649
|
path: DEFAULT_PATH,
|
|
1348
1650
|
maxQueue: 200,
|
|
1349
|
-
|
|
1651
|
+
// 100 was too small to survive a long multi-agent session: the ring is the
|
|
1652
|
+
// only archive source until the desktop app persists it to SQLite, and a
|
|
1653
|
+
// night of agent-to-agent chatter evicts everything within minutes.
|
|
1654
|
+
historyLimit: 1e3,
|
|
1350
1655
|
waitTimeoutMs: 6e4,
|
|
1351
1656
|
defaultWaitMs: 3e4,
|
|
1352
1657
|
connectedWindowMs: 3e4,
|
|
1353
|
-
peerIdleTimeoutMs: 6e5
|
|
1658
|
+
peerIdleTimeoutMs: 6e5,
|
|
1659
|
+
// The companion desktop app (and `agent-comm-hub status` probe) registers
|
|
1660
|
+
// as `agent-hub-cli`; it is the natural roster manager. Agents themselves
|
|
1661
|
+
// are NOT managers — they keep chatting, a GUI manages.
|
|
1662
|
+
managerPeers: ["agent-hub-cli"]
|
|
1354
1663
|
};
|
|
1664
|
+
function writeRosterFile(file, profiles) {
|
|
1665
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1666
|
+
const tmp = `${file}.tmp`;
|
|
1667
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
|
|
1668
|
+
renameSync(tmp, file);
|
|
1669
|
+
}
|
|
1355
1670
|
function startHub(config = {}, log = console) {
|
|
1356
1671
|
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
1357
1672
|
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
1673
|
+
let mcp;
|
|
1674
|
+
const pushRoster = () => {
|
|
1675
|
+
mcp.notifyAll("notifications/message", {
|
|
1676
|
+
level: "info",
|
|
1677
|
+
logger: "bridge",
|
|
1678
|
+
data: { event: "peers_changed", peers: rosterFor(hub, registry) }
|
|
1679
|
+
});
|
|
1680
|
+
};
|
|
1681
|
+
const pushQueued = (message, target) => {
|
|
1682
|
+
mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
|
|
1683
|
+
level: "info",
|
|
1684
|
+
logger: "bridge",
|
|
1685
|
+
data: { event: "message", message: present(message) }
|
|
1686
|
+
});
|
|
1687
|
+
};
|
|
1688
|
+
let saveTimer;
|
|
1689
|
+
let rosterDirty = false;
|
|
1690
|
+
const flushRoster = () => {
|
|
1691
|
+
if (resolved.stateFile === void 0 || !rosterDirty) return;
|
|
1692
|
+
rosterDirty = false;
|
|
1693
|
+
try {
|
|
1694
|
+
writeRosterFile(resolved.stateFile, hub.exportProfiles());
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
log.warn(`roster save failed: ${error.message}`);
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
const scheduleRosterSave = () => {
|
|
1700
|
+
if (resolved.stateFile === void 0) return;
|
|
1701
|
+
rosterDirty = true;
|
|
1702
|
+
if (saveTimer !== void 0) return;
|
|
1703
|
+
saveTimer = setTimeout(() => {
|
|
1704
|
+
saveTimer = void 0;
|
|
1705
|
+
flushRoster();
|
|
1706
|
+
}, 500);
|
|
1707
|
+
saveTimer.unref?.();
|
|
1708
|
+
};
|
|
1358
1709
|
const hub = new AgentHub({
|
|
1359
1710
|
maxQueue: resolved.maxQueue,
|
|
1360
1711
|
historyLimit: resolved.historyLimit,
|
|
@@ -1363,9 +1714,37 @@ function startHub(config = {}, log = console) {
|
|
|
1363
1714
|
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
1364
1715
|
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
1365
1716
|
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
1366
|
-
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
1717
|
+
isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
|
|
1718
|
+
// SSE event push: roster changes and queued-mail hints. Best-effort — a
|
|
1719
|
+
// notification failure must never break routing, so swallow and log.
|
|
1720
|
+
onPeersChanged: () => {
|
|
1721
|
+
try {
|
|
1722
|
+
pushRoster();
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
log.warn(`peers_changed notification failed: ${error.message}`);
|
|
1725
|
+
}
|
|
1726
|
+
scheduleRosterSave();
|
|
1727
|
+
},
|
|
1728
|
+
onQueued: (message, target) => {
|
|
1729
|
+
try {
|
|
1730
|
+
pushQueued(message, target);
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
log.warn(`message notification failed: ${error.message}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1367
1735
|
});
|
|
1368
1736
|
const registry = new SessionRegistry();
|
|
1737
|
+
if (resolved.stateFile !== void 0) {
|
|
1738
|
+
try {
|
|
1739
|
+
const parsed = JSON.parse(readFileSync(resolved.stateFile, "utf8"));
|
|
1740
|
+
const imported = hub.importProfiles(parsed.profiles);
|
|
1741
|
+
if (imported > 0) log.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
if (error.code !== "ENOENT") {
|
|
1744
|
+
log.warn(`roster load failed: ${error.message}`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1369
1748
|
const herdr = new HerdrCtl({
|
|
1370
1749
|
bin: resolved.herdrBin,
|
|
1371
1750
|
baseArgs: resolved.herdrBaseArgs,
|
|
@@ -1373,19 +1752,20 @@ function startHub(config = {}, log = console) {
|
|
|
1373
1752
|
socketPath: resolved.herdrSocketPath,
|
|
1374
1753
|
sendRequest: resolved.herdrSendRequest
|
|
1375
1754
|
});
|
|
1376
|
-
|
|
1755
|
+
mcp = new McpStreamableHttpServer(
|
|
1377
1756
|
hubTools(hub, registry, {
|
|
1378
1757
|
defaultWaitMs: resolved.defaultWaitMs,
|
|
1379
1758
|
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
1380
1759
|
herdr,
|
|
1381
|
-
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
|
|
1760
|
+
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
|
|
1761
|
+
managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
|
|
1382
1762
|
}),
|
|
1383
1763
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
1384
1764
|
registry,
|
|
1385
1765
|
(message) => log.warn(message),
|
|
1386
|
-
(sessionId, clientName) => {
|
|
1766
|
+
(sessionId, clientName, clientVersion) => {
|
|
1387
1767
|
try {
|
|
1388
|
-
const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
|
|
1768
|
+
const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
|
|
1389
1769
|
if (peer !== void 0) log.info(`peer joined: ${peer}`);
|
|
1390
1770
|
} catch (error) {
|
|
1391
1771
|
log.warn(`auto-register failed: ${error.message}`);
|
|
@@ -1406,6 +1786,11 @@ function startHub(config = {}, log = console) {
|
|
|
1406
1786
|
close: () => {
|
|
1407
1787
|
hub.dispose();
|
|
1408
1788
|
mcp.close();
|
|
1789
|
+
if (saveTimer !== void 0) {
|
|
1790
|
+
clearTimeout(saveTimer);
|
|
1791
|
+
saveTimer = void 0;
|
|
1792
|
+
}
|
|
1793
|
+
flushRoster();
|
|
1409
1794
|
server.closeAllConnections?.();
|
|
1410
1795
|
server.close();
|
|
1411
1796
|
}
|
|
@@ -1431,6 +1816,7 @@ export {
|
|
|
1431
1816
|
encodeContent,
|
|
1432
1817
|
hubTools,
|
|
1433
1818
|
present,
|
|
1819
|
+
sanitizeAlias,
|
|
1434
1820
|
sanitizePeerId,
|
|
1435
1821
|
startHub
|
|
1436
1822
|
};
|