agent-comm-hub 0.5.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/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
- register(peerId) {
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 timeouts. */
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());
@@ -526,13 +648,17 @@ var AgentHub = class {
526
648
  const now = Date.now();
527
649
  return {
528
650
  server: "agent-comm-hub",
529
- peers: this.peers().map((peer) => ({
530
- id: peer,
531
- connected: this.isActive(peer) || livePeers?.has(peer) === true,
532
- lastSeenMs: this.lastSeen.get(peer) ?? 0,
533
- queued: (this.queues.get(peer) ?? []).length,
534
- waiting: (this.waiters.get(peer) ?? []).length
535
- })),
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
+ }),
536
662
  historyLimit: this.options.historyLimit,
537
663
  maxQueue: this.options.maxQueue
538
664
  };
@@ -624,7 +750,7 @@ var AgentHub = class {
624
750
  if (queue.length >= this.options.maxQueue) queue.shift();
625
751
  queue.push(message);
626
752
  this.queues.set(target, queue);
627
- this.options.onQueued?.(message);
753
+ this.options.onQueued?.(message, target);
628
754
  }
629
755
  /** Append a delivered message to the history ring. */
630
756
  remember(message) {
@@ -642,6 +768,10 @@ function drainFrom(queue, from) {
642
768
  queue.splice(0, queue.length, ...kept);
643
769
  return drained;
644
770
  }
771
+ function omit(value, key) {
772
+ const { [key]: _dropped, ...rest } = value;
773
+ return rest;
774
+ }
645
775
 
646
776
  // src/hub-tools.ts
647
777
  var DEFAULT_WAIT_MS = 3e4;
@@ -657,13 +787,25 @@ function sanitizePeerId(name) {
657
787
  const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
658
788
  return cleaned === "" ? "agent" : cleaned;
659
789
  }
660
- function autoRegisterPeer(hub, registry, sessionId, clientName) {
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) {
661
798
  if (sessionId === void 0) return void 0;
662
799
  const bound = registry.peerFor(sessionId);
663
800
  if (bound !== void 0) return bound;
664
801
  if (registry.isSuppressed(sessionId)) return void 0;
665
802
  const peerId = sanitizePeerId(clientName ?? "agent");
666
- if (!hub.has(peerId)) hub.register(peerId);
803
+ if (!hub.has(peerId)) {
804
+ hub.register(peerId, {
805
+ ...clientName !== void 0 ? { name: clientName } : {},
806
+ ...clientVersion !== void 0 ? { version: clientVersion } : {}
807
+ });
808
+ }
667
809
  registry.bindPeer(sessionId, peerId);
668
810
  hub.touch(peerId);
669
811
  return peerId;
@@ -676,6 +818,23 @@ function livePeersFor(registry) {
676
818
  }
677
819
  return live;
678
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
+ }
679
838
  function hubTools(hub, registry, options) {
680
839
  const schema = (properties, required = []) => ({
681
840
  type: "object",
@@ -719,6 +878,12 @@ function hubTools(hub, registry, options) {
719
878
  }
720
879
  return herdr;
721
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
+ };
722
887
  const asStatuses = (value) => {
723
888
  if (!Array.isArray(value)) return void 0;
724
889
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
@@ -731,42 +896,108 @@ function hubTools(hub, registry, options) {
731
896
  return [
732
897
  {
733
898
  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({ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars.") }, ["peerId"]),
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"]),
736
904
  handler: async (args, sessionId) => {
737
905
  const peerId = String(args.peerId);
738
- if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
906
+ if (!PEER_ID_PATTERN.test(peerId)) {
739
907
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
740
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));
741
913
  if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
742
914
  throw new Error(`peer already registered by another connection: ${peerId}`);
743
915
  }
744
916
  const current = registry.peerFor(sessionId);
745
917
  if (current !== void 0 && current !== peerId) {
746
- registry.unbindPeer(sessionId);
747
- if (registry.attachedCount(current) === 0) hub.unregister(current);
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
+ }
748
925
  }
749
- registry.bindPeer(sessionId, peerId);
750
926
  if (!hub.has(peerId)) hub.register(peerId);
927
+ registry.bindPeer(sessionId, peerId);
751
928
  registry.clearSuppress(sessionId);
752
929
  hub.touch(peerId);
753
- return { ok: true, peerId, peers: hub.peers() };
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() };
754
933
  }
755
934
  },
756
935
  {
757
936
  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
- handler: async (_args, sessionId) => {
761
- const peer = registry.peerFor(sessionId);
762
- if (peer !== void 0) {
763
- registry.unbindPeer(sessionId);
764
- if (registry.attachedCount(peer) === 0) hub.unregister(peer);
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 };
765
950
  }
766
- registry.suppressAuto(sessionId);
767
- return { ok: true, peerId: peer ?? null };
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 };
957
+ }
958
+ hub.unregister(target);
959
+ const detached = registry.unbindPeerId(target, { suppress: true });
960
+ return { ok: true, peerId: target, kicked: true, detachedSessions: detached.length };
768
961
  }
769
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
+ },
770
1001
  {
771
1002
  name: "bridge_chat",
772
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.',
@@ -837,20 +1068,21 @@ function hubTools(hub, registry, options) {
837
1068
  },
838
1069
  {
839
1070
  name: "bridge_peers",
840
- description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
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.",
841
1072
  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
- })
1073
+ handler: wrap(true, async () => ({ peers: rosterFor(hub, registry) }))
846
1074
  },
847
1075
  {
848
1076
  name: "bridge_history",
849
- description: 'Recent messages involving you (newest first); pass `peer` to inspect another peer\'s conversation, or `peer: "all"` for the unfiltered tail across every peer. Use to refresh context after a reconnect.',
850
- inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself.'), limit: int("How many messages to return (default 20).") }),
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).") }),
851
1079
  handler: wrap(true, async (args, peer) => {
852
1080
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
853
- const messages = args.peer === "all" ? hub.historyAll(limit) : hub.history(args.peer === void 0 ? peer : String(args.peer), limit);
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);
854
1086
  return { messages: messages.map(present) };
855
1087
  })
856
1088
  },
@@ -1096,15 +1328,23 @@ var SessionRegistry = class {
1096
1328
  }
1097
1329
  /** Client-reported name per session (from the initialize clientInfo). */
1098
1330
  clientNames = /* @__PURE__ */ new Map();
1099
- /** Remember the client name reported by a session (on initialize). */
1100
- noteClient(sessionId, name) {
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) {
1101
1335
  this.clientNames.set(sessionId, name);
1336
+ if (version !== void 0) this.clientVersions.set(sessionId, version);
1102
1337
  }
1103
1338
  /** Client-reported name for a session, if any. */
1104
1339
  clientName(sessionId) {
1105
1340
  if (sessionId === void 0) return void 0;
1106
1341
  return this.clientNames.get(sessionId);
1107
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
+ }
1108
1348
  /** Sessions whose owner explicitly unregistered; auto-registration is
1109
1349
  * suppressed for them until an explicit `bridge_register`. */
1110
1350
  suppressedAuto = /* @__PURE__ */ new Set();
@@ -1134,11 +1374,39 @@ var SessionRegistry = class {
1134
1374
  liveSessions() {
1135
1375
  return this.liveStreams;
1136
1376
  }
1137
- /** Drop every binding that points at `peerId` (used by the idle GC). */
1138
- unbindPeerId(peerId) {
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 = [];
1139
1383
  for (const [sessionId, bound] of this.peerBindings) {
1140
- if (bound === peerId) this.peerBindings.delete(sessionId);
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);
1141
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;
1403
+ for (const [sessionId, bound] of this.peerBindings) {
1404
+ if (bound === oldId) {
1405
+ this.peerBindings.set(sessionId, newId);
1406
+ count++;
1407
+ }
1408
+ }
1409
+ return count;
1142
1410
  }
1143
1411
  /** How many sessions are currently attached to `peerId`. */
1144
1412
  attachedCount(peerId) {
@@ -1188,6 +1456,30 @@ var McpStreamableHttpServer = class {
1188
1456
  for (const stream of this.sseStreams.values()) stream.end();
1189
1457
  this.sseStreams.clear();
1190
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
+ }
1191
1483
  handleGet(req, res) {
1192
1484
  const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
1193
1485
  res.writeHead(200, {
@@ -1251,10 +1543,11 @@ var McpStreamableHttpServer = class {
1251
1543
  const newSessionId = this.registry.ensureSession(sessionId);
1252
1544
  const clientInfo = message.params?.clientInfo;
1253
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;
1254
1547
  if (clientName !== void 0) {
1255
- this.registry.noteClient(newSessionId, clientName);
1548
+ this.registry.noteClient(newSessionId, clientName, clientVersion);
1256
1549
  }
1257
- this.onInitialize(newSessionId, clientName);
1550
+ this.onInitialize(newSessionId, clientName, clientVersion);
1258
1551
  const requested = message.params?.protocolVersion;
1259
1552
  const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
1260
1553
  return {
@@ -1344,7 +1637,7 @@ function readBody(req) {
1344
1637
 
1345
1638
  // src/index.ts
1346
1639
  var SERVER_NAME = "agent-comm-hub";
1347
- var SERVER_VERSION = "0.5.0";
1640
+ var SERVER_VERSION = "0.6.0";
1348
1641
  var DEFAULT_HOST = "127.0.0.1";
1349
1642
  var DEFAULT_PORT = 18764;
1350
1643
  var DEFAULT_PATH = "/mcp";
@@ -1360,11 +1653,57 @@ var DEFAULT_CONFIG = {
1360
1653
  waitTimeoutMs: 6e4,
1361
1654
  defaultWaitMs: 3e4,
1362
1655
  connectedWindowMs: 3e4,
1363
- 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"]
1364
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
+ }
1365
1668
  function startHub(config = {}, log2 = console) {
1366
1669
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
1367
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
+ };
1368
1707
  const hub = new AgentHub({
1369
1708
  maxQueue: resolved.maxQueue,
1370
1709
  historyLimit: resolved.historyLimit,
@@ -1373,9 +1712,37 @@ function startHub(config = {}, log2 = console) {
1373
1712
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
1374
1713
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
1375
1714
  // The idle GC must never evict a peer whose session has a live SSE channel.
1376
- 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
+ }
1377
1733
  });
1378
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
+ }
1379
1746
  const herdr = new HerdrCtl({
1380
1747
  bin: resolved.herdrBin,
1381
1748
  baseArgs: resolved.herdrBaseArgs,
@@ -1383,19 +1750,20 @@ function startHub(config = {}, log2 = console) {
1383
1750
  socketPath: resolved.herdrSocketPath,
1384
1751
  sendRequest: resolved.herdrSendRequest
1385
1752
  });
1386
- const mcp = new McpStreamableHttpServer(
1753
+ mcp = new McpStreamableHttpServer(
1387
1754
  hubTools(hub, registry, {
1388
1755
  defaultWaitMs: resolved.defaultWaitMs,
1389
1756
  waitTimeoutMs: resolved.waitTimeoutMs,
1390
1757
  herdr,
1391
- 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)
1392
1760
  }),
1393
1761
  { name: SERVER_NAME, version: SERVER_VERSION },
1394
1762
  registry,
1395
1763
  (message) => log2.warn(message),
1396
- (sessionId, clientName) => {
1764
+ (sessionId, clientName, clientVersion) => {
1397
1765
  try {
1398
- const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
1766
+ const peer = autoRegisterPeer(hub, registry, sessionId, clientName, clientVersion);
1399
1767
  if (peer !== void 0) log2.info(`peer joined: ${peer}`);
1400
1768
  } catch (error) {
1401
1769
  log2.warn(`auto-register failed: ${error.message}`);
@@ -1416,6 +1784,11 @@ function startHub(config = {}, log2 = console) {
1416
1784
  close: () => {
1417
1785
  hub.dispose();
1418
1786
  mcp.close();
1787
+ if (saveTimer !== void 0) {
1788
+ clearTimeout(saveTimer);
1789
+ saveTimer = void 0;
1790
+ }
1791
+ flushRoster();
1419
1792
  server.closeAllConnections?.();
1420
1793
  server.close();
1421
1794
  }
@@ -1426,17 +1799,17 @@ function startHub(config = {}, log2 = console) {
1426
1799
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1427
1800
  import { existsSync as existsSync2 } from "node:fs";
1428
1801
  import { homedir as homedir2 } from "node:os";
1429
- import { dirname as dirname2, join as join2 } from "node:path";
1802
+ import { dirname as dirname3, join as join2 } from "node:path";
1430
1803
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1431
1804
 
1432
1805
  // src/discover.ts
1433
1806
  import { execFileSync } from "node:child_process";
1434
- 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";
1435
1808
  import { homedir } from "node:os";
1436
- import { dirname, join, sep } from "node:path";
1809
+ import { dirname as dirname2, join, sep } from "node:path";
1437
1810
  import { fileURLToPath } from "node:url";
1438
1811
  function registryFile() {
1439
- return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1812
+ return join(dirname2(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1440
1813
  }
1441
1814
  function expandHome(file, home) {
1442
1815
  return file.startsWith("~/") ? join(home, file.slice(2)) : file;
@@ -1492,7 +1865,7 @@ function validateRegistry(registry) {
1492
1865
  }
1493
1866
  }
1494
1867
  function loadRegistry(file = registryFile()) {
1495
- const registry = JSON.parse(readFileSync(file, "utf8"));
1868
+ const registry = JSON.parse(readFileSync2(file, "utf8"));
1496
1869
  validateRegistry(registry);
1497
1870
  return registry;
1498
1871
  }
@@ -1606,7 +1979,7 @@ function runDiscover(options = {}) {
1606
1979
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
1607
1980
  var DEFAULT_SERVER = "agent-hub";
1608
1981
  function defaultSkillSrc() {
1609
- return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1982
+ return join2(dirname3(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1610
1983
  }
1611
1984
  function stamp() {
1612
1985
  const now = /* @__PURE__ */ new Date();
@@ -1622,7 +1995,7 @@ async function readJson(file) {
1622
1995
  }
1623
1996
  }
1624
1997
  async function writeJsonNoBom(file, doc) {
1625
- await mkdir(dirname2(file), { recursive: true });
1998
+ await mkdir(dirname3(file), { recursive: true });
1626
1999
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
1627
2000
  }
1628
2001
  async function backup(file) {
@@ -1690,12 +2063,13 @@ function escapeRegExp(value) {
1690
2063
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1691
2064
  }
1692
2065
  var DSH_PATCH_MARKER = "# \u2500\u2500 agent-comm-hub MCP client";
2066
+ var DSH_PLUGIN_NAME = "@deepseek-ai/dsh-mcp-client";
1693
2067
  function dshPatchBlock(url, serverName) {
1694
2068
  return `
1695
2069
  ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --remove\`) \u2500
1696
2070
  - insert:
1697
2071
  - id: ${serverName}
1698
- name: '@deepseek-ai/dsh-mcp-client'
2072
+ name: '${DSH_PLUGIN_NAME}'
1699
2073
  config:
1700
2074
  serverName: ${serverName}
1701
2075
  transport: streamable-http
@@ -1706,55 +2080,47 @@ async function mergeDshPatch(file, opts) {
1706
2080
  if (!existsSync2(file)) return "skipped";
1707
2081
  const text = await readFile(file, "utf8");
1708
2082
  const lines = text.split("\n");
1709
- const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
1710
- const hasBlock = markerLine >= 0;
1711
- const blockRange = () => {
1712
- let entryStart = -1;
1713
- for (let i = markerLine + 1; i < lines.length; i++) {
1714
- if (/^- /.test(lines[i])) {
1715
- entryStart = i;
1716
- break;
1717
- }
2083
+ const hubEntryRanges = () => {
2084
+ const tops = [];
2085
+ for (let i = 0; i < lines.length; i++) {
2086
+ if (/^- /.test(lines[i])) tops.push(i);
1718
2087
  }
1719
- let end = lines.length;
1720
- if (entryStart >= 0) {
1721
- for (let i = entryStart + 1; i < lines.length; i++) {
1722
- if (/^- /.test(lines[i])) {
1723
- end = i;
1724
- break;
1725
- }
1726
- }
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 });
1727
2096
  }
1728
- let start = markerLine;
1729
- while (start > 0 && lines[start - 1].trim() === "") start--;
1730
- return { start, end };
2097
+ return ranges2;
1731
2098
  };
1732
- const withoutBlock = () => {
1733
- const { start, end } = blockRange();
1734
- return lines.slice(0, start).concat(lines.slice(end)).join("\n");
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");
1735
2105
  };
1736
2106
  if (opts.remove) {
1737
- if (!hasBlock) return "absent";
1738
- const cleaned = withoutBlock();
2107
+ if (hubEntryRanges().length === 0) return "absent";
1739
2108
  await backup(file);
1740
- await writeFile(file, cleaned, "utf8");
2109
+ await writeFile(file, withoutHubEntries(), "utf8");
1741
2110
  return "removed";
1742
2111
  }
1743
- if (hasBlock) {
1744
- if (lines.some((line) => line.includes(`url: ${opts.url}`))) return "unchanged";
1745
- const replaced = withoutBlock();
1746
- await backup(file);
1747
- await writeFile(file, replaced.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
1748
- 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";
1749
2115
  }
1750
2116
  await backup(file);
1751
- await writeFile(file, text.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
2117
+ await writeFile(file, withoutHubEntries().trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
1752
2118
  return "changed";
1753
2119
  }
1754
2120
  async function syncSkill(skillDir, skillSrc, remove, log2) {
1755
2121
  if (remove) {
1756
2122
  if (existsSync2(skillDir)) {
1757
- await mkdir(dirname2(skillDir), { recursive: true });
2123
+ await mkdir(dirname3(skillDir), { recursive: true });
1758
2124
  await rmRecursive(skillDir);
1759
2125
  log2(` skill removed: ${skillDir}`);
1760
2126
  }
@@ -1846,9 +2212,9 @@ async function runSetup(options = {}) {
1846
2212
  // src/ops.ts
1847
2213
  import { execFileSync as execFileSync2 } from "node:child_process";
1848
2214
  import { request } from "node:http";
1849
- import { mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
2215
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1850
2216
  import { homedir as homedir3 } from "node:os";
1851
- import { dirname as dirname3, join as join3 } from "node:path";
2217
+ import { dirname as dirname4, join as join3 } from "node:path";
1852
2218
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1853
2219
  async function runStatus(options = {}) {
1854
2220
  const host = options.host ?? "127.0.0.1";
@@ -1901,8 +2267,8 @@ async function runStatus(options = {}) {
1901
2267
  function runUpdate() {
1902
2268
  const messages = [];
1903
2269
  try {
1904
- const pkgFile = join3(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
1905
- const before = JSON.parse(readFileSync2(pkgFile, "utf8")).version ?? "?";
2270
+ const pkgFile = join3(dirname4(fileURLToPath3(import.meta.url)), "..", "package.json");
2271
+ const before = JSON.parse(readFileSync3(pkgFile, "utf8")).version ?? "?";
1906
2272
  messages.push(`current version: ${before}`);
1907
2273
  const script = [
1908
2274
  "import { execFileSync } from 'node:child_process'",
@@ -1958,8 +2324,8 @@ function runService(options) {
1958
2324
  messages.push(`[dry-run] write ${vbs}`);
1959
2325
  messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
1960
2326
  } else {
1961
- mkdirSync(launcherDir, { recursive: true });
1962
- writeFileSync(vbs, vbsContent);
2327
+ mkdirSync2(launcherDir, { recursive: true });
2328
+ writeFileSync2(vbs, vbsContent);
1963
2329
  execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1964
2330
  messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
1965
2331
  messages.push(`start it now with: wscript.exe "${vbs}"`);
@@ -1998,8 +2364,8 @@ WantedBy=default.target
1998
2364
  messages.push(`[dry-run] would write ${unitFile}`);
1999
2365
  messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
2000
2366
  } else {
2001
- mkdirSync(unitDir, { recursive: true });
2002
- writeFileSync(unitFile, unit);
2367
+ mkdirSync2(unitDir, { recursive: true });
2368
+ writeFileSync2(unitFile, unit);
2003
2369
  run("systemctl", ["--user", "daemon-reload"], false);
2004
2370
  const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
2005
2371
  messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
@@ -2052,8 +2418,8 @@ WantedBy=default.target
2052
2418
  messages.push(`[dry-run] write ${plist}`);
2053
2419
  messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
2054
2420
  } else {
2055
- mkdirSync(launchAgentsDir, { recursive: true });
2056
- writeFileSync(plist, plistContent);
2421
+ mkdirSync2(launchAgentsDir, { recursive: true });
2422
+ writeFileSync2(plist, plistContent);
2057
2423
  try {
2058
2424
  execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
2059
2425
  } catch {
@@ -2088,10 +2454,13 @@ WantedBy=default.target
2088
2454
  }
2089
2455
 
2090
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");
2091
2460
  function parseArgs(argv) {
2092
2461
  const args = {};
2093
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"]);
2094
- 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"]);
2095
2464
  for (let i = 0; i < argv.length; i++) {
2096
2465
  const flag = argv[i];
2097
2466
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
@@ -2151,6 +2520,13 @@ Hub options:
2151
2520
  --herdr-bin <path> herdr CLI binary for bridge_agent_* control tools
2152
2521
  (default herdr, resolved via PATH)
2153
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)
2154
2530
 
2155
2531
  Setup options:
2156
2532
  --url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
@@ -2208,7 +2584,8 @@ try {
2208
2584
  console.log("no peers online yet \u2014 start an agent session to see it appear");
2209
2585
  } else {
2210
2586
  for (const peer of result.peers) {
2211
- console.log(` ${peer.id.padEnd(32)} ${peer.connected ? "connected" : "offline"}`);
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"}`);
2212
2589
  }
2213
2590
  }
2214
2591
  process.exit(0);
@@ -2268,7 +2645,9 @@ try {
2268
2645
  connectedWindowMs: args["--connected-window-ms"],
2269
2646
  peerIdleTimeoutMs: args["--peer-idle-timeout-ms"],
2270
2647
  herdrBin: args["--herdr-bin"],
2271
- 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"])
2272
2651
  }, log);
2273
2652
  const shutdown = () => {
2274
2653
  log.info("agent-comm-hub shutting down");