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/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
5
7
|
|
|
6
8
|
// src/herdr-ctl.ts
|
|
7
9
|
import { execFile } from "node:child_process";
|
|
@@ -418,6 +420,7 @@ function socketSend(socketPath, method, params) {
|
|
|
418
420
|
import { randomUUID } from "node:crypto";
|
|
419
421
|
|
|
420
422
|
// src/protocol.ts
|
|
423
|
+
var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
421
424
|
var BROADCAST = "all";
|
|
422
425
|
function decodeContent(kind, content) {
|
|
423
426
|
if (kind !== "task" && kind !== "ack") return content;
|
|
@@ -430,6 +433,7 @@ function decodeContent(kind, content) {
|
|
|
430
433
|
}
|
|
431
434
|
|
|
432
435
|
// src/hub.ts
|
|
436
|
+
var MAX_PROFILES = 512;
|
|
433
437
|
var AgentHub = class {
|
|
434
438
|
constructor(options) {
|
|
435
439
|
this.options = options;
|
|
@@ -444,6 +448,8 @@ var AgentHub = class {
|
|
|
444
448
|
waiters = /* @__PURE__ */ new Map();
|
|
445
449
|
historyRing = [];
|
|
446
450
|
lastSeen = /* @__PURE__ */ new Map();
|
|
451
|
+
/** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
|
|
452
|
+
profiles = /* @__PURE__ */ new Map();
|
|
447
453
|
gcTimer;
|
|
448
454
|
/** Stop the idle GC (call when the hub shuts down). */
|
|
449
455
|
dispose() {
|
|
@@ -463,13 +469,25 @@ var AgentHub = class {
|
|
|
463
469
|
peers() {
|
|
464
470
|
return [...this.lastSeen.keys()];
|
|
465
471
|
}
|
|
466
|
-
/** Register a peer; throws if the id is already taken.
|
|
467
|
-
|
|
472
|
+
/** Register a peer; throws if the id is already taken. Client metadata
|
|
473
|
+
* (when provided) refreshes the profile — with same-name sessions sharing a
|
|
474
|
+
* peer, the freshest connect wins, which self-heals stale metadata. */
|
|
475
|
+
register(peerId, client) {
|
|
468
476
|
if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
|
|
469
477
|
this.lastSeen.set(peerId, Date.now());
|
|
478
|
+
const existing = this.profiles.get(peerId);
|
|
479
|
+
this.profiles.set(peerId, {
|
|
480
|
+
...existing,
|
|
481
|
+
id: peerId,
|
|
482
|
+
registeredAt: existing?.registeredAt ?? Date.now(),
|
|
483
|
+
...client?.name !== void 0 ? { clientName: client.name } : existing?.clientName !== void 0 ? { clientName: existing.clientName } : {},
|
|
484
|
+
...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
|
|
485
|
+
});
|
|
486
|
+
this.evictOverflowProfiles();
|
|
470
487
|
this.options.onPeersChanged?.(this.peers());
|
|
471
488
|
}
|
|
472
|
-
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
489
|
+
/** Remove a peer and its queued messages; pending waiters resolve as
|
|
490
|
+
* timeouts. The profile is kept so a reconnecting peer keeps its alias. */
|
|
473
491
|
unregister(peerId) {
|
|
474
492
|
this.queues.delete(peerId);
|
|
475
493
|
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
@@ -480,6 +498,110 @@ var AgentHub = class {
|
|
|
480
498
|
this.lastSeen.delete(peerId);
|
|
481
499
|
this.options.onPeersChanged?.(this.peers());
|
|
482
500
|
}
|
|
501
|
+
/** Identity metadata for `peerId`, if the hub ever saw it. */
|
|
502
|
+
profileOf(peerId) {
|
|
503
|
+
return this.profiles.get(peerId);
|
|
504
|
+
}
|
|
505
|
+
/** Snapshot of every known profile (registered or not) for persistence. */
|
|
506
|
+
exportProfiles() {
|
|
507
|
+
return [...this.profiles.values()].map((profile) => ({ ...profile }));
|
|
508
|
+
}
|
|
509
|
+
/** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
|
|
510
|
+
* and non-finite fields are sanitized. Returns the imported count. Silent
|
|
511
|
+
* for roster observers — this runs before the server accepts requests. */
|
|
512
|
+
importProfiles(profiles) {
|
|
513
|
+
if (!Array.isArray(profiles)) return 0;
|
|
514
|
+
let count = 0;
|
|
515
|
+
for (const raw of profiles) {
|
|
516
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
517
|
+
const candidate = raw;
|
|
518
|
+
const id = candidate.id;
|
|
519
|
+
if (typeof id !== "string" || !PEER_ID_PATTERN.test(id) || id === BROADCAST) continue;
|
|
520
|
+
if (this.profiles.has(id)) continue;
|
|
521
|
+
const profile = {
|
|
522
|
+
id,
|
|
523
|
+
registeredAt: typeof candidate.registeredAt === "number" && Number.isFinite(candidate.registeredAt) ? candidate.registeredAt : Date.now()
|
|
524
|
+
};
|
|
525
|
+
if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
|
|
526
|
+
if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
|
|
527
|
+
if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
|
|
528
|
+
this.profiles.set(id, profile);
|
|
529
|
+
count++;
|
|
530
|
+
}
|
|
531
|
+
this.evictOverflowProfiles();
|
|
532
|
+
return count;
|
|
533
|
+
}
|
|
534
|
+
/** Set (or clear with `undefined`) the display alias of a registered peer.
|
|
535
|
+
* Routing state is untouched — this only changes how the peer is shown. */
|
|
536
|
+
setAlias(peerId, alias) {
|
|
537
|
+
if (!this.lastSeen.has(peerId)) throw new Error(`unknown peer: ${peerId}`);
|
|
538
|
+
const profile = this.profiles.get(peerId);
|
|
539
|
+
if (profile !== void 0) {
|
|
540
|
+
this.profiles.set(peerId, alias === void 0 ? omit(profile, "alias") : { ...profile, alias });
|
|
541
|
+
}
|
|
542
|
+
this.options.onPeersChanged?.(this.peers());
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Atomically re-key a registered peer: mailbox, waiters, last-seen,
|
|
546
|
+
* profile, session bindings (via the caller's registry), and history
|
|
547
|
+
* attribution move from `oldId` to `newId` in one step, so queued
|
|
548
|
+
* messages, pending waits, ack routing, and history continuity all
|
|
549
|
+
* survive the rename. All validation happens before the first mutation.
|
|
550
|
+
*/
|
|
551
|
+
renamePeer(oldId, newId) {
|
|
552
|
+
if (oldId === newId) return;
|
|
553
|
+
if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
|
|
554
|
+
if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
|
|
555
|
+
if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
556
|
+
if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
|
|
557
|
+
const queue = this.queues.get(oldId);
|
|
558
|
+
const waiters = this.waiters.get(oldId);
|
|
559
|
+
const seen = this.lastSeen.get(oldId) ?? Date.now();
|
|
560
|
+
const profile = this.profiles.get(oldId);
|
|
561
|
+
const stale = this.profiles.get(newId);
|
|
562
|
+
this.queues.delete(oldId);
|
|
563
|
+
this.waiters.delete(oldId);
|
|
564
|
+
this.lastSeen.delete(oldId);
|
|
565
|
+
this.profiles.delete(oldId);
|
|
566
|
+
if (queue !== void 0) this.queues.set(newId, queue);
|
|
567
|
+
if (waiters !== void 0) this.waiters.set(newId, waiters);
|
|
568
|
+
this.lastSeen.set(newId, seen);
|
|
569
|
+
if (profile !== void 0) {
|
|
570
|
+
const merged = {
|
|
571
|
+
...stale,
|
|
572
|
+
...profile,
|
|
573
|
+
id: newId,
|
|
574
|
+
registeredAt: stale !== void 0 && stale.registeredAt < profile.registeredAt ? stale.registeredAt : profile.registeredAt
|
|
575
|
+
};
|
|
576
|
+
this.profiles.set(newId, merged);
|
|
577
|
+
} else if (stale !== void 0) {
|
|
578
|
+
this.profiles.set(newId, stale);
|
|
579
|
+
} else {
|
|
580
|
+
this.profiles.set(newId, { id: newId, registeredAt: Date.now() });
|
|
581
|
+
}
|
|
582
|
+
for (const list of this.waiters.values()) {
|
|
583
|
+
for (const waiter of list) {
|
|
584
|
+
if (waiter.from === oldId) waiter.from = newId;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
for (const message of this.historyRing) {
|
|
588
|
+
if (message.from === oldId) message.from = newId;
|
|
589
|
+
if (message.to === oldId) message.to = newId;
|
|
590
|
+
}
|
|
591
|
+
this.options.onPeersChanged?.(this.peers());
|
|
592
|
+
}
|
|
593
|
+
/** Keep the profile map bounded: drop the oldest profiles of peers that are
|
|
594
|
+
* NOT currently registered (live peers are never evicted). */
|
|
595
|
+
evictOverflowProfiles() {
|
|
596
|
+
if (this.profiles.size <= MAX_PROFILES) return;
|
|
597
|
+
const evictable = [...this.profiles.values()].filter((profile) => !this.lastSeen.has(profile.id)).sort((a, b) => a.registeredAt - b.registeredAt);
|
|
598
|
+
let overflow = this.profiles.size - MAX_PROFILES;
|
|
599
|
+
for (const profile of evictable) {
|
|
600
|
+
if (overflow <= 0) break;
|
|
601
|
+
this.profiles.delete(profile.id);
|
|
602
|
+
overflow--;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
483
605
|
/** Mark activity for `peer` (called on every tool call from that peer). */
|
|
484
606
|
touch(peerId) {
|
|
485
607
|
if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
|
|
@@ -512,6 +634,12 @@ var AgentHub = class {
|
|
|
512
634
|
const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
|
|
513
635
|
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
514
636
|
}
|
|
637
|
+
/** Most recent messages across every peer, unfiltered (newest first).
|
|
638
|
+
* Backs `bridge_history { peer: "all" }` — lets an archiver (the desktop
|
|
639
|
+
* app) capture peer-to-peer traffic it is not a party of. */
|
|
640
|
+
historyAll(limit) {
|
|
641
|
+
return this.historyRing.slice(-Math.max(0, limit)).reverse();
|
|
642
|
+
}
|
|
515
643
|
/**
|
|
516
644
|
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
517
645
|
* stream) count as connected even without recent tool activity.
|
|
@@ -520,13 +648,17 @@ var AgentHub = class {
|
|
|
520
648
|
const now = Date.now();
|
|
521
649
|
return {
|
|
522
650
|
server: "agent-comm-hub",
|
|
523
|
-
peers: this.peers().map((peer) =>
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
651
|
+
peers: this.peers().map((peer) => {
|
|
652
|
+
const alias = this.profiles.get(peer)?.alias;
|
|
653
|
+
return {
|
|
654
|
+
id: peer,
|
|
655
|
+
...alias !== void 0 ? { alias } : {},
|
|
656
|
+
connected: this.isActive(peer) || livePeers?.has(peer) === true,
|
|
657
|
+
lastSeenMs: this.lastSeen.get(peer) ?? 0,
|
|
658
|
+
queued: (this.queues.get(peer) ?? []).length,
|
|
659
|
+
waiting: (this.waiters.get(peer) ?? []).length
|
|
660
|
+
};
|
|
661
|
+
}),
|
|
530
662
|
historyLimit: this.options.historyLimit,
|
|
531
663
|
maxQueue: this.options.maxQueue
|
|
532
664
|
};
|
|
@@ -618,7 +750,7 @@ var AgentHub = class {
|
|
|
618
750
|
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
619
751
|
queue.push(message);
|
|
620
752
|
this.queues.set(target, queue);
|
|
621
|
-
this.options.onQueued?.(message);
|
|
753
|
+
this.options.onQueued?.(message, target);
|
|
622
754
|
}
|
|
623
755
|
/** Append a delivered message to the history ring. */
|
|
624
756
|
remember(message) {
|
|
@@ -636,6 +768,10 @@ function drainFrom(queue, from) {
|
|
|
636
768
|
queue.splice(0, queue.length, ...kept);
|
|
637
769
|
return drained;
|
|
638
770
|
}
|
|
771
|
+
function omit(value, key) {
|
|
772
|
+
const { [key]: _dropped, ...rest } = value;
|
|
773
|
+
return rest;
|
|
774
|
+
}
|
|
639
775
|
|
|
640
776
|
// src/hub-tools.ts
|
|
641
777
|
var DEFAULT_WAIT_MS = 3e4;
|
|
@@ -651,13 +787,25 @@ function sanitizePeerId(name) {
|
|
|
651
787
|
const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
652
788
|
return cleaned === "" ? "agent" : cleaned;
|
|
653
789
|
}
|
|
654
|
-
function
|
|
790
|
+
function sanitizeAlias(alias) {
|
|
791
|
+
const cleaned = alias.trim();
|
|
792
|
+
if (cleaned === "") throw new Error("alias must not be empty");
|
|
793
|
+
if (cleaned.length > 64) throw new Error(`alias too long: ${cleaned.length} chars (max 64)`);
|
|
794
|
+
if (/[\u0000-\u001f\u007f]/.test(cleaned)) throw new Error("alias must not contain control characters");
|
|
795
|
+
return cleaned;
|
|
796
|
+
}
|
|
797
|
+
function autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion) {
|
|
655
798
|
if (sessionId === void 0) return void 0;
|
|
656
799
|
const bound = registry.peerFor(sessionId);
|
|
657
800
|
if (bound !== void 0) return bound;
|
|
658
801
|
if (registry.isSuppressed(sessionId)) return void 0;
|
|
659
802
|
const peerId = sanitizePeerId(clientName ?? "agent");
|
|
660
|
-
if (!hub.has(peerId))
|
|
803
|
+
if (!hub.has(peerId)) {
|
|
804
|
+
hub.register(peerId, {
|
|
805
|
+
...clientName !== void 0 ? { name: clientName } : {},
|
|
806
|
+
...clientVersion !== void 0 ? { version: clientVersion } : {}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
661
809
|
registry.bindPeer(sessionId, peerId);
|
|
662
810
|
hub.touch(peerId);
|
|
663
811
|
return peerId;
|
|
@@ -670,6 +818,23 @@ function livePeersFor(registry) {
|
|
|
670
818
|
}
|
|
671
819
|
return live;
|
|
672
820
|
}
|
|
821
|
+
function rosterFor(hub, registry) {
|
|
822
|
+
const status = hub.status(livePeersFor(registry));
|
|
823
|
+
return status.peers.map((peer) => {
|
|
824
|
+
const profile = hub.profileOf(peer.id);
|
|
825
|
+
const alias = profile?.alias;
|
|
826
|
+
const clientName = profile?.clientName;
|
|
827
|
+
const clientVersion = profile?.clientVersion;
|
|
828
|
+
return {
|
|
829
|
+
id: peer.id,
|
|
830
|
+
...alias !== void 0 ? { alias } : {},
|
|
831
|
+
...clientName !== void 0 ? { clientName } : {},
|
|
832
|
+
...clientVersion !== void 0 ? { clientVersion } : {},
|
|
833
|
+
connected: peer.connected,
|
|
834
|
+
lastSeenMs: peer.lastSeenMs
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
}
|
|
673
838
|
function hubTools(hub, registry, options) {
|
|
674
839
|
const schema = (properties, required = []) => ({
|
|
675
840
|
type: "object",
|
|
@@ -713,6 +878,12 @@ function hubTools(hub, registry, options) {
|
|
|
713
878
|
}
|
|
714
879
|
return herdr;
|
|
715
880
|
};
|
|
881
|
+
const requireManager = (peer, action) => {
|
|
882
|
+
const managers = options.managerPeers ?? "all";
|
|
883
|
+
if (managers !== "all" && !managers.has(peer)) {
|
|
884
|
+
throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
716
887
|
const asStatuses = (value) => {
|
|
717
888
|
if (!Array.isArray(value)) return void 0;
|
|
718
889
|
const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
|
|
@@ -725,42 +896,108 @@ function hubTools(hub, registry, options) {
|
|
|
725
896
|
return [
|
|
726
897
|
{
|
|
727
898
|
name: "bridge_register",
|
|
728
|
-
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.',
|
|
729
|
-
inputSchema: schema({
|
|
899
|
+
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.',
|
|
900
|
+
inputSchema: schema({
|
|
901
|
+
peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars."),
|
|
902
|
+
alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
|
|
903
|
+
}, ["peerId"]),
|
|
730
904
|
handler: async (args, sessionId) => {
|
|
731
905
|
const peerId = String(args.peerId);
|
|
732
|
-
if (
|
|
906
|
+
if (!PEER_ID_PATTERN.test(peerId)) {
|
|
733
907
|
throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
734
908
|
}
|
|
909
|
+
if (peerId === BROADCAST) {
|
|
910
|
+
throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
|
|
911
|
+
}
|
|
912
|
+
const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
|
|
735
913
|
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
736
914
|
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
737
915
|
}
|
|
738
916
|
const current = registry.peerFor(sessionId);
|
|
739
917
|
if (current !== void 0 && current !== peerId) {
|
|
740
|
-
registry.
|
|
741
|
-
|
|
918
|
+
if (registry.attachedCount(current) === 1) {
|
|
919
|
+
hub.renamePeer(current, peerId);
|
|
920
|
+
registry.rebindPeerId(current, peerId);
|
|
921
|
+
} else {
|
|
922
|
+
registry.unbindPeer(sessionId);
|
|
923
|
+
if (registry.attachedCount(current) === 0) hub.unregister(current);
|
|
924
|
+
}
|
|
742
925
|
}
|
|
743
|
-
registry.bindPeer(sessionId, peerId);
|
|
744
926
|
if (!hub.has(peerId)) hub.register(peerId);
|
|
927
|
+
registry.bindPeer(sessionId, peerId);
|
|
745
928
|
registry.clearSuppress(sessionId);
|
|
746
929
|
hub.touch(peerId);
|
|
747
|
-
|
|
930
|
+
if (alias !== void 0) hub.setAlias(peerId, alias);
|
|
931
|
+
const finalAlias = hub.profileOf(peerId)?.alias;
|
|
932
|
+
return { ok: true, peerId, ...finalAlias !== void 0 ? { alias: finalAlias } : {}, peers: hub.peers() };
|
|
748
933
|
}
|
|
749
934
|
},
|
|
750
935
|
{
|
|
751
936
|
name: "bridge_unregister",
|
|
752
|
-
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.",
|
|
753
|
-
inputSchema: schema({
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
937
|
+
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.",
|
|
938
|
+
inputSchema: schema({
|
|
939
|
+
peer: optStr("Kick this peer instead of yourself. Requires manager rights (hub managerPeers).")
|
|
940
|
+
}),
|
|
941
|
+
handler: async (args, sessionId) => {
|
|
942
|
+
if (args.peer === void 0) {
|
|
943
|
+
const peer = registry.peerFor(sessionId);
|
|
944
|
+
if (peer !== void 0) {
|
|
945
|
+
registry.unbindPeer(sessionId);
|
|
946
|
+
if (registry.attachedCount(peer) === 0) hub.unregister(peer);
|
|
947
|
+
}
|
|
948
|
+
registry.suppressAuto(sessionId);
|
|
949
|
+
return { ok: true, peerId: peer ?? null };
|
|
950
|
+
}
|
|
951
|
+
const caller = registry.peerFor(sessionId);
|
|
952
|
+
if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
953
|
+
const target = String(args.peer);
|
|
954
|
+
requireManager(caller, `kicking '${target}'`);
|
|
955
|
+
if (!hub.has(target)) {
|
|
956
|
+
return { ok: true, peerId: null, kicked: false };
|
|
759
957
|
}
|
|
760
|
-
|
|
761
|
-
|
|
958
|
+
hub.unregister(target);
|
|
959
|
+
const detached = registry.unbindPeerId(target, { suppress: true });
|
|
960
|
+
return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
|
|
762
961
|
}
|
|
763
962
|
},
|
|
963
|
+
{
|
|
964
|
+
name: "bridge_rename",
|
|
965
|
+
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).",
|
|
966
|
+
inputSchema: schema({
|
|
967
|
+
alias: optStr("New display name (1-64 chars, spaces/CJK allowed; empty string clears back to no alias)."),
|
|
968
|
+
peerId: optStr("New routing id for `peer` \u2014 a true rename that moves all state (manager only). Target must be a registered peer."),
|
|
969
|
+
peer: optStr("Whose name/id to change; default yourself. Changing another peer requires manager rights.")
|
|
970
|
+
}),
|
|
971
|
+
handler: wrap(true, async (args, peer) => {
|
|
972
|
+
const hasAlias = args.alias !== void 0;
|
|
973
|
+
const hasPeerId = args.peerId !== void 0;
|
|
974
|
+
if (!hasAlias && !hasPeerId) {
|
|
975
|
+
throw new Error("nothing to rename: pass alias and/or peerId");
|
|
976
|
+
}
|
|
977
|
+
let target = args.peer === void 0 ? peer : String(args.peer);
|
|
978
|
+
if (target !== peer) requireManager(peer, `renaming '${target}'`);
|
|
979
|
+
let previousId;
|
|
980
|
+
if (hasPeerId) {
|
|
981
|
+
if (args.peer === void 0) {
|
|
982
|
+
throw new Error("peerId requires peer \u2014 to change your own id, call bridge_register(newId)");
|
|
983
|
+
}
|
|
984
|
+
const newId = String(args.peerId);
|
|
985
|
+
if (!PEER_ID_PATTERN.test(newId)) {
|
|
986
|
+
throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
987
|
+
}
|
|
988
|
+
previousId = target;
|
|
989
|
+
hub.renamePeer(target, newId);
|
|
990
|
+
registry.rebindPeerId(target, newId);
|
|
991
|
+
target = newId;
|
|
992
|
+
}
|
|
993
|
+
if (hasAlias) {
|
|
994
|
+
const alias = String(args.alias).trim();
|
|
995
|
+
hub.setAlias(target, alias === "" ? void 0 : sanitizeAlias(alias));
|
|
996
|
+
}
|
|
997
|
+
const applied = hub.profileOf(target)?.alias;
|
|
998
|
+
return { ok: true, peerId: target, ...previousId !== void 0 ? { previousId } : {}, ...applied !== void 0 ? { alias: applied } : {} };
|
|
999
|
+
})
|
|
1000
|
+
},
|
|
764
1001
|
{
|
|
765
1002
|
name: "bridge_chat",
|
|
766
1003
|
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.',
|
|
@@ -831,18 +1068,23 @@ function hubTools(hub, registry, options) {
|
|
|
831
1068
|
},
|
|
832
1069
|
{
|
|
833
1070
|
name: "bridge_peers",
|
|
834
|
-
description: "List registered peers and
|
|
1071
|
+
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.",
|
|
835
1072
|
inputSchema: schema({}),
|
|
836
|
-
handler: wrap(true, async () => {
|
|
837
|
-
const status = hub.status(livePeersFor(registry));
|
|
838
|
-
return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
|
|
839
|
-
})
|
|
1073
|
+
handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
|
|
840
1074
|
},
|
|
841
1075
|
{
|
|
842
1076
|
name: "bridge_history",
|
|
843
|
-
description:
|
|
844
|
-
inputSchema: schema({ peer: optStr(
|
|
845
|
-
handler: wrap(true, async (args, peer) =>
|
|
1077
|
+
description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
|
|
1078
|
+
inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
|
|
1079
|
+
handler: wrap(true, async (args, peer) => {
|
|
1080
|
+
const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
|
|
1081
|
+
const target = args.peer === void 0 ? peer : String(args.peer);
|
|
1082
|
+
if (target !== peer) {
|
|
1083
|
+
requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`} requires manager rights`);
|
|
1084
|
+
}
|
|
1085
|
+
const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
|
|
1086
|
+
return { messages: messages.map(present) };
|
|
1087
|
+
})
|
|
846
1088
|
},
|
|
847
1089
|
// ---- herdr control tools ------------------------------------------
|
|
848
1090
|
// These type into real agent terminals via the herdr runtime. They are
|
|
@@ -1086,15 +1328,23 @@ var SessionRegistry = class {
|
|
|
1086
1328
|
}
|
|
1087
1329
|
/** Client-reported name per session (from the initialize clientInfo). */
|
|
1088
1330
|
clientNames = /* @__PURE__ */ new Map();
|
|
1089
|
-
/**
|
|
1090
|
-
|
|
1331
|
+
/** Client-reported version per session (undefined when not reported). */
|
|
1332
|
+
clientVersions = /* @__PURE__ */ new Map();
|
|
1333
|
+
/** Remember the client name/version reported by a session (on initialize). */
|
|
1334
|
+
noteClient(sessionId, name, version) {
|
|
1091
1335
|
this.clientNames.set(sessionId, name);
|
|
1336
|
+
if (version !== void 0) this.clientVersions.set(sessionId, version);
|
|
1092
1337
|
}
|
|
1093
1338
|
/** Client-reported name for a session, if any. */
|
|
1094
1339
|
clientName(sessionId) {
|
|
1095
1340
|
if (sessionId === void 0) return void 0;
|
|
1096
1341
|
return this.clientNames.get(sessionId);
|
|
1097
1342
|
}
|
|
1343
|
+
/** Client-reported version for a session, if any. */
|
|
1344
|
+
clientVersion(sessionId) {
|
|
1345
|
+
if (sessionId === void 0) return void 0;
|
|
1346
|
+
return this.clientVersions.get(sessionId);
|
|
1347
|
+
}
|
|
1098
1348
|
/** Sessions whose owner explicitly unregistered; auto-registration is
|
|
1099
1349
|
* suppressed for them until an explicit `bridge_register`. */
|
|
1100
1350
|
suppressedAuto = /* @__PURE__ */ new Set();
|
|
@@ -1124,11 +1374,39 @@ var SessionRegistry = class {
|
|
|
1124
1374
|
liveSessions() {
|
|
1125
1375
|
return this.liveStreams;
|
|
1126
1376
|
}
|
|
1127
|
-
/** Drop every binding that points at `peerId` (used by the idle GC).
|
|
1128
|
-
|
|
1377
|
+
/** Drop every binding that points at `peerId` (used by the idle GC). With
|
|
1378
|
+
* `suppress`, the detached sessions also stop auto-re-registering — the
|
|
1379
|
+
* manager-kick path, so a kicked agent stays out until it explicitly
|
|
1380
|
+
* `bridge_register`s or reconnects with a fresh session. */
|
|
1381
|
+
unbindPeerId(peerId, options) {
|
|
1382
|
+
const detached = [];
|
|
1383
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
1384
|
+
if (bound === peerId) {
|
|
1385
|
+
this.peerBindings.delete(sessionId);
|
|
1386
|
+
detached.push(sessionId);
|
|
1387
|
+
if (options?.suppress === true) this.suppressedAuto.add(sessionId);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return detached;
|
|
1391
|
+
}
|
|
1392
|
+
/** Session ids currently attached to `peerId` (any binding direction). */
|
|
1393
|
+
sessionsForPeer(peerId) {
|
|
1394
|
+
const sessions = [];
|
|
1395
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
1396
|
+
if (bound === peerId) sessions.push(sessionId);
|
|
1397
|
+
}
|
|
1398
|
+
return sessions;
|
|
1399
|
+
}
|
|
1400
|
+
/** Re-point every session bound to `oldId` at `newId` (true rename). */
|
|
1401
|
+
rebindPeerId(oldId, newId) {
|
|
1402
|
+
let count = 0;
|
|
1129
1403
|
for (const [sessionId, bound] of this.peerBindings) {
|
|
1130
|
-
if (bound ===
|
|
1404
|
+
if (bound === oldId) {
|
|
1405
|
+
this.peerBindings.set(sessionId, newId);
|
|
1406
|
+
count++;
|
|
1407
|
+
}
|
|
1131
1408
|
}
|
|
1409
|
+
return count;
|
|
1132
1410
|
}
|
|
1133
1411
|
/** How many sessions are currently attached to `peerId`. */
|
|
1134
1412
|
attachedCount(peerId) {
|
|
@@ -1178,6 +1456,30 @@ var McpStreamableHttpServer = class {
|
|
|
1178
1456
|
for (const stream of this.sseStreams.values()) stream.end();
|
|
1179
1457
|
this.sseStreams.clear();
|
|
1180
1458
|
}
|
|
1459
|
+
/** Push a JSON-RPC notification to every open SSE stream (roster events are
|
|
1460
|
+
* public — any peer could poll bridge_peers for the same information).
|
|
1461
|
+
* Write failures drop the dead stream silently; SSE is best-effort and
|
|
1462
|
+
* long-poll (bridge_wait) remains the reliable delivery path. */
|
|
1463
|
+
notifyAll(method, params) {
|
|
1464
|
+
this.notify([...this.sseStreams.keys()], method, params);
|
|
1465
|
+
}
|
|
1466
|
+
/** Push a JSON-RPC notification to the SSE streams of specific sessions. */
|
|
1467
|
+
notify(sessionIds, method, params) {
|
|
1468
|
+
const frame = `event: message
|
|
1469
|
+
data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
|
|
1470
|
+
|
|
1471
|
+
`;
|
|
1472
|
+
for (const sessionId of sessionIds) {
|
|
1473
|
+
const stream = this.sseStreams.get(sessionId);
|
|
1474
|
+
if (stream === void 0) continue;
|
|
1475
|
+
try {
|
|
1476
|
+
stream.write(frame);
|
|
1477
|
+
} catch {
|
|
1478
|
+
this.sseStreams.delete(sessionId);
|
|
1479
|
+
this.registry.markSseClosed(sessionId);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1181
1483
|
handleGet(req, res) {
|
|
1182
1484
|
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
1183
1485
|
res.writeHead(200, {
|
|
@@ -1241,10 +1543,11 @@ var McpStreamableHttpServer = class {
|
|
|
1241
1543
|
const newSessionId = this.registry.ensureSession(sessionId);
|
|
1242
1544
|
const clientInfo = message.params?.clientInfo;
|
|
1243
1545
|
const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
|
|
1546
|
+
const clientVersion = typeof clientInfo?.version === "string" && clientInfo.version !== "" ? clientInfo.version : void 0;
|
|
1244
1547
|
if (clientName !== void 0) {
|
|
1245
|
-
this.registry.noteClient(newSessionId, clientName);
|
|
1548
|
+
this.registry.noteClient(newSessionId, clientName, clientVersion);
|
|
1246
1549
|
}
|
|
1247
|
-
this.onInitialize(newSessionId, clientName);
|
|
1550
|
+
this.onInitialize(newSessionId, clientName, clientVersion);
|
|
1248
1551
|
const requested = message.params?.protocolVersion;
|
|
1249
1552
|
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
1250
1553
|
return {
|
|
@@ -1334,7 +1637,7 @@ function readBody(req) {
|
|
|
1334
1637
|
|
|
1335
1638
|
// src/index.ts
|
|
1336
1639
|
var SERVER_NAME = "agent-comm-hub";
|
|
1337
|
-
var SERVER_VERSION = "0.
|
|
1640
|
+
var SERVER_VERSION = "0.6.0";
|
|
1338
1641
|
var DEFAULT_HOST = "127.0.0.1";
|
|
1339
1642
|
var DEFAULT_PORT = 18764;
|
|
1340
1643
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -1343,15 +1646,64 @@ var DEFAULT_CONFIG = {
|
|
|
1343
1646
|
port: DEFAULT_PORT,
|
|
1344
1647
|
path: DEFAULT_PATH,
|
|
1345
1648
|
maxQueue: 200,
|
|
1346
|
-
|
|
1649
|
+
// 100 was too small to survive a long multi-agent session: the ring is the
|
|
1650
|
+
// only archive source until the desktop app persists it to SQLite, and a
|
|
1651
|
+
// night of agent-to-agent chatter evicts everything within minutes.
|
|
1652
|
+
historyLimit: 1e3,
|
|
1347
1653
|
waitTimeoutMs: 6e4,
|
|
1348
1654
|
defaultWaitMs: 3e4,
|
|
1349
1655
|
connectedWindowMs: 3e4,
|
|
1350
|
-
peerIdleTimeoutMs: 6e5
|
|
1656
|
+
peerIdleTimeoutMs: 6e5,
|
|
1657
|
+
// The companion desktop app (and `agent-comm-hub status` probe) registers
|
|
1658
|
+
// as `agent-hub-cli`; it is the natural roster manager. Agents themselves
|
|
1659
|
+
// are NOT managers — they keep chatting, a GUI manages.
|
|
1660
|
+
managerPeers: ["agent-hub-cli"]
|
|
1351
1661
|
};
|
|
1662
|
+
function writeRosterFile(file, profiles) {
|
|
1663
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1664
|
+
const tmp = `${file}.tmp`;
|
|
1665
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
|
|
1666
|
+
renameSync(tmp, file);
|
|
1667
|
+
}
|
|
1352
1668
|
function startHub(config = {}, log2 = console) {
|
|
1353
1669
|
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
1354
1670
|
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
1671
|
+
let mcp;
|
|
1672
|
+
const pushRoster = () => {
|
|
1673
|
+
mcp.notifyAll("notifications/message", {
|
|
1674
|
+
level: "info",
|
|
1675
|
+
logger: "bridge",
|
|
1676
|
+
data: { event: "peers_changed", peers: rosterFor(hub, registry) }
|
|
1677
|
+
});
|
|
1678
|
+
};
|
|
1679
|
+
const pushQueued = (message, target) => {
|
|
1680
|
+
mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
|
|
1681
|
+
level: "info",
|
|
1682
|
+
logger: "bridge",
|
|
1683
|
+
data: { event: "message", message: present(message) }
|
|
1684
|
+
});
|
|
1685
|
+
};
|
|
1686
|
+
let saveTimer;
|
|
1687
|
+
let rosterDirty = false;
|
|
1688
|
+
const flushRoster = () => {
|
|
1689
|
+
if (resolved.stateFile === void 0 || !rosterDirty) return;
|
|
1690
|
+
rosterDirty = false;
|
|
1691
|
+
try {
|
|
1692
|
+
writeRosterFile(resolved.stateFile, hub.exportProfiles());
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
log2.warn(`roster save failed: ${error.message}`);
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
const scheduleRosterSave = () => {
|
|
1698
|
+
if (resolved.stateFile === void 0) return;
|
|
1699
|
+
rosterDirty = true;
|
|
1700
|
+
if (saveTimer !== void 0) return;
|
|
1701
|
+
saveTimer = setTimeout(() => {
|
|
1702
|
+
saveTimer = void 0;
|
|
1703
|
+
flushRoster();
|
|
1704
|
+
}, 500);
|
|
1705
|
+
saveTimer.unref?.();
|
|
1706
|
+
};
|
|
1355
1707
|
const hub = new AgentHub({
|
|
1356
1708
|
maxQueue: resolved.maxQueue,
|
|
1357
1709
|
historyLimit: resolved.historyLimit,
|
|
@@ -1360,9 +1712,37 @@ function startHub(config = {}, log2 = console) {
|
|
|
1360
1712
|
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
1361
1713
|
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
1362
1714
|
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
1363
|
-
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
1715
|
+
isPeerLive: (peerId) => livePeersFor(registry).has(peerId),
|
|
1716
|
+
// SSE event push: roster changes and queued-mail hints. Best-effort — a
|
|
1717
|
+
// notification failure must never break routing, so swallow and log.
|
|
1718
|
+
onPeersChanged: () => {
|
|
1719
|
+
try {
|
|
1720
|
+
pushRoster();
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
log2.warn(`peers_changed notification failed: ${error.message}`);
|
|
1723
|
+
}
|
|
1724
|
+
scheduleRosterSave();
|
|
1725
|
+
},
|
|
1726
|
+
onQueued: (message, target) => {
|
|
1727
|
+
try {
|
|
1728
|
+
pushQueued(message, target);
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
log2.warn(`message notification failed: ${error.message}`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1364
1733
|
});
|
|
1365
1734
|
const registry = new SessionRegistry();
|
|
1735
|
+
if (resolved.stateFile !== void 0) {
|
|
1736
|
+
try {
|
|
1737
|
+
const parsed = JSON.parse(readFileSync(resolved.stateFile, "utf8"));
|
|
1738
|
+
const imported = hub.importProfiles(parsed.profiles);
|
|
1739
|
+
if (imported > 0) log2.info(`roster restored: ${imported} profile(s) from ${resolved.stateFile}`);
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
if (error.code !== "ENOENT") {
|
|
1742
|
+
log2.warn(`roster load failed: ${error.message}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1366
1746
|
const herdr = new HerdrCtl({
|
|
1367
1747
|
bin: resolved.herdrBin,
|
|
1368
1748
|
baseArgs: resolved.herdrBaseArgs,
|
|
@@ -1370,19 +1750,20 @@ function startHub(config = {}, log2 = console) {
|
|
|
1370
1750
|
socketPath: resolved.herdrSocketPath,
|
|
1371
1751
|
sendRequest: resolved.herdrSendRequest
|
|
1372
1752
|
});
|
|
1373
|
-
|
|
1753
|
+
mcp = new McpStreamableHttpServer(
|
|
1374
1754
|
hubTools(hub, registry, {
|
|
1375
1755
|
defaultWaitMs: resolved.defaultWaitMs,
|
|
1376
1756
|
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
1377
1757
|
herdr,
|
|
1378
|
-
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
|
|
1758
|
+
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
|
|
1759
|
+
managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
|
|
1379
1760
|
}),
|
|
1380
1761
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
1381
1762
|
registry,
|
|
1382
1763
|
(message) => log2.warn(message),
|
|
1383
|
-
(sessionId, clientName) => {
|
|
1764
|
+
(sessionId, clientName, clientVersion) => {
|
|
1384
1765
|
try {
|
|
1385
|
-
const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
|
|
1766
|
+
const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
|
|
1386
1767
|
if (peer !== void 0) log2.info(`peer joined: ${peer}`);
|
|
1387
1768
|
} catch (error) {
|
|
1388
1769
|
log2.warn(`auto-register failed: ${error.message}`);
|
|
@@ -1403,6 +1784,11 @@ function startHub(config = {}, log2 = console) {
|
|
|
1403
1784
|
close: () => {
|
|
1404
1785
|
hub.dispose();
|
|
1405
1786
|
mcp.close();
|
|
1787
|
+
if (saveTimer !== void 0) {
|
|
1788
|
+
clearTimeout(saveTimer);
|
|
1789
|
+
saveTimer = void 0;
|
|
1790
|
+
}
|
|
1791
|
+
flushRoster();
|
|
1406
1792
|
server.closeAllConnections?.();
|
|
1407
1793
|
server.close();
|
|
1408
1794
|
}
|
|
@@ -1413,17 +1799,17 @@ function startHub(config = {}, log2 = console) {
|
|
|
1413
1799
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1414
1800
|
import { existsSync as existsSync2 } from "node:fs";
|
|
1415
1801
|
import { homedir as homedir2 } from "node:os";
|
|
1416
|
-
import { dirname as
|
|
1802
|
+
import { dirname as dirname3, join as join2 } from "node:path";
|
|
1417
1803
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1418
1804
|
|
|
1419
1805
|
// src/discover.ts
|
|
1420
1806
|
import { execFileSync } from "node:child_process";
|
|
1421
|
-
import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
|
|
1807
|
+
import { existsSync, readdirSync, accessSync, readFileSync as readFileSync2, constants as fsConstants } from "node:fs";
|
|
1422
1808
|
import { homedir } from "node:os";
|
|
1423
|
-
import { dirname, join, sep } from "node:path";
|
|
1809
|
+
import { dirname as dirname2, join, sep } from "node:path";
|
|
1424
1810
|
import { fileURLToPath } from "node:url";
|
|
1425
1811
|
function registryFile() {
|
|
1426
|
-
return join(
|
|
1812
|
+
return join(dirname2(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
|
|
1427
1813
|
}
|
|
1428
1814
|
function expandHome(file, home) {
|
|
1429
1815
|
return file.startsWith("~/") ? join(home, file.slice(2)) : file;
|
|
@@ -1479,7 +1865,7 @@ function validateRegistry(registry) {
|
|
|
1479
1865
|
}
|
|
1480
1866
|
}
|
|
1481
1867
|
function loadRegistry(file = registryFile()) {
|
|
1482
|
-
const registry = JSON.parse(
|
|
1868
|
+
const registry = JSON.parse(readFileSync2(file, "utf8"));
|
|
1483
1869
|
validateRegistry(registry);
|
|
1484
1870
|
return registry;
|
|
1485
1871
|
}
|
|
@@ -1593,7 +1979,7 @@ function runDiscover(options = {}) {
|
|
|
1593
1979
|
var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
|
|
1594
1980
|
var DEFAULT_SERVER = "agent-hub";
|
|
1595
1981
|
function defaultSkillSrc() {
|
|
1596
|
-
return join2(
|
|
1982
|
+
return join2(dirname3(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
|
|
1597
1983
|
}
|
|
1598
1984
|
function stamp() {
|
|
1599
1985
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1609,7 +1995,7 @@ async function readJson(file) {
|
|
|
1609
1995
|
}
|
|
1610
1996
|
}
|
|
1611
1997
|
async function writeJsonNoBom(file, doc) {
|
|
1612
|
-
await mkdir(
|
|
1998
|
+
await mkdir(dirname3(file), { recursive: true });
|
|
1613
1999
|
await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1614
2000
|
}
|
|
1615
2001
|
async function backup(file) {
|
|
@@ -1677,12 +2063,13 @@ function escapeRegExp(value) {
|
|
|
1677
2063
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1678
2064
|
}
|
|
1679
2065
|
var DSH_PATCH_MARKER = "# \u2500\u2500 agent-comm-hub MCP client";
|
|
2066
|
+
var DSH_PLUGIN_NAME = "@deepseek-ai/dsh-mcp-client";
|
|
1680
2067
|
function dshPatchBlock(url, serverName) {
|
|
1681
2068
|
return `
|
|
1682
2069
|
${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --remove\`) \u2500
|
|
1683
2070
|
- insert:
|
|
1684
2071
|
- id: ${serverName}
|
|
1685
|
-
name: '
|
|
2072
|
+
name: '${DSH_PLUGIN_NAME}'
|
|
1686
2073
|
config:
|
|
1687
2074
|
serverName: ${serverName}
|
|
1688
2075
|
transport: streamable-http
|
|
@@ -1693,55 +2080,47 @@ async function mergeDshPatch(file, opts) {
|
|
|
1693
2080
|
if (!existsSync2(file)) return "skipped";
|
|
1694
2081
|
const text = await readFile(file, "utf8");
|
|
1695
2082
|
const lines = text.split("\n");
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
for (let i = markerLine + 1; i < lines.length; i++) {
|
|
1701
|
-
if (/^- /.test(lines[i])) {
|
|
1702
|
-
entryStart = i;
|
|
1703
|
-
break;
|
|
1704
|
-
}
|
|
2083
|
+
const hubEntryRanges = () => {
|
|
2084
|
+
const tops = [];
|
|
2085
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2086
|
+
if (/^- /.test(lines[i])) tops.push(i);
|
|
1705
2087
|
}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
}
|
|
2088
|
+
const ranges2 = [];
|
|
2089
|
+
for (let k = 0; k < tops.length; k++) {
|
|
2090
|
+
const end = k + 1 < tops.length ? tops[k + 1] : lines.length;
|
|
2091
|
+
if (!lines.slice(tops[k], end).some((line) => line.includes(DSH_PLUGIN_NAME))) continue;
|
|
2092
|
+
let start = tops[k];
|
|
2093
|
+
while (start > 0 && lines[start - 1].trim() === "") start--;
|
|
2094
|
+
if (start > 0 && lines[start - 1].includes(DSH_PATCH_MARKER)) start--;
|
|
2095
|
+
ranges2.push({ start, end });
|
|
1714
2096
|
}
|
|
1715
|
-
|
|
1716
|
-
while (start > 0 && lines[start - 1].trim() === "") start--;
|
|
1717
|
-
return { start, end };
|
|
2097
|
+
return ranges2;
|
|
1718
2098
|
};
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
2099
|
+
const withoutHubEntries = () => {
|
|
2100
|
+
let out = lines;
|
|
2101
|
+
for (const range of hubEntryRanges().reverse()) {
|
|
2102
|
+
out = out.slice(0, range.start).concat(out.slice(range.end));
|
|
2103
|
+
}
|
|
2104
|
+
return out.join("\n");
|
|
1722
2105
|
};
|
|
1723
2106
|
if (opts.remove) {
|
|
1724
|
-
if (
|
|
1725
|
-
const cleaned = withoutBlock();
|
|
2107
|
+
if (hubEntryRanges().length === 0) return "absent";
|
|
1726
2108
|
await backup(file);
|
|
1727
|
-
await writeFile(file,
|
|
2109
|
+
await writeFile(file, withoutHubEntries(), "utf8");
|
|
1728
2110
|
return "removed";
|
|
1729
2111
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
await backup(file);
|
|
1734
|
-
await writeFile(file, replaced.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
|
|
1735
|
-
return "changed";
|
|
2112
|
+
const ranges = hubEntryRanges();
|
|
2113
|
+
if (ranges.length === 1 && lines.slice(ranges[0].start, ranges[0].end).some((line) => line.includes(`url: ${opts.url}`))) {
|
|
2114
|
+
return "unchanged";
|
|
1736
2115
|
}
|
|
1737
2116
|
await backup(file);
|
|
1738
|
-
await writeFile(file,
|
|
2117
|
+
await writeFile(file, withoutHubEntries().trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
|
|
1739
2118
|
return "changed";
|
|
1740
2119
|
}
|
|
1741
2120
|
async function syncSkill(skillDir, skillSrc, remove, log2) {
|
|
1742
2121
|
if (remove) {
|
|
1743
2122
|
if (existsSync2(skillDir)) {
|
|
1744
|
-
await mkdir(
|
|
2123
|
+
await mkdir(dirname3(skillDir), { recursive: true });
|
|
1745
2124
|
await rmRecursive(skillDir);
|
|
1746
2125
|
log2(` skill removed: ${skillDir}`);
|
|
1747
2126
|
}
|
|
@@ -1833,9 +2212,9 @@ async function runSetup(options = {}) {
|
|
|
1833
2212
|
// src/ops.ts
|
|
1834
2213
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1835
2214
|
import { request } from "node:http";
|
|
1836
|
-
import { mkdirSync, readFileSync as
|
|
2215
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1837
2216
|
import { homedir as homedir3 } from "node:os";
|
|
1838
|
-
import { dirname as
|
|
2217
|
+
import { dirname as dirname4, join as join3 } from "node:path";
|
|
1839
2218
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1840
2219
|
async function runStatus(options = {}) {
|
|
1841
2220
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -1888,8 +2267,8 @@ async function runStatus(options = {}) {
|
|
|
1888
2267
|
function runUpdate() {
|
|
1889
2268
|
const messages = [];
|
|
1890
2269
|
try {
|
|
1891
|
-
const pkgFile = join3(
|
|
1892
|
-
const before = JSON.parse(
|
|
2270
|
+
const pkgFile = join3(dirname4(fileURLToPath3(import.meta.url)), "..", "package.json");
|
|
2271
|
+
const before = JSON.parse(readFileSync3(pkgFile, "utf8")).version ?? "?";
|
|
1893
2272
|
messages.push(`current version: ${before}`);
|
|
1894
2273
|
const script = [
|
|
1895
2274
|
"import { execFileSync } from 'node:child_process'",
|
|
@@ -1945,8 +2324,8 @@ function runService(options) {
|
|
|
1945
2324
|
messages.push(`[dry-run] write ${vbs}`);
|
|
1946
2325
|
messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
|
|
1947
2326
|
} else {
|
|
1948
|
-
|
|
1949
|
-
|
|
2327
|
+
mkdirSync2(launcherDir, { recursive: true });
|
|
2328
|
+
writeFileSync2(vbs, vbsContent);
|
|
1950
2329
|
execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
|
|
1951
2330
|
messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
|
|
1952
2331
|
messages.push(`start it now with: wscript.exe "${vbs}"`);
|
|
@@ -1985,8 +2364,8 @@ WantedBy=default.target
|
|
|
1985
2364
|
messages.push(`[dry-run] would write ${unitFile}`);
|
|
1986
2365
|
messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
|
|
1987
2366
|
} else {
|
|
1988
|
-
|
|
1989
|
-
|
|
2367
|
+
mkdirSync2(unitDir, { recursive: true });
|
|
2368
|
+
writeFileSync2(unitFile, unit);
|
|
1990
2369
|
run("systemctl", ["--user", "daemon-reload"], false);
|
|
1991
2370
|
const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
|
|
1992
2371
|
messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
|
|
@@ -2039,8 +2418,8 @@ WantedBy=default.target
|
|
|
2039
2418
|
messages.push(`[dry-run] write ${plist}`);
|
|
2040
2419
|
messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
|
|
2041
2420
|
} else {
|
|
2042
|
-
|
|
2043
|
-
|
|
2421
|
+
mkdirSync2(launchAgentsDir, { recursive: true });
|
|
2422
|
+
writeFileSync2(plist, plistContent);
|
|
2044
2423
|
try {
|
|
2045
2424
|
execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
|
|
2046
2425
|
} catch {
|
|
@@ -2075,10 +2454,13 @@ WantedBy=default.target
|
|
|
2075
2454
|
}
|
|
2076
2455
|
|
|
2077
2456
|
// src/cli.ts
|
|
2457
|
+
import { homedir as homedir4 } from "node:os";
|
|
2458
|
+
import { join as join4 } from "node:path";
|
|
2459
|
+
var DEFAULT_STATE_FILE = join4(homedir4(), ".agent-comm-hub", "roster.json");
|
|
2078
2460
|
function parseArgs(argv) {
|
|
2079
2461
|
const args = {};
|
|
2080
2462
|
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"]);
|
|
2081
|
-
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin"]);
|
|
2463
|
+
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file"]);
|
|
2082
2464
|
for (let i = 0; i < argv.length; i++) {
|
|
2083
2465
|
const flag = argv[i];
|
|
2084
2466
|
if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
|
|
@@ -2130,7 +2512,7 @@ Hub options:
|
|
|
2130
2512
|
--port <n> Listen port (default 18764)
|
|
2131
2513
|
--path <p> MCP endpoint path (default /mcp)
|
|
2132
2514
|
--max-queue <n> Queued messages per peer before dropping oldest (default 200)
|
|
2133
|
-
--history-limit <n> Retained history messages (default
|
|
2515
|
+
--history-limit <n> Retained history messages (default 1000)
|
|
2134
2516
|
--wait-timeout-ms <n> Long-poll ceiling for bridge_wait (default 60000)
|
|
2135
2517
|
--default-wait-ms <n> bridge_wait default budget (default 30000)
|
|
2136
2518
|
--connected-window-ms <n> Peer counts as active within this window (default 30000)
|
|
@@ -2138,6 +2520,13 @@ Hub options:
|
|
|
2138
2520
|
--herdr-bin <path> herdr CLI binary for bridge_agent_* control tools
|
|
2139
2521
|
(default herdr, resolved via PATH)
|
|
2140
2522
|
--herdr-timeout-ms <n> Default cap for one herdr call in ms (default 30000)
|
|
2523
|
+
--manager-peers <ids> Comma-separated peer ids allowed to manage the
|
|
2524
|
+
roster (rename others / kick), or "all".
|
|
2525
|
+
(default agent-hub-cli \u2014 the desktop GUI identity)
|
|
2526
|
+
--state-file <path> Persist the peer roster (aliases, client info) to
|
|
2527
|
+
this JSON file so identities survive restarts
|
|
2528
|
+
(default ~/.agent-comm-hub/roster.json; "off"
|
|
2529
|
+
keeps everything in memory)
|
|
2141
2530
|
|
|
2142
2531
|
Setup options:
|
|
2143
2532
|
--url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
|
|
@@ -2195,7 +2584,8 @@ try {
|
|
|
2195
2584
|
console.log("no peers online yet \u2014 start an agent session to see it appear");
|
|
2196
2585
|
} else {
|
|
2197
2586
|
for (const peer of result.peers) {
|
|
2198
|
-
|
|
2587
|
+
const label = peer.alias !== void 0 && peer.alias !== peer.id ? `${peer.id} (${peer.alias})` : peer.id;
|
|
2588
|
+
console.log(` ${label.padEnd(32)} ${peer.connected ? "connected" : "offline"}`);
|
|
2199
2589
|
}
|
|
2200
2590
|
}
|
|
2201
2591
|
process.exit(0);
|
|
@@ -2255,7 +2645,9 @@ try {
|
|
|
2255
2645
|
connectedWindowMs: args["--connected-window-ms"],
|
|
2256
2646
|
peerIdleTimeoutMs: args["--peer-idle-timeout-ms"],
|
|
2257
2647
|
herdrBin: args["--herdr-bin"],
|
|
2258
|
-
herdrTimeoutMs: args["--herdr-timeout-ms"]
|
|
2648
|
+
herdrTimeoutMs: args["--herdr-timeout-ms"],
|
|
2649
|
+
managerPeers: args["--manager-peers"] === void 0 ? void 0 : args["--manager-peers"] === "all" ? "all" : String(args["--manager-peers"]).split(",").map((id) => id.trim()).filter((id) => id !== ""),
|
|
2650
|
+
stateFile: args["--state-file"] === void 0 ? DEFAULT_STATE_FILE : args["--state-file"] === "off" ? void 0 : String(args["--state-file"])
|
|
2259
2651
|
}, log);
|
|
2260
2652
|
const shutdown = () => {
|
|
2261
2653
|
log.info("agent-comm-hub shutting down");
|