agent-comm-hub 0.9.0 → 0.10.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
@@ -4,7 +4,7 @@
4
4
  import { createServer } from "node:http";
5
5
  import { randomBytes as randomBytes2 } from "node:crypto";
6
6
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
7
- import { dirname as dirname2 } from "node:path";
7
+ import { dirname as dirname2, join } from "node:path";
8
8
 
9
9
  // src/herdr-ctl.ts
10
10
  import { execFile } from "node:child_process";
@@ -417,6 +417,104 @@ function socketSend(socketPath, method, params) {
417
417
  });
418
418
  }
419
419
 
420
+ // src/runner.ts
421
+ import { execFile as execFile2 } from "node:child_process";
422
+ var DEFAULT_RUN_TIMEOUT_MS = 12e4;
423
+ var MAX_RUN_NOTE_CHARS = 4e3;
424
+ function validateRunnerConfig(config, index) {
425
+ const where = `runners[${index}]`;
426
+ if (typeof config.peer !== "string" || config.peer === "") throw new Error(`${where}: peer must be a non-empty string`);
427
+ if (config.kind !== "cli" && config.kind !== "herdr") throw new Error(`${where}: kind must be 'cli' or 'herdr'`);
428
+ if (config.kind === "cli") {
429
+ if (typeof config.command !== "string" || config.command === "") throw new Error(`${where}: cli needs a command`);
430
+ if (config.args !== void 0 && (!Array.isArray(config.args) || config.args.some((arg) => typeof arg !== "string"))) {
431
+ throw new Error(`${where}: args must be an array of strings`);
432
+ }
433
+ }
434
+ if (config.timeoutMs !== void 0 && (!Number.isFinite(config.timeoutMs) || config.timeoutMs <= 0)) {
435
+ throw new Error(`${where}: timeoutMs must be a positive number`);
436
+ }
437
+ }
438
+ var truncate = (text) => text.length > MAX_RUN_NOTE_CHARS ? `${text.slice(0, MAX_RUN_NOTE_CHARS)}\u2026 (truncated)` : text;
439
+ function substitute(arg, request2) {
440
+ const values = {
441
+ prompt: request2.prompt,
442
+ context: request2.context,
443
+ deliverable: request2.deliverable,
444
+ taskId: request2.taskId,
445
+ peer: request2.peer
446
+ };
447
+ return arg.replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
448
+ }
449
+ function cliRunner(config) {
450
+ const command = config.command;
451
+ const args = config.args ?? [];
452
+ const timeout = config.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
453
+ return {
454
+ name: `cli:${command}`,
455
+ run: (request2) => new Promise((resolve) => {
456
+ execFile2(
457
+ command,
458
+ args.map((arg) => substitute(arg, request2)),
459
+ // No shell, a bounded buffer, and a hard timeout: a hung runner must
460
+ // not hold the task open forever.
461
+ { timeout, maxBuffer: 4 * 1024 * 1024, windowsHide: true },
462
+ (error, stdout, stderr) => {
463
+ const out = stdout.trim();
464
+ if (error !== null) {
465
+ const detail = stderr.trim() !== "" ? stderr.trim() : error.message;
466
+ resolve({ status: "failed", note: truncate(`runner failed: ${detail}${out !== "" ? `
467
+ ${out}` : ""}`) });
468
+ return;
469
+ }
470
+ resolve({ status: "done", note: truncate(out === "" ? "(runner produced no output)" : out) });
471
+ }
472
+ );
473
+ })
474
+ };
475
+ }
476
+ function herdrRunner(config, ctl) {
477
+ const target = config.target ?? config.peer;
478
+ const timeout = config.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
479
+ return {
480
+ name: `herdr:${target}`,
481
+ run: async (request2) => {
482
+ try {
483
+ const text = [request2.prompt, request2.context].filter((part) => part !== void 0 && part !== "").join("\n\n");
484
+ await ctl.promptSmart(target, text, { wait: true, timeoutMs: timeout });
485
+ const read = await ctl.readSmart(target, { lines: 200 });
486
+ return { status: "done", note: truncate(read.text.trim() === "" ? "(no output read back)" : read.text) };
487
+ } catch (error) {
488
+ return { status: "failed", note: truncate(`herdr runner failed: ${error.message}`) };
489
+ }
490
+ }
491
+ };
492
+ }
493
+ function createRunner(config, deps = {}) {
494
+ if (config.kind === "cli") return cliRunner(config);
495
+ if (deps.herdr === void 0) throw new Error(`runner for '${config.peer}': kind 'herdr' needs herdr to be enabled`);
496
+ return herdrRunner(config, deps.herdr);
497
+ }
498
+ var RunnerRegistry = class {
499
+ runners = /* @__PURE__ */ new Map();
500
+ constructor(configs = [], deps = {}) {
501
+ configs.forEach((config, index) => {
502
+ validateRunnerConfig(config, index);
503
+ this.runners.set(config.peer, createRunner(config, deps));
504
+ });
505
+ }
506
+ get(peer) {
507
+ return this.runners.get(peer);
508
+ }
509
+ /** Peer ids that have a runner (for startup logging). */
510
+ peers() {
511
+ return [...this.runners.keys()];
512
+ }
513
+ get size() {
514
+ return this.runners.size;
515
+ }
516
+ };
517
+
420
518
  // src/hub.ts
421
519
  import { randomUUID } from "node:crypto";
422
520
 
@@ -424,8 +522,34 @@ import { randomUUID } from "node:crypto";
424
522
  var KINDS = ["chat", "task", "notice", "ack"];
425
523
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
426
524
  var BROADCAST = "all";
525
+ var HUB_PEER = "hub";
526
+ var SSE_METHOD = "notifications/message";
527
+ var SSE_EVENT_PEERS_CHANGED = "peers_changed";
528
+ var SSE_EVENT_MESSAGE = "message";
427
529
  var MAX_HOP = 5;
428
- var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout"];
530
+ var MAX_REPLY_CHAIN = 50;
531
+ function a2aState(status) {
532
+ switch (status) {
533
+ case "pending":
534
+ case "accepted":
535
+ return "submitted";
536
+ case "working":
537
+ return "working";
538
+ case "input-required":
539
+ return "input-required";
540
+ case "done":
541
+ return "completed";
542
+ case "canceled":
543
+ return "canceled";
544
+ case "failed":
545
+ case "timeout":
546
+ return "failed";
547
+ case "rejected":
548
+ return "rejected";
549
+ }
550
+ }
551
+ var CLIENT_ACK_STATUSES = ["accepted", "working", "input-required", "rejected", "done", "failed"];
552
+ var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout", "canceled"];
429
553
  function ackToTaskStatus(ack) {
430
554
  return ack;
431
555
  }
@@ -440,6 +564,9 @@ function nextTaskStatus(current, ack) {
440
564
  case "working":
441
565
  if (next === "accepted") return "working";
442
566
  return next;
567
+ case "input-required":
568
+ if (next === "accepted") return "input-required";
569
+ return next;
443
570
  default:
444
571
  return null;
445
572
  }
@@ -447,6 +574,7 @@ function nextTaskStatus(current, ack) {
447
574
  function aggregateTaskStatus(assignees) {
448
575
  if (assignees.length === 0) return "pending";
449
576
  const statuses = assignees.map((a) => a.status);
577
+ if (statuses.some((s) => s === "input-required")) return "input-required";
450
578
  const active = statuses.filter((s) => s === "pending" || s === "accepted" || s === "working");
451
579
  if (active.length > 0) {
452
580
  if (active.some((s) => s === "working")) return "working";
@@ -456,10 +584,14 @@ function aggregateTaskStatus(assignees) {
456
584
  if (statuses.every((s) => s === "done")) return "done";
457
585
  if (statuses.some((s) => s === "failed")) return "failed";
458
586
  if (statuses.some((s) => s === "timeout")) return "timeout";
587
+ if (statuses.some((s) => s === "canceled")) return "canceled";
459
588
  return "rejected";
460
589
  }
590
+ function encodeContent(payload) {
591
+ return JSON.stringify(payload);
592
+ }
461
593
  function decodeContent(kind, content) {
462
- if (kind !== "task" && kind !== "ack") return content;
594
+ if (kind !== "task" && kind !== "ack" && kind !== "notice") return content;
463
595
  try {
464
596
  const parsed = JSON.parse(content);
465
597
  if (parsed !== null && typeof parsed === "object") return parsed;
@@ -471,19 +603,103 @@ function decodeContent(kind, content) {
471
603
  // src/hub.ts
472
604
  var MAX_PROFILES = 512;
473
605
  var MAX_TASKS = 1024;
474
- var AgentHub = class {
606
+ var MAX_TIMER_DELAY_MS = 2147483647;
607
+ var DEFAULT_PARKED_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
608
+ var DEFAULT_LEASE_TTL_MS = 6e4;
609
+ var DEFAULT_MAX_ATTEMPTS = 5;
610
+ var MAX_EVENTS = 500;
611
+ var AgentHub = class _AgentHub {
475
612
  constructor(options) {
476
613
  this.options = options;
477
614
  const idle = options.peerIdleTimeoutMs ?? 6e5;
615
+ const parkedTtl = options.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS;
478
616
  if (idle > 0) {
479
617
  const interval = Math.min(6e4, Math.max(1e3, idle / 2));
480
618
  this.gcTimer = setInterval(() => this.gcTick(idle), interval);
481
619
  this.gcTimer.unref?.();
620
+ } else if (parkedTtl > 0) {
621
+ const interval = Math.min(6e4, Math.max(1e3, parkedTtl / 2));
622
+ this.gcTimer = setInterval(() => this.expireParkedMailboxes(Date.now()), interval);
623
+ this.gcTimer.unref?.();
482
624
  }
483
625
  }
484
626
  queues = /* @__PURE__ */ new Map();
627
+ /** When each parked (peer-gone-but-kept) mailbox was parked, for the TTL sweep.
628
+ * A queue in this map has no registered peer; see {@link unregister}. */
629
+ parkedAt = /* @__PURE__ */ new Map();
630
+ /** Highest {@link BridgeMessage.seq} handed out so far. Restored history can
631
+ * raise it (see importHistory) but nothing lowers it: a client's `since` cursor
632
+ * has to keep meaning the same thing across a restart. */
633
+ seqCounter = 0;
634
+ /**
635
+ * Record one operational event.
636
+ *
637
+ * Deliberately NOT the same thing as history: history is the messages that
638
+ * flowed (what peers said to each other), this is what the HUB did — a peer
639
+ * joined, a mailbox was dropped, a task moved. History can be read per peer and
640
+ * is persisted; events are global, per-run, and never persisted, because they
641
+ * describe the life of this process.
642
+ */
643
+ recordEvent(kind, input = {}) {
644
+ this.eventRing.push({
645
+ seq: ++this.eventSeq,
646
+ ts: Date.now(),
647
+ kind,
648
+ ...input.peer !== void 0 ? { peer: input.peer } : {},
649
+ detail: input.detail ?? {}
650
+ });
651
+ while (this.eventRing.length > MAX_EVENTS) this.eventRing.shift();
652
+ }
653
+ /**
654
+ * Read recorded events, oldest first.
655
+ *
656
+ * `since` is exclusive ("give me what happened after seq N"), which makes it the
657
+ * natural cursor: pass the `lastSeq` of the previous call. `truncated` says the
658
+ * requested window is no longer in the ring, so the caller knows it missed
659
+ * entries instead of assuming it saw everything.
660
+ */
661
+ events(options = {}) {
662
+ const limit = Math.max(1, Math.min(options.limit ?? 100, MAX_EVENTS));
663
+ const since = options.since ?? 0;
664
+ const available = this.eventRing.filter((event) => event.seq > since);
665
+ const truncated = since > 0 && available.length > 0 && available[0].seq > since + 1;
666
+ return {
667
+ events: available.slice(-limit),
668
+ lastSeq: this.eventSeq,
669
+ truncated
670
+ };
671
+ }
672
+ /** The task hook, plus the event it implies.
673
+ *
674
+ * Every status change in this class already reports through
675
+ * `options.onTaskChanged`, so recording the event HERE needs no new call sites:
676
+ * the hook IS the transition signal. That is also why the event can carry the
677
+ * aggregate status without the caller having to say what changed — it is read
678
+ * from the record at the moment the transition is announced. */
679
+ taskChanged(record) {
680
+ if (record !== void 0) {
681
+ this.recordEvent("task_transition", {
682
+ peer: record.from,
683
+ detail: { taskId: record.id, status: record.status, assignees: record.assignees.length }
684
+ });
685
+ }
686
+ this.options.onTaskChanged?.(record);
687
+ }
688
+ /** Record an event from outside the hub.
689
+ *
690
+ * The runner layer lives in `index.ts` and knows when a run starts and ends,
691
+ * which the hub cannot see. Deliberately narrow: a caller can ADD an event but
692
+ * cannot read or clear the ring through this.
693
+ */
694
+ noteEvent(kind, input = {}) {
695
+ this.recordEvent(kind, input);
696
+ }
485
697
  waiters = /* @__PURE__ */ new Map();
486
698
  historyRing = [];
699
+ /** Structured operational events, newest last. A ring, not a log: the hub is a
700
+ * long-running process and an unbounded event list is a leak. See {@link events}. */
701
+ eventRing = [];
702
+ eventSeq = 0;
487
703
  lastSeen = /* @__PURE__ */ new Map();
488
704
  /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
489
705
  profiles = /* @__PURE__ */ new Map();
@@ -506,10 +722,70 @@ var AgentHub = class {
506
722
  if (now - (this.lastSeen.get(peerId) ?? 0) > idleTimeoutMs) {
507
723
  if (this.options.isPeerLive?.(peerId) === true) continue;
508
724
  this.options.onPeerGc?.(peerId);
509
- this.unregister(peerId);
725
+ this.unregister(peerId, { keepMailbox: true });
726
+ }
727
+ }
728
+ for (const peerId of [...this.leases.keys()]) this.reapLeases(peerId, now);
729
+ this.expireParkedMailboxes(now);
730
+ }
731
+ /** Drop parked mailboxes older than `parkedTtlMs` (0 disables expiry). */
732
+ expireParkedMailboxes(now) {
733
+ const ttl = this.options.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS;
734
+ if (ttl <= 0) return;
735
+ for (const [peerId, parkedMs] of [...this.parkedAt]) {
736
+ if (this.lastSeen.has(peerId)) {
737
+ this.parkedAt.delete(peerId);
738
+ continue;
739
+ }
740
+ if (now - parkedMs <= ttl) continue;
741
+ const queue = this.queues.get(peerId) ?? [];
742
+ this.queues.delete(peerId);
743
+ this.parkedAt.delete(peerId);
744
+ for (const message of queue) {
745
+ if (message.kind === "task") this.failTaskForGoneAssignee(message.id, peerId, now, ttl);
746
+ }
747
+ const dropped = queue.length;
748
+ if (dropped > 0) {
749
+ this.options.onMailboxesChanged?.();
750
+ this.recordEvent("mailbox_dropped", { peer: peerId, detail: { reason: "expired", dropped, ttlMs: ttl } });
751
+ this.options.onMailboxExpired?.(peerId, dropped);
510
752
  }
511
753
  }
512
754
  }
755
+ /** Reconcile the task ledger after `peerId`'s parked mailbox was destroyed with
756
+ * the task still inside it.
757
+ *
758
+ * The task fails as a SYSTEM event, not as an ack: nobody acked anything, the
759
+ * peer simply never came back. Marking only this assignee's row keeps a
760
+ * broadcast task's other recipients intact, and the aggregate status is
761
+ * recomputed the same way every other transition does it. */
762
+ failTaskForGoneAssignee(taskId, peerId, now, ttl) {
763
+ const record = this.taskLedger.get(taskId);
764
+ if (record === void 0) return;
765
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) return;
766
+ const assignee = record.assignees.find((a) => a.peerId === peerId);
767
+ if (assignee === void 0 || TERMINAL_TASK_STATUSES.includes(assignee.status)) return;
768
+ const event = {
769
+ from: peerId,
770
+ status: "failed",
771
+ note: `recipient '${peerId}' never came back: its parked mailbox expired after ${ttl}ms and the task was dropped with it`,
772
+ ts: now,
773
+ messageId: `expired-${taskId}`,
774
+ applied: true,
775
+ system: true
776
+ };
777
+ record.acks.push(event);
778
+ assignee.status = "failed";
779
+ assignee.acks.push({ ...event });
780
+ record.status = aggregateTaskStatus(record.assignees);
781
+ record.updatedAt = now;
782
+ const timer = this.taskTimers.get(taskId);
783
+ if (timer !== void 0) {
784
+ clearTimeout(timer);
785
+ this.taskTimers.delete(taskId);
786
+ }
787
+ this.taskChanged(this.taskOf(taskId));
788
+ }
513
789
  /** All registered peer ids, insertion order. */
514
790
  peers() {
515
791
  return [...this.lastSeen.keys()];
@@ -520,6 +796,7 @@ var AgentHub = class {
520
796
  register(peerId, client) {
521
797
  if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
522
798
  this.lastSeen.set(peerId, Date.now());
799
+ this.parkedAt.delete(peerId);
523
800
  const existing = this.profiles.get(peerId);
524
801
  this.profiles.set(peerId, {
525
802
  ...existing,
@@ -529,13 +806,28 @@ var AgentHub = class {
529
806
  ...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
530
807
  });
531
808
  this.evictOverflowProfiles();
809
+ this.recordEvent("peer_joined", { peer: peerId, detail: { clientName: client?.name } });
532
810
  this.options.onPeersChanged?.(this.peers());
533
811
  }
534
- /** Remove a peer and its queued messages; pending waiters resolve as
535
- * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
536
- unregister(peerId) {
537
- if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
538
- this.queues.delete(peerId);
812
+ /** Remove a peer; pending waiters resolve as timeouts. The profile is kept so
813
+ * a reconnecting peer keeps its alias.
814
+ *
815
+ * `keepMailbox` is what the idle GC passes: the peer's queued messages stay in
816
+ * `queues` (marked parked) so a returning agent drains what was sent while it
817
+ * was away. An explicit `bridge_unregister` and a manager kick leave it off,
818
+ * i.e. they still drop the mail — leaving the hub is a deliberate act. */
819
+ unregister(peerId, options) {
820
+ const queue = this.queues.get(peerId) ?? [];
821
+ const keep = options?.keepMailbox === true;
822
+ if (queue.length > 0) this.options.onMailboxesChanged?.();
823
+ if (keep) {
824
+ this.parkedAt.set(peerId, Date.now());
825
+ this.recordEvent("peer_parked", { peer: peerId, detail: { queued: queue.length } });
826
+ } else {
827
+ this.recordEvent("peer_left", { peer: peerId, detail: { queued: queue.length } });
828
+ this.queues.delete(peerId);
829
+ this.parkedAt.delete(peerId);
830
+ }
539
831
  for (const waiter of this.waiters.get(peerId) ?? []) {
540
832
  clearTimeout(waiter.timer);
541
833
  waiter.onAbort();
@@ -552,6 +844,44 @@ var AgentHub = class {
552
844
  exportProfiles() {
553
845
  return [...this.profiles.values()].map((profile) => ({ ...profile }));
554
846
  }
847
+ /** Record what a peer says about itself. Creates the profile if the peer is
848
+ * new: a card is identity, and identity outlives a connection. */
849
+ announceCard(peerId, card) {
850
+ const existing = this.profiles.get(peerId);
851
+ const profile = { ...existing, id: peerId, registeredAt: existing?.registeredAt ?? Date.now(), card };
852
+ this.profiles.set(peerId, profile);
853
+ this.evictOverflowProfiles();
854
+ this.options.onPeersChanged?.(this.peers());
855
+ return profile;
856
+ }
857
+ /** Every peer whose announced card matches `filter`, with the load a caller
858
+ * needs to pick one.
859
+ *
860
+ * `acceptsTasks` defaults to TRUE — and that default is the point: discover is
861
+ * for finding somebody to HAND WORK TO, and "has a skill" is not "wants a
862
+ * task". Pass `false` to drop the requirement and list peers regardless. */
863
+ discover(filter = {}) {
864
+ const requireAccept = filter.acceptsTasks !== false;
865
+ const out = [];
866
+ for (const profile of this.profiles.values()) {
867
+ const card = profile.card;
868
+ if (card === void 0) continue;
869
+ if (requireAccept && card.acceptsTasks !== true) continue;
870
+ const skills = card.skills ?? [];
871
+ if (filter.skill !== void 0 && !skills.some((skill) => skill.id === filter.skill)) continue;
872
+ if (filter.tag !== void 0 && !skills.some((skill) => (skill.tags ?? []).includes(filter.tag))) continue;
873
+ const load = {
874
+ queued: this.queues.get(profile.id)?.length ?? 0,
875
+ waiting: this.waiters.get(profile.id)?.length ?? 0,
876
+ running: [...this.taskLedger.values()].filter(
877
+ (record) => !TERMINAL_TASK_STATUSES.includes(record.status) && record.assignees.some((a) => a.peerId === profile.id)
878
+ ).length
879
+ };
880
+ if (filter.idleOnly === true && (load.queued > 0 || load.waiting > 0 || load.running > 0)) continue;
881
+ out.push({ peerId: profile.id, ...profile.alias !== void 0 ? { alias: profile.alias } : {}, card, load });
882
+ }
883
+ return out;
884
+ }
555
885
  /** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
556
886
  * and non-finite fields are sanitized. Returns the imported count. Silent
557
887
  * for roster observers — this runs before the server accepts requests. */
@@ -571,6 +901,7 @@ var AgentHub = class {
571
901
  if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
572
902
  if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
573
903
  if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
904
+ if (candidate.card !== null && typeof candidate.card === "object") profile.card = candidate.card;
574
905
  this.profiles.set(id, profile);
575
906
  count++;
576
907
  }
@@ -598,6 +929,7 @@ var AgentHub = class {
598
929
  if (oldId === newId) return;
599
930
  if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
600
931
  if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
932
+ if (newId === HUB_PEER) throw new Error(`reserved peer id: ${newId}`);
601
933
  if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
602
934
  if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
603
935
  const queue = this.queues.get(oldId);
@@ -605,6 +937,8 @@ var AgentHub = class {
605
937
  const seen = this.lastSeen.get(oldId) ?? Date.now();
606
938
  const profile = this.profiles.get(oldId);
607
939
  const stale = this.profiles.get(newId);
940
+ this.parkedAt.delete(oldId);
941
+ this.parkedAt.delete(newId);
608
942
  this.queues.delete(oldId);
609
943
  this.waiters.delete(oldId);
610
944
  this.lastSeen.delete(oldId);
@@ -656,6 +990,13 @@ var AgentHub = class {
656
990
  has(peerId) {
657
991
  return this.lastSeen.has(peerId);
658
992
  }
993
+ /** Is the peer parked — gone from the live registry through idle GC, but still
994
+ * holding its mailbox and still addressable? `has()` is false for it, so a
995
+ * guard written only as `has()` will decide there is nothing to do and leave
996
+ * the peer reachable. A manager kick has to cover this state explicitly. */
997
+ isParked(peerId) {
998
+ return this.parkedAt.has(peerId);
999
+ }
659
1000
  /** Is the peer "active" (tool/activity within the connected window)? */
660
1001
  isActive(peerId) {
661
1002
  const last = this.lastSeen.get(peerId);
@@ -663,15 +1004,21 @@ var AgentHub = class {
663
1004
  }
664
1005
  /** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
665
1006
  send(from, to, kind, content, opts) {
666
- return this.route(from, to, kind, content, opts?.ref, opts?.threadId);
1007
+ return this.route(from, to, kind, content, opts?.ref, opts?.threadId, opts?.expiresAt, false, opts?.priority);
667
1008
  }
668
1009
  /** Send a structured task message to `to` (peer, {@link BROADCAST}, or a
669
1010
  * group id). Records a ledger entry so the sender can later query status /
670
- * wait for acks. */
671
- sendTask(from, to, task) {
1011
+ * wait for acks.
1012
+ *
1013
+ * `ref` marks the task this one answers. When the parent is itself a task the
1014
+ * new one is a **re-delegation**, and that chain is capped at {@link MAX_HOP}
1015
+ * — without a way to express the parent, the cap would be unreachable and a
1016
+ * hand-off loop unobservable. */
1017
+ sendTask(from, to, task, ref, expiresAt) {
672
1018
  const threadId = task.threadId;
673
- const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
1019
+ const message = this.compose(from, to, "task", JSON.stringify(task), ref, threadId, expiresAt, false, task.priority);
674
1020
  this.recordTask(message, task);
1021
+ this.dispatch(message);
675
1022
  return message;
676
1023
  }
677
1024
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
@@ -680,20 +1027,126 @@ var AgentHub = class {
680
1027
  sendAck(from, ref, ack) {
681
1028
  const task = this.taskLedger.get(ref);
682
1029
  let target;
1030
+ let threadId;
683
1031
  if (task !== void 0) {
684
1032
  target = task.from;
1033
+ threadId = task.threadId;
685
1034
  } else {
686
1035
  const original = this.historyRing.findLast((message2) => message2.id === ref);
687
1036
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
688
1037
  target = original.from;
1038
+ threadId = original.threadId;
689
1039
  }
690
- const message = this.route(from, target, "ack", JSON.stringify(ack), ref);
1040
+ const message = this.route(from, target, "ack", JSON.stringify(ack), ref, threadId);
691
1041
  this.applyAckToLedger(message, from, ref, ack);
692
1042
  return message;
693
1043
  }
1044
+ /** Record an ack the hub produced ON BEHALF of a peer whose runner finished
1045
+ * its task (see `src/runner.ts`).
1046
+ *
1047
+ * Unlike {@link sendAck} the sender is not a live session — it may be parked,
1048
+ * or not connected at all — so the "sender must be registered" check does not
1049
+ * apply. This is the hub acting for a peer it is executing for, never a
1050
+ * caller-supplied identity; nothing from a request reaches `from` here.
1051
+ * Everything else (ledger update, routing to the task's sender) is identical. */
1052
+ ackForRunner(peer, ref, ack) {
1053
+ const task = this.taskLedger.get(ref);
1054
+ if (task === void 0) throw new Error(`runner cannot ack unknown task: ${ref}`);
1055
+ const message = this.route(peer, task.from, "ack", JSON.stringify(ack), ref, void 0, void 0, true);
1056
+ this.applyAckToLedger(message, peer, ref, ack);
1057
+ return message;
1058
+ }
1059
+ /** Cancel a task. EITHER side may do it — the sender stops work it no longer
1060
+ * wants, an assignee reports it will not finish — and the cancel is both
1061
+ * recorded on the ledger and routed to the OTHER side, so whoever did not
1062
+ * press the button finds out. Cancelling an already-terminal task is an error
1063
+ * rather than a silent no-op: the caller asked for something that did not
1064
+ * happen. */
1065
+ cancelTask(from, ref, reason) {
1066
+ const record = this.taskLedger.get(ref);
1067
+ if (record === void 0) throw new Error(`cannot cancel unknown task: ${ref}`);
1068
+ const isSender = from === record.from;
1069
+ if (!isSender && !record.assignees.some((a) => a.peerId === from)) {
1070
+ throw new Error(`only the sender or an assignee can cancel task ${ref}`);
1071
+ }
1072
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) {
1073
+ throw new Error(`task ${ref} is already ${record.status}`);
1074
+ }
1075
+ const ack = { status: "canceled", ...reason !== void 0 ? { note: reason } : {} };
1076
+ if (!isSender) {
1077
+ return this.sendAck(from, ref, ack);
1078
+ }
1079
+ let last;
1080
+ for (const assignee of record.assignees) {
1081
+ if (TERMINAL_TASK_STATUSES.includes(assignee.status)) continue;
1082
+ const message = this.route(from, assignee.peerId, "ack", JSON.stringify(ack), ref, record.threadId);
1083
+ const event = {
1084
+ from,
1085
+ status: "canceled",
1086
+ ...reason !== void 0 ? { note: reason } : {},
1087
+ ts: message.ts,
1088
+ messageId: message.id,
1089
+ applied: true
1090
+ };
1091
+ record.acks.push(event);
1092
+ assignee.status = "canceled";
1093
+ assignee.acks.push({ ...event });
1094
+ last = message;
1095
+ }
1096
+ if (last === void 0) throw new Error(`task ${ref} has no assignee left to cancel`);
1097
+ record.status = aggregateTaskStatus(record.assignees);
1098
+ record.updatedAt = last.ts;
1099
+ this.taskChanged(this.taskOf(ref));
1100
+ return last;
1101
+ }
1102
+ /** Answer an `input-required` interrupt. Only the task's SENDER can answer — it
1103
+ * is the side the assignee asked. The answer is delivered to the waiting
1104
+ * assignee as an `ack` carrying the task id, so a `bridge_wait { ref }` picks
1105
+ * it up, and the task resumes (`working`). */
1106
+ taskInput(from, ref, input) {
1107
+ const record = this.taskLedger.get(ref);
1108
+ if (record === void 0) throw new Error(`cannot answer unknown task: ${ref}`);
1109
+ if (from !== record.from) {
1110
+ throw new Error(`only the sender of task ${ref} can answer it (an assignee resumes by acking 'working')`);
1111
+ }
1112
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) {
1113
+ throw new Error(`task ${ref} is already ${record.status}`);
1114
+ }
1115
+ const waiting = record.assignees.filter((a) => a.status === "input-required");
1116
+ const targets = waiting.length > 0 ? waiting : record.assignees;
1117
+ const ack = { status: "working", note: input };
1118
+ let last;
1119
+ for (const assignee of targets) {
1120
+ const message = this.route(from, assignee.peerId, "ack", JSON.stringify(ack), ref, record.threadId);
1121
+ const event = { from, status: "working", note: input, ts: message.ts, messageId: message.id, applied: true };
1122
+ record.acks.push(event);
1123
+ if (assignee.status === "input-required") {
1124
+ assignee.status = "working";
1125
+ assignee.acks.push({ ...event });
1126
+ }
1127
+ last = message;
1128
+ }
1129
+ if (last === void 0) throw new Error(`task ${ref} has no assignee to answer`);
1130
+ record.status = aggregateTaskStatus(record.assignees);
1131
+ record.updatedAt = last.ts;
1132
+ this.taskChanged(this.taskOf(ref));
1133
+ return last;
1134
+ }
1135
+ /** Every peer a fan-out can reach: the live registry **plus** parked peers.
1136
+ *
1137
+ * Parked peers keep their mailbox and stay addressable (that is the point of
1138
+ * parking, see {@link isParked}), and a group send already delivered to its
1139
+ * parked members because it reads the member list rather than the live roster.
1140
+ * Broadcast read the live roster, so `peer-x` showed up in `bridge_peers` as
1141
+ * addressable yet silently missed every broadcast. Both resolve here now. */
1142
+ audience() {
1143
+ const ids = new Set(this.lastSeen.keys());
1144
+ for (const id of this.parkedAt.keys()) ids.add(id);
1145
+ return [...ids];
1146
+ }
694
1147
  /** Resolve the peer ids a task is assigned to (broadcast / group fan-out). */
695
1148
  resolveAssignees(from, to) {
696
- if (to === BROADCAST) return this.peers().filter((peer) => peer !== from);
1149
+ if (to === BROADCAST) return this.audience().filter((peer) => peer !== from);
697
1150
  const group = this.groups.get(to);
698
1151
  if (group !== void 0) return group.members.filter((member) => member !== from);
699
1152
  return [to];
@@ -722,13 +1175,19 @@ var AgentHub = class {
722
1175
  this.taskLedger.set(message.id, record);
723
1176
  this.scheduleTaskTimeout(record);
724
1177
  this.evictOverflowTasks();
725
- this.options.onTaskChanged?.(this.taskOf(record.id));
726
- }
727
- /** Arm the SLA timer for a task that supplied `timeoutMs`. */
1178
+ this.taskChanged(this.taskOf(record.id));
1179
+ }
1180
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. A deadline
1181
+ * beyond {@link MAX_TIMER_DELAY_MS} cannot be expressed as one delay — the
1182
+ * timer would overflow and fire after ~1 ms — so it is re-armed in segments
1183
+ * of that size. `deadlineAt` stays the authoritative deadline and each
1184
+ * segment recomputes the remaining time, so a long SLA is honored exactly
1185
+ * (no drift); settling or evicting the task clears the pending segment, and
1186
+ * clearing the timer is what ends the chain. */
728
1187
  scheduleTaskTimeout(record) {
729
1188
  if (record.deadlineAt === void 0) return;
730
- const delay = Math.max(0, record.deadlineAt - Date.now());
731
- const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
1189
+ const remaining = record.deadlineAt - Date.now();
1190
+ const timer = remaining > MAX_TIMER_DELAY_MS ? setTimeout(() => this.scheduleTaskTimeout(record), MAX_TIMER_DELAY_MS) : setTimeout(() => this.fireTaskTimeout(record.id), Math.max(0, remaining));
732
1191
  timer.unref?.();
733
1192
  this.taskTimers.set(record.id, timer);
734
1193
  }
@@ -758,7 +1217,7 @@ var AgentHub = class {
758
1217
  record.status = "timeout";
759
1218
  record.updatedAt = now;
760
1219
  this.clearTaskTimer(taskId);
761
- this.options.onTaskChanged?.(this.taskOf(taskId));
1220
+ this.taskChanged(this.taskOf(taskId));
762
1221
  }
763
1222
  clearTaskTimer(taskId) {
764
1223
  const timer = this.taskTimers.get(taskId);
@@ -775,7 +1234,7 @@ var AgentHub = class {
775
1234
  if (assignee === void 0 || assignee.deliveredMs !== void 0) return;
776
1235
  assignee.deliveredMs = ts;
777
1236
  record.updatedAt = ts;
778
- this.options.onTaskChanged?.(this.taskOf(taskId));
1237
+ this.taskChanged(this.taskOf(taskId));
779
1238
  }
780
1239
  /**
781
1240
  * Fold an ack into the task's timeline and (when legal) its status.
@@ -795,11 +1254,15 @@ var AgentHub = class {
795
1254
  status: ack.status,
796
1255
  ...ack.note !== void 0 ? { note: ack.note } : {},
797
1256
  ...typeof ack.progress === "number" && Number.isFinite(ack.progress) ? { progress: Math.max(0, Math.min(100, ack.progress)) } : {},
1257
+ ...ack.artifacts !== void 0 && ack.artifacts.length > 0 ? { artifacts: ack.artifacts } : {},
798
1258
  ts: message.ts,
799
1259
  messageId: message.id,
800
1260
  applied
801
1261
  };
802
1262
  record.acks.push(event);
1263
+ if (applied && event.artifacts !== void 0) {
1264
+ record.artifacts = [...record.artifacts ?? [], ...event.artifacts];
1265
+ }
803
1266
  if (assignee !== void 0 && assigneeNext !== null) {
804
1267
  assignee.status = assigneeNext;
805
1268
  assignee.acks.push({ ...event });
@@ -811,7 +1274,7 @@ var AgentHub = class {
811
1274
  }
812
1275
  record.updatedAt = message.ts;
813
1276
  if (TERMINAL_TASK_STATUSES.includes(record.status)) this.clearTaskTimer(record.id);
814
- this.options.onTaskChanged?.(this.taskOf(record.id));
1277
+ this.taskChanged(this.taskOf(record.id));
815
1278
  }
816
1279
  /** Keep the ledger bounded: drop oldest terminal tasks first. */
817
1280
  evictOverflowTasks() {
@@ -935,27 +1398,38 @@ var AgentHub = class {
935
1398
  this.evictOverflowTasks();
936
1399
  return count;
937
1400
  }
1401
+ /** Highest sequence number handed out so far — a client's "start from now"
1402
+ * cursor (`bridge_status.lastSeq`). */
1403
+ lastSeq() {
1404
+ return this.seqCounter;
1405
+ }
938
1406
  /** Recent messages involving `peer` (inbound and outbound, plus group
939
- * channels the peer belongs to), newest first. */
940
- history(peerId, limit) {
1407
+ * channels the peer belongs to), newest first. `since` restricts the result to
1408
+ * messages newer than that sequence number (a resume cursor); omitting it keeps
1409
+ * the original behaviour, which includes history recorded before cursors
1410
+ * existed. */
1411
+ history(peerId, limit, since) {
941
1412
  const memberOf = new Set(
942
1413
  [...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
943
1414
  );
944
1415
  const filtered = this.historyRing.filter(
945
- (message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
1416
+ (message) => (since === void 0 || (message.seq ?? 0) > since) && (message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel))
946
1417
  );
947
1418
  return filtered.slice(-Math.max(0, limit)).reverse();
948
1419
  }
949
1420
  /** Recent messages of one group channel, newest first. */
950
- historyChannel(groupId, limit) {
951
- const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
1421
+ historyChannel(groupId, limit, since) {
1422
+ const filtered = this.historyRing.filter(
1423
+ (message) => (since === void 0 || (message.seq ?? 0) > since) && (message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId))
1424
+ );
952
1425
  return filtered.slice(-Math.max(0, limit)).reverse();
953
1426
  }
954
1427
  /** Most recent messages across every peer, unfiltered (newest first).
955
1428
  * Backs `bridge_history { peer: "all" }` — lets an archiver (the desktop
956
1429
  * app) capture peer-to-peer traffic it is not a party of. */
957
- historyAll(limit) {
958
- return this.historyRing.slice(-Math.max(0, limit)).reverse();
1430
+ historyAll(limit, since) {
1431
+ const filtered = since === void 0 ? this.historyRing : this.historyRing.filter((message) => (message.seq ?? 0) > since);
1432
+ return filtered.slice(-Math.max(0, limit)).reverse();
959
1433
  }
960
1434
  // ---- groups (group channel) ------------------------------------------------
961
1435
  /** Create a channel; throws when the id is taken or a member is unknown. */
@@ -1058,6 +1532,7 @@ var AgentHub = class {
1058
1532
  const m = raw;
1059
1533
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1060
1534
  if (KINDS.includes(m.kind) === false) continue;
1535
+ const hop = importHop(m.hop);
1061
1536
  valid.push({
1062
1537
  id: m.id,
1063
1538
  from: m.from,
@@ -1067,9 +1542,18 @@ var AgentHub = class {
1067
1542
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1068
1543
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1069
1544
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1545
+ ...hop !== void 0 ? { hop } : {},
1546
+ // Absent in databases written before the column existed; such a message
1547
+ // is simply unreachable through a `since` cursor, which is the honest
1548
+ // state for history that predates the cursor.
1549
+ ...typeof m.seq === "number" && Number.isFinite(m.seq) && m.seq > 0 ? { seq: m.seq } : {},
1550
+ ...typeof m.expiresAt === "number" && Number.isFinite(m.expiresAt) && m.expiresAt > 0 ? { expiresAt: m.expiresAt } : {},
1070
1551
  ts: m.ts
1071
1552
  });
1072
1553
  }
1554
+ for (const message of valid) {
1555
+ if (message.seq !== void 0 && message.seq > this.seqCounter) this.seqCounter = message.seq;
1556
+ }
1073
1557
  for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
1074
1558
  return Math.min(valid.length, this.options.historyLimit);
1075
1559
  }
@@ -1092,6 +1576,7 @@ var AgentHub = class {
1092
1576
  if (typeof raw !== "object" || raw === null) continue;
1093
1577
  const m = raw;
1094
1578
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1579
+ const hop = importHop(m.hop);
1095
1580
  restored.push({
1096
1581
  id: m.id,
1097
1582
  from: m.from,
@@ -1101,11 +1586,18 @@ var AgentHub = class {
1101
1586
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1102
1587
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1103
1588
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1589
+ ...hop !== void 0 ? { hop } : {},
1590
+ // Restored mail must keep its deadline and its sequence number: the
1591
+ // deadline decides whether it is still deliverable at all, and the
1592
+ // cursor has to stay continuous across a restart.
1593
+ ...typeof m.seq === "number" && Number.isFinite(m.seq) && m.seq > 0 ? { seq: m.seq } : {},
1594
+ ...typeof m.expiresAt === "number" && Number.isFinite(m.expiresAt) && m.expiresAt > 0 ? { expiresAt: m.expiresAt } : {},
1104
1595
  ts: m.ts
1105
1596
  });
1106
1597
  }
1107
1598
  if (restored.length > 0) {
1108
1599
  this.queues.set(peer, restored.slice(-this.options.maxQueue));
1600
+ if (!this.lastSeen.has(peer)) this.parkedAt.set(peer, Date.now());
1109
1601
  count += restored.length;
1110
1602
  }
1111
1603
  }
@@ -1119,27 +1611,321 @@ var AgentHub = class {
1119
1611
  const now = Date.now();
1120
1612
  return {
1121
1613
  server: "agent-comm-hub",
1122
- peers: this.peers().map((peer) => {
1123
- const alias = this.profiles.get(peer)?.alias;
1124
- return {
1125
- id: peer,
1126
- ...alias !== void 0 ? { alias } : {},
1127
- connected: this.isActive(peer) || livePeers?.has(peer) === true,
1128
- lastSeenMs: this.lastSeen.get(peer) ?? 0,
1129
- queued: (this.queues.get(peer) ?? []).length,
1130
- waiting: (this.waiters.get(peer) ?? []).length
1131
- };
1132
- }),
1614
+ peers: this.rosterRows(livePeers),
1133
1615
  historyLimit: this.options.historyLimit,
1134
- maxQueue: this.options.maxQueue
1616
+ maxQueue: this.options.maxQueue,
1617
+ // A client that wants only what happened since it last looked reads this
1618
+ // and passes it back as `bridge_history { since }`.
1619
+ lastSeq: this.seqCounter
1135
1620
  };
1136
1621
  }
1622
+ /** Registered peers plus parked ones (gone but still holding mail).
1623
+ * Parked rows are what lets a sender tell "queued, will be delivered when it
1624
+ * comes back" apart from "vanished" — without them the message just looks lost.
1625
+ */
1626
+ rosterRows(livePeers) {
1627
+ const rows = this.peers().map((peer) => {
1628
+ const alias = this.profiles.get(peer)?.alias;
1629
+ const connected = this.isActive(peer) || livePeers?.has(peer) === true;
1630
+ return {
1631
+ id: peer,
1632
+ ...alias !== void 0 ? { alias } : {},
1633
+ connected,
1634
+ reachable: connected ? "online" : "idle",
1635
+ lastSeenMs: this.lastSeen.get(peer) ?? 0,
1636
+ queued: (this.queues.get(peer) ?? []).length,
1637
+ waiting: (this.waiters.get(peer) ?? []).length,
1638
+ ...this.leases.get(peer) !== void 0 ? { inFlight: this.leases.get(peer).length } : {},
1639
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).overflow > 0 ? { droppedOverflow: this.drops.get(peer).overflow } : {},
1640
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).expired > 0 ? { droppedExpired: this.drops.get(peer).expired } : {},
1641
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).rate > 0 ? { droppedRate: this.drops.get(peer).rate } : {},
1642
+ ...this.deadLetters.get(peer) !== void 0 ? { deadLettered: this.deadLetters.get(peer).length } : {}
1643
+ };
1644
+ });
1645
+ for (const [peerId, parkedMs] of this.parkedAt) {
1646
+ if (this.lastSeen.has(peerId)) continue;
1647
+ const alias = this.profiles.get(peerId)?.alias;
1648
+ rows.push({
1649
+ id: peerId,
1650
+ ...alias !== void 0 ? { alias } : {},
1651
+ connected: false,
1652
+ reachable: "parked",
1653
+ lastSeenMs: parkedMs,
1654
+ queued: (this.queues.get(peerId) ?? []).length,
1655
+ waiting: 0,
1656
+ ...this.leases.get(peerId) !== void 0 ? { inFlight: this.leases.get(peerId).length } : {},
1657
+ ...this.deadLetters.get(peerId) !== void 0 ? { deadLettered: this.deadLetters.get(peerId).length } : {}
1658
+ });
1659
+ }
1660
+ return rows;
1661
+ }
1662
+ // ---- manual-ack leases -----------------------------------------------------
1663
+ /** A message handed out but not yet committed, and when its lease ends. */
1664
+ leases = /* @__PURE__ */ new Map();
1665
+ /** Hand-out count per (peer, message). Kept OUTSIDE the message so requeueing
1666
+ * preserves it and so it is never persisted as part of the message. */
1667
+ leaseAttempts = /* @__PURE__ */ new Map();
1668
+ /** Messages that ran out of attempts; `nack` revives them. */
1669
+ deadLetters = /* @__PURE__ */ new Map();
1670
+ // ---- drop accounting --------------------------------------------------------
1671
+ /** Per-peer drop counters, surfaced in the roster. Memory-only on purpose:
1672
+ * these are operational counters for the current run, not durable state. */
1673
+ drops = /* @__PURE__ */ new Map();
1674
+ /** Per-peer token bucket for the MAILBOX. See {@link HubOptions.ratePerMin}. */
1675
+ buckets = /* @__PURE__ */ new Map();
1676
+ /** Traffic counters, per peer: how much this peer sent, was sent, and acked.
1677
+ * Deliberately separate from the mailbox — "how much has this peer sent" is not
1678
+ * "how much is queued", and a drained queue must not erase the history of what
1679
+ * went through it. Memory-only, like {@link drops}: operational, not durable. */
1680
+ traffic = /* @__PURE__ */ new Map();
1681
+ bumpTraffic(peerId, field) {
1682
+ const row = this.traffic.get(peerId) ?? { sent: 0, received: 0, acked: 0 };
1683
+ row[field]++;
1684
+ this.traffic.set(peerId, row);
1685
+ }
1686
+ /**
1687
+ * Observability snapshot: per-peer and global counters.
1688
+ *
1689
+ * Everything here is either derived from live state or from a counter the hub
1690
+ * already keeps — nothing is estimated, and nothing is sampled. `messagesTotal`
1691
+ * is the history `seq` (monotonic, never resets in a run), so it is a true count
1692
+ * of composed messages rather than a rolling window.
1693
+ */
1694
+ metrics() {
1695
+ const tasksByStatus = {};
1696
+ for (const record of this.taskLedger.values()) {
1697
+ tasksByStatus[record.status] = (tasksByStatus[record.status] ?? 0) + 1;
1698
+ }
1699
+ const ids = /* @__PURE__ */ new Set([
1700
+ ...this.lastSeen.keys(),
1701
+ ...this.parkedAt.keys(),
1702
+ ...this.queues.keys(),
1703
+ ...this.traffic.keys()
1704
+ ]);
1705
+ const peers = [...ids].map((peerId) => {
1706
+ const row = this.traffic.get(peerId) ?? { sent: 0, received: 0, acked: 0 };
1707
+ const drop = this.drops.get(peerId) ?? { overflow: 0, expired: 0, rate: 0 };
1708
+ return {
1709
+ peerId,
1710
+ sent: row.sent,
1711
+ received: row.received,
1712
+ acked: row.acked,
1713
+ queued: this.queues.get(peerId)?.length ?? 0,
1714
+ inFlight: this.leases.get(peerId)?.length ?? 0,
1715
+ deadLettered: this.deadLetters.get(peerId)?.length ?? 0,
1716
+ droppedOverflow: drop.overflow,
1717
+ droppedExpired: drop.expired,
1718
+ droppedRate: drop.rate,
1719
+ // Same definition the roster uses: recent activity OR a live SSE channel.
1720
+ connected: this.isActive(peerId) || this.options.isPeerLive?.(peerId) === true
1721
+ };
1722
+ });
1723
+ return {
1724
+ global: {
1725
+ messagesTotal: this.seqCounter,
1726
+ peers: peers.length,
1727
+ connectedPeers: peers.filter((p) => p.connected).length,
1728
+ parkedPeers: peers.filter((p) => !p.connected && this.parkedAt.has(p.peerId)).length,
1729
+ waitersNow: [...this.waiters.values()].reduce((sum, list) => sum + list.length, 0),
1730
+ queuedTotal: peers.reduce((sum, p) => sum + p.queued, 0),
1731
+ tasksByStatus,
1732
+ droppedOverflow: peers.reduce((sum, p) => sum + p.droppedOverflow, 0),
1733
+ droppedExpired: peers.reduce((sum, p) => sum + p.droppedExpired, 0),
1734
+ droppedRate: peers.reduce((sum, p) => sum + p.droppedRate, 0),
1735
+ deadLettered: peers.reduce((sum, p) => sum + p.deadLettered, 0),
1736
+ inFlight: peers.reduce((sum, p) => sum + p.inFlight, 0)
1737
+ },
1738
+ peers: peers.sort((a, b) => a.peerId.localeCompare(b.peerId))
1739
+ };
1740
+ }
1741
+ /** Count a dropped message for `peerId` and, if that peer is waiting right
1742
+ * now, tell it. The notice is NOT queued: the mailbox is either full
1743
+ * (overflow) or the message already expired, and queueing the notice would
1744
+ * push yet another message out. An offline peer just sees the counters. */
1745
+ /** May this peer's mailbox accept one more message right now?
1746
+ *
1747
+ * Applied on the QUEUE path only, never on a waiter handoff: a waiting peer
1748
+ * already controls its own rate (it gets one message per `bridge_wait`), while a
1749
+ * mailbox is filled by traffic the recipient never asked for. That is the whole
1750
+ * point of the bucket.
1751
+ */
1752
+ takeToken(peerId, now) {
1753
+ const rate = this.options.ratePerMin ?? 0;
1754
+ if (rate <= 0) return true;
1755
+ const bucket = this.buckets.get(peerId) ?? { tokens: rate, last: now };
1756
+ const elapsed = Math.max(0, now - bucket.last);
1757
+ bucket.tokens = Math.min(rate, bucket.tokens + elapsed * rate / 6e4);
1758
+ bucket.last = now;
1759
+ if (bucket.tokens < 1) {
1760
+ this.buckets.set(peerId, bucket);
1761
+ return false;
1762
+ }
1763
+ bucket.tokens -= 1;
1764
+ this.buckets.set(peerId, bucket);
1765
+ return true;
1766
+ }
1767
+ countDrop(peerId, kind, now) {
1768
+ const stats = this.drops.get(peerId) ?? { overflow: 0, expired: 0, rate: 0 };
1769
+ stats[kind] += 1;
1770
+ this.drops.set(peerId, stats);
1771
+ const total = stats.overflow + stats.expired + stats.rate;
1772
+ this.recordEvent("mailbox_dropped", { peer: peerId, detail: { reason: kind, total } });
1773
+ const notice = {
1774
+ id: randomUUID(),
1775
+ seq: ++this.seqCounter,
1776
+ from: HUB_PEER,
1777
+ to: peerId,
1778
+ kind: "notice",
1779
+ content: encodeContent({
1780
+ message: kind === "overflow" ? `mailbox overflow: an older message was dropped before you read it (${total} dropped so far)` : kind === "rate" ? `rate limit: messages addressed to you arrived faster than ${this.options.ratePerMin ?? 0}/min and were dropped (${total} dropped so far)` : `a message addressed to you expired before delivery and was dropped (${total} dropped so far)`,
1781
+ level: "warn"
1782
+ }),
1783
+ ts: now
1784
+ };
1785
+ if (kind === "expired") {
1786
+ const queue = this.queues.get(peerId) ?? [];
1787
+ if (queue.length < this.options.maxQueue) {
1788
+ queue.push(notice);
1789
+ this.queues.set(peerId, queue);
1790
+ this.options.onQueued?.(notice, peerId);
1791
+ this.options.onMailboxesChanged?.();
1792
+ return;
1793
+ }
1794
+ }
1795
+ this.deliverToWaiter(peerId, notice);
1796
+ }
1797
+ /** Drop counters for `peerId` (undefined when nothing was ever dropped). */
1798
+ dropsFor(peerId) {
1799
+ return this.drops.get(peerId);
1800
+ }
1801
+ /** Hand `message` to a matching live waiter, if there is one. Never queues —
1802
+ * used for notices about a mailbox that must not grow. Returns whether it
1803
+ * was handed over. */
1804
+ deliverToWaiter(target, message) {
1805
+ const list = this.waiters.get(target) ?? [];
1806
+ const index = list.findIndex((waiter2) => {
1807
+ if (waiter2.from !== void 0 && waiter2.from !== message.from) return false;
1808
+ if (waiter2.ref !== void 0 && !(message.kind === "ack" && message.ref === waiter2.ref)) return false;
1809
+ if (waiter2.threadId !== void 0 && message.threadId !== waiter2.threadId) return false;
1810
+ return true;
1811
+ });
1812
+ if (index < 0) return false;
1813
+ const [waiter] = list.splice(index, 1);
1814
+ this.waiters.set(target, list);
1815
+ clearTimeout(waiter.timer);
1816
+ waiter.resolve({ type: "message", message: this.handOut(target, message, waiter.manual === true, message.ts) });
1817
+ return true;
1818
+ }
1819
+ static leaseKey(peerId, messageId) {
1820
+ return `${peerId}\0${messageId}`;
1821
+ }
1822
+ /** Return expired leases to the FRONT of the queue (order preserved), or
1823
+ * dead-letter them once they have been handed out too many times. Called
1824
+ * before every take, so expiry needs no timer of its own. */
1825
+ reapLeases(peerId, now) {
1826
+ const list = this.leases.get(peerId);
1827
+ if (list === void 0 || list.length === 0) return;
1828
+ const max = this.options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1829
+ const still = [];
1830
+ const back = [];
1831
+ for (const lease of list) {
1832
+ if (lease.expiresAt > now) {
1833
+ still.push(lease);
1834
+ continue;
1835
+ }
1836
+ const attempts = this.leaseAttempts.get(_AgentHub.leaseKey(peerId, lease.message.id)) ?? 0;
1837
+ if (max > 0 && attempts >= max) {
1838
+ const dead = this.deadLetters.get(peerId) ?? [];
1839
+ dead.push(lease.message);
1840
+ this.deadLetters.set(peerId, dead);
1841
+ continue;
1842
+ }
1843
+ back.push(lease.message);
1844
+ }
1845
+ if (still.length > 0) this.leases.set(peerId, still);
1846
+ else this.leases.delete(peerId);
1847
+ if (back.length > 0) {
1848
+ const queue = this.queues.get(peerId) ?? [];
1849
+ insertByPriority(queue, back);
1850
+ this.queues.set(peerId, queue);
1851
+ this.options.onMailboxesChanged?.();
1852
+ }
1853
+ }
1854
+ /** Hand a message to `peerId`. Without `manual` it is gone; with it the hub
1855
+ * keeps a lease and the peer must `commit` or `nack` before it expires. */
1856
+ handOut(peerId, message, manual, now) {
1857
+ if (!manual) return message;
1858
+ const key = _AgentHub.leaseKey(peerId, message.id);
1859
+ const attempts = (this.leaseAttempts.get(key) ?? 0) + 1;
1860
+ this.leaseAttempts.set(key, attempts);
1861
+ const expiresAt = now + (this.options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS);
1862
+ const list = this.leases.get(peerId) ?? [];
1863
+ list.push({ message, expiresAt });
1864
+ this.leases.set(peerId, list);
1865
+ return { ...message, attempts, leaseExpiresAt: expiresAt };
1866
+ }
1867
+ /** Mark leased messages as consumed; returns how many leases were released. */
1868
+ commit(peerId, ids) {
1869
+ const wanted = new Set(ids);
1870
+ const list = this.leases.get(peerId);
1871
+ if (list === void 0) return 0;
1872
+ const still = list.filter((lease) => !wanted.has(lease.message.id));
1873
+ const released = list.length - still.length;
1874
+ for (const id of wanted) this.leaseAttempts.delete(_AgentHub.leaseKey(peerId, id));
1875
+ if (still.length > 0) this.leases.set(peerId, still);
1876
+ else this.leases.delete(peerId);
1877
+ return released;
1878
+ }
1879
+ /** Give messages back NOW — from live leases or from the dead letters. Attempts
1880
+ * are kept, so a message that keeps failing still eventually dead-letters;
1881
+ * reviving a dead letter resets its count, which is the point of reviving it. */
1882
+ nack(peerId, ids) {
1883
+ const wanted = new Set(ids);
1884
+ const back = [];
1885
+ const list = this.leases.get(peerId);
1886
+ if (list !== void 0) {
1887
+ const still = [];
1888
+ for (const lease of list) {
1889
+ if (wanted.has(lease.message.id)) back.push(lease.message);
1890
+ else still.push(lease);
1891
+ }
1892
+ if (still.length > 0) this.leases.set(peerId, still);
1893
+ else this.leases.delete(peerId);
1894
+ }
1895
+ const dead = this.deadLetters.get(peerId);
1896
+ if (dead !== void 0) {
1897
+ const still = [];
1898
+ for (const message of dead) {
1899
+ if (wanted.has(message.id)) {
1900
+ back.push(message);
1901
+ this.leaseAttempts.delete(_AgentHub.leaseKey(peerId, message.id));
1902
+ } else still.push(message);
1903
+ }
1904
+ if (still.length > 0) this.deadLetters.set(peerId, still);
1905
+ else this.deadLetters.delete(peerId);
1906
+ }
1907
+ if (back.length === 0) return 0;
1908
+ const queue = this.queues.get(peerId) ?? [];
1909
+ insertByPriority(queue, back);
1910
+ this.queues.set(peerId, queue);
1911
+ this.options.onMailboxesChanged?.();
1912
+ return back.length;
1913
+ }
1137
1914
  /** Non-blocking drain of everything queued for `peer` (optionally from one sender). */
1138
- poll(peerId, from) {
1915
+ poll(peerId, from, options) {
1916
+ const now = Date.now();
1917
+ this.reapLeases(peerId, now);
1139
1918
  const queue = this.queues.get(peerId) ?? [];
1919
+ for (let i = queue.length - 1; i >= 0; i--) {
1920
+ const queued = queue[i];
1921
+ if (queued.expiresAt !== void 0 && queued.expiresAt <= now) {
1922
+ queue.splice(i, 1);
1923
+ this.countDrop(peerId, "expired", now);
1924
+ }
1925
+ }
1140
1926
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
1141
1927
  if (drained.length > 0) this.options.onMailboxesChanged?.();
1142
- return drained;
1928
+ return drained.map((message) => this.handOut(peerId, message, options?.manual === true, now));
1143
1929
  }
1144
1930
  /**
1145
1931
  * Long-poll for the next message addressed to `peer`: resolves immediately
@@ -1152,7 +1938,7 @@ var AgentHub = class {
1152
1938
  * a multi-message burst (or a reconnect with a full queue) would silently
1153
1939
  * lose messages that never reach wait/poll again.
1154
1940
  */
1155
- wait(peerId, timeoutMs, from, signal, ref, threadId) {
1941
+ wait(peerId, timeoutMs, from, signal, ref, threadId, manual, maxMessages = 1) {
1156
1942
  const startedAt = Date.now();
1157
1943
  const matches = (message) => {
1158
1944
  if (from !== void 0 && message.from !== from) return false;
@@ -1160,13 +1946,28 @@ var AgentHub = class {
1160
1946
  if (threadId !== void 0 && message.threadId !== threadId) return false;
1161
1947
  return true;
1162
1948
  };
1949
+ this.reapLeases(peerId, startedAt);
1163
1950
  const queue = this.queues.get(peerId);
1951
+ if (queue !== void 0) {
1952
+ for (let i = queue.length - 1; i >= 0; i--) {
1953
+ const queued = queue[i];
1954
+ if (queued.expiresAt !== void 0 && queued.expiresAt <= startedAt) {
1955
+ queue.splice(i, 1);
1956
+ this.countDrop(peerId, "expired", startedAt);
1957
+ }
1958
+ }
1959
+ }
1164
1960
  if (queue !== void 0 && queue.length > 0) {
1165
- const index = queue.findIndex(matches);
1166
- if (index >= 0) {
1167
- const [queued] = queue.splice(index, 1);
1961
+ const take = Math.max(1, Math.floor(maxMessages));
1962
+ const taken = [];
1963
+ for (let i = 0; i < queue.length && taken.length < take; ) {
1964
+ if (matches(queue[i])) taken.push(...queue.splice(i, 1));
1965
+ else i++;
1966
+ }
1967
+ if (taken.length > 0) {
1168
1968
  this.options.onMailboxesChanged?.();
1169
- return Promise.resolve({ type: "message", message: queued });
1969
+ const delivered = taken.map((queued) => this.handOut(peerId, queued, manual === true, startedAt));
1970
+ return Promise.resolve(take > 1 ? { type: "messages", messages: delivered } : { type: "message", message: delivered[0] });
1170
1971
  }
1171
1972
  }
1172
1973
  const budget = Math.max(1, Math.min(Math.floor(timeoutMs), this.options.waitTimeoutMs));
@@ -1203,29 +2004,42 @@ var AgentHub = class {
1203
2004
  onAbort,
1204
2005
  ...from !== void 0 ? { from } : {},
1205
2006
  ...ref !== void 0 ? { ref } : {},
1206
- ...threadId !== void 0 ? { threadId } : {}
2007
+ ...threadId !== void 0 ? { threadId } : {},
2008
+ ...manual === true ? { manual } : {},
2009
+ // A batch request keeps the batch SHAPE even when the wait ends with a
2010
+ // single message, so the caller's field access does not depend on timing.
2011
+ ...maxMessages > 1 ? { batch: true } : {}
1207
2012
  });
1208
2013
  this.waiters.set(peerId, list);
1209
2014
  });
1210
2015
  }
1211
2016
  /** Create a message from `from` addressed to `to` and deliver it. `to` may
1212
2017
  * be a peer id, {@link BROADCAST}, or a group id. */
1213
- route(from, to, kind, content, ref, threadId) {
1214
- if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
2018
+ route(from, to, kind, content, ref, threadId, expiresAt, system = false, priority) {
2019
+ const message = this.compose(from, to, kind, content, ref, threadId, expiresAt, system, priority);
2020
+ this.dispatch(message);
2021
+ return message;
2022
+ }
2023
+ /** Validate, build, and record a message from `from` to `to` (`to` may be a
2024
+ * peer id, {@link BROADCAST}, or a group id). Composing and dispatching are
2025
+ * separate steps because a caller may need ledger state to exist before
2026
+ * anything reaches a recipient — {@link sendTask} books delivery times. */
2027
+ compose(from, to, kind, content, ref, threadId, expiresAt, system = false, priority) {
2028
+ if (!system && !this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1215
2029
  const group = this.groups.get(to);
1216
- if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
2030
+ const known = this.lastSeen.has(to) || this.parkedAt.has(to);
2031
+ if (to !== BROADCAST && group === void 0 && !known) {
1217
2032
  throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1218
2033
  }
1219
2034
  let hop = 0;
1220
2035
  if (ref !== void 0) {
1221
2036
  const parent = this.historyRing.findLast((m) => m.id === ref);
1222
- hop = (parent?.hop ?? 0) + 1;
1223
- if (hop > MAX_HOP) {
1224
- throw new Error(`hop limit exceeded (max ${MAX_HOP}): start a new task/thread instead of extending this chain`);
1225
- }
2037
+ const ceiling = kind === "task" && parent?.kind === "task" ? MAX_HOP : MAX_REPLY_CHAIN;
2038
+ hop = Math.min((parent?.hop ?? 0) + 1, ceiling);
1226
2039
  }
1227
2040
  const message = {
1228
2041
  id: randomUUID(),
2042
+ seq: ++this.seqCounter,
1229
2043
  from,
1230
2044
  to,
1231
2045
  kind,
@@ -1234,27 +2048,45 @@ var AgentHub = class {
1234
2048
  ...threadId !== void 0 ? { threadId } : {},
1235
2049
  ...group !== void 0 ? { channel: group.id } : {},
1236
2050
  ...hop > 0 ? { hop } : {},
2051
+ ...expiresAt !== void 0 ? { expiresAt } : {},
2052
+ // Only a real priority is stored: an unset one must not appear on the wire,
2053
+ // or every reader has to distinguish 0 from "absent" for no reason.
2054
+ ...priority !== void 0 ? { priority } : {},
1237
2055
  ts: Date.now()
1238
2056
  };
1239
- this.lastSeen.set(from, message.ts);
2057
+ if (!system) this.lastSeen.set(from, message.ts);
2058
+ this.bumpTraffic(from, "sent");
2059
+ if (kind === "ack") this.bumpTraffic(from, "acked");
1240
2060
  this.remember(message);
1241
- if (to === BROADCAST) {
1242
- for (const peer of this.peers()) {
1243
- if (peer !== from) this.deliver(peer, message);
2061
+ return message;
2062
+ }
2063
+ /** Fan a composed message out to its recipients: broadcast, group members,
2064
+ * or the single addressed peer. */
2065
+ dispatch(message) {
2066
+ if (message.to === BROADCAST) {
2067
+ for (const peer of this.audience()) {
2068
+ if (peer !== message.from) this.deliver(peer, message);
1244
2069
  }
1245
- } else if (group !== void 0) {
2070
+ return;
2071
+ }
2072
+ const group = this.groups.get(message.to);
2073
+ if (group !== void 0) {
1246
2074
  for (const member of group.members) {
1247
- if (member !== from) this.deliver(member, message);
2075
+ if (member !== message.from) this.deliver(member, message);
1248
2076
  }
1249
- } else {
1250
- this.deliver(to, message);
2077
+ return;
1251
2078
  }
1252
- return message;
2079
+ this.deliver(message.to, message);
1253
2080
  }
1254
2081
  /** Queue or hand off a message; wake the first matching waiter for its target.
1255
2082
  * History / onMessage are recorded by the caller (route / sendToGroup) —
1256
2083
  * exactly once per message, never per fan-out target. */
1257
2084
  deliver(target, message) {
2085
+ this.bumpTraffic(target, "received");
2086
+ if (message.expiresAt !== void 0 && message.expiresAt <= Date.now()) {
2087
+ this.countDrop(target, "expired", Date.now());
2088
+ return;
2089
+ }
1258
2090
  const list = this.waiters.get(target) ?? [];
1259
2091
  const index = list.findIndex((waiter) => {
1260
2092
  if (waiter.from !== void 0 && waiter.from !== message.from) return false;
@@ -1267,14 +2099,26 @@ var AgentHub = class {
1267
2099
  const [waiter] = list.splice(index, 1);
1268
2100
  this.waiters.set(target, list);
1269
2101
  clearTimeout(waiter.timer);
1270
- waiter.resolve({ type: "message", message });
2102
+ const delivered = this.handOut(target, message, waiter.manual === true, message.ts);
2103
+ waiter.resolve(waiter.batch === true ? { type: "messages", messages: [delivered] } : { type: "message", message: delivered });
2104
+ return;
2105
+ }
2106
+ if (!this.takeToken(target, Date.now())) {
2107
+ this.countDrop(target, "rate", Date.now());
1271
2108
  return;
1272
2109
  }
1273
2110
  const queue = this.queues.get(target) ?? [];
1274
- if (queue.length >= this.options.maxQueue) queue.shift();
1275
- queue.push(message);
2111
+ if (queue.length >= this.options.maxQueue) {
2112
+ const lowest = priorityOf(queue[queue.length - 1]);
2113
+ let victim = queue.length - 1;
2114
+ while (victim > 0 && priorityOf(queue[victim - 1]) === lowest) victim--;
2115
+ queue.splice(victim, 1);
2116
+ this.countDrop(target, "overflow", Date.now());
2117
+ }
2118
+ insertByPriority(queue, [message]);
1276
2119
  this.queues.set(target, queue);
1277
2120
  this.options.onQueued?.(message, target);
2121
+ if (message.kind === "task") this.options.onTaskQueued?.(message, target);
1278
2122
  }
1279
2123
  /** Append a sent message to the history ring (and fire the persistence /
1280
2124
  * relay hooks). Exactly one call per message — see route() / sendToGroup(). */
@@ -1284,6 +2128,20 @@ var AgentHub = class {
1284
2128
  this.options.onMessage?.(message);
1285
2129
  }
1286
2130
  };
2131
+ function importHop(value) {
2132
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2133
+ }
2134
+ function insertByPriority(queue, messages) {
2135
+ for (const message of messages) {
2136
+ let at = queue.length;
2137
+ while (at > 0 && priorityOf(queue[at - 1]) < priorityOf(message)) at--;
2138
+ queue.splice(at, 0, message);
2139
+ }
2140
+ }
2141
+ function priorityOf(message) {
2142
+ const value = message.priority;
2143
+ return value === 1 || value === 2 ? value : 0;
2144
+ }
1287
2145
  function drainFrom(queue, from) {
1288
2146
  const kept = [];
1289
2147
  const drained = [];
@@ -1305,6 +2163,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
1305
2163
  import { dirname } from "node:path";
1306
2164
  var ANON_JOIN_PEER = "__join__";
1307
2165
  var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
2166
+ var PLACEHOLDER_TOKEN = /CHANGE[-_]?ME/i;
1308
2167
  function loadTokenFile(file) {
1309
2168
  let parsed;
1310
2169
  try {
@@ -1323,6 +2182,9 @@ function loadTokenFile(file) {
1323
2182
  if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
1324
2183
  throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
1325
2184
  }
2185
+ if (PLACEHOLDER_TOKEN.test(token)) {
2186
+ throw new Error(msg("uses the example placeholder token \u2014 generate one with `agent-comm-hub auth gen`"));
2187
+ }
1326
2188
  if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
1327
2189
  throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
1328
2190
  }
@@ -1338,8 +2200,19 @@ function loadTokenFile(file) {
1338
2200
  peers.add(peer);
1339
2201
  });
1340
2202
  if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
2203
+ warnIfTokenFileIsReadableByOthers(file);
1341
2204
  return table;
1342
2205
  }
2206
+ function warnIfTokenFileIsReadableByOthers(file) {
2207
+ if (process.platform === "win32") return;
2208
+ try {
2209
+ const mode = statSync(file).mode & 511;
2210
+ if ((mode & 63) !== 0) {
2211
+ console.warn(`warning: ${file} is readable by group/other (mode ${mode.toString(8)}) \u2014 chmod 600 it`);
2212
+ }
2213
+ } catch {
2214
+ }
2215
+ }
1343
2216
  function watchTokenFile(file, log2) {
1344
2217
  let table = loadTokenFile(file);
1345
2218
  const reload = () => {
@@ -1430,6 +2303,9 @@ function loadTokenFileVoid(rows) {
1430
2303
  const peers = /* @__PURE__ */ new Set();
1431
2304
  for (const { token, peer, role, owner } of rows) {
1432
2305
  if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
2306
+ if (PLACEHOLDER_TOKEN.test(token)) {
2307
+ throw new Error(`example placeholder token for peer '${peer}' \u2014 generate one with \`agent-comm-hub auth gen\``);
2308
+ }
1433
2309
  if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
1434
2310
  if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
1435
2311
  if (table.has(token)) throw new Error("duplicate token");
@@ -1449,6 +2325,9 @@ function present(message) {
1449
2325
  ...channel !== void 0 ? { channel } : {},
1450
2326
  ...threadId !== void 0 ? { threadId } : {},
1451
2327
  ...hop !== void 0 ? { hop } : {},
2328
+ // A2A speaks `taskId` / `contextId`; our names stay, these ride along.
2329
+ ...message.kind === "ack" && ref !== void 0 ? { taskId: ref } : {},
2330
+ ...threadId !== void 0 ? { contextId: threadId } : {},
1452
2331
  content: decodeContent(message.kind, message.content)
1453
2332
  };
1454
2333
  }
@@ -1513,13 +2392,28 @@ function rosterFor(hub, registry) {
1513
2392
  ...alias !== void 0 ? { alias } : {},
1514
2393
  ...clientName !== void 0 ? { clientName } : {},
1515
2394
  ...clientVersion !== void 0 ? { clientVersion } : {},
2395
+ // The announced card rides with the roster: `bridge_peers` is how an agent
2396
+ // sees who else is here, and a card nobody can read is not announced.
2397
+ ...profile?.card !== void 0 ? { card: profile.card } : {},
1516
2398
  ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1517
2399
  ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
1518
2400
  connected: peer.connected,
2401
+ // W7: keep the reachability verdict and the queue depth in the tool output —
2402
+ // `connected: false` alone cannot say "idle, will read it later" apart from
2403
+ // "gone, and your message is nowhere".
2404
+ reachable: peer.reachable,
2405
+ queued: peer.queued,
1519
2406
  lastSeenMs: peer.lastSeenMs
1520
2407
  };
1521
2408
  });
1522
2409
  }
2410
+ function isManagerCaller(registry, sessionId, peer, options) {
2411
+ if (registry.tokenFor(sessionId)?.role === "manager") return true;
2412
+ if (options.managerTokenRequired === true) return registry.isManagerSession(sessionId);
2413
+ const managers = options.managerPeers ?? "all";
2414
+ if (managers === "all") return true;
2415
+ return peer !== void 0 && managers.has(peer);
2416
+ }
1523
2417
  function hubTools(hub, registry, options) {
1524
2418
  const schema = (properties, required = []) => ({
1525
2419
  type: "object",
@@ -1530,6 +2424,86 @@ function hubTools(hub, registry, options) {
1530
2424
  const str = (description) => ({ type: "string", description });
1531
2425
  const int = (description) => ({ type: "integer", description });
1532
2426
  const optStr = str;
2427
+ const priorityField = () => ({
2428
+ type: "integer",
2429
+ enum: [0, 1, 2],
2430
+ description: "Delivery importance inside the recipient's mailbox: 2 beats 1 beats 0. Default 0 (plain FIFO). Also decides what overflow drops \u2014 the LEAST important queued message goes, not the oldest."
2431
+ });
2432
+ const optPriority = (value) => value === 0 || value === 1 || value === 2 ? { priority: value } : {};
2433
+ const idList = (value, tool) => {
2434
+ if (!Array.isArray(value)) throw new Error(`${tool} expects ids: an array of message ids`);
2435
+ const ids = value.filter((id) => typeof id === "string" && id !== "");
2436
+ if (ids.length === 0) throw new Error(`${tool} expects at least one message id`);
2437
+ return ids;
2438
+ };
2439
+ const deadline = (value) => {
2440
+ const at = Number(value);
2441
+ if (!Number.isFinite(at) || at <= 0) throw new Error(`invalid expiresAt: ${String(value)} (expected epoch milliseconds)`);
2442
+ if (at <= Date.now()) throw new Error(`expiresAt is already in the past: ${new Date(at).toISOString()}`);
2443
+ return at;
2444
+ };
2445
+ const parseArtifacts = (value) => {
2446
+ if (value === void 0) return void 0;
2447
+ if (!Array.isArray(value)) throw new Error("artifacts must be an array of {name, text?/url?} objects");
2448
+ const out = [];
2449
+ for (const [index, entry] of value.entries()) {
2450
+ if (typeof entry !== "object" || entry === null) throw new Error(`artifacts[${index}] must be an object`);
2451
+ const raw = entry;
2452
+ if (typeof raw.name !== "string" || raw.name === "") throw new Error(`artifacts[${index}].name is required`);
2453
+ const artifact = { name: raw.name };
2454
+ if (typeof raw.mimeType === "string" && raw.mimeType !== "") artifact.mimeType = raw.mimeType;
2455
+ if (typeof raw.text === "string") artifact.text = raw.text;
2456
+ if (typeof raw.url === "string" && raw.url !== "") artifact.url = raw.url;
2457
+ if (artifact.text === void 0 && artifact.url === void 0) {
2458
+ throw new Error(`artifacts[${index}] needs text or url \u2014 an artifact with neither says nothing`);
2459
+ }
2460
+ out.push(artifact);
2461
+ }
2462
+ return out.length > 0 ? out : void 0;
2463
+ };
2464
+ const parseCard = (value) => {
2465
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("card must be an object");
2466
+ const raw = value;
2467
+ const card = {};
2468
+ if (raw.skills !== void 0) {
2469
+ if (!Array.isArray(raw.skills)) throw new Error("card.skills must be an array");
2470
+ card.skills = raw.skills.map((entry, index) => {
2471
+ if (typeof entry !== "object" || entry === null) throw new Error(`card.skills[${index}] must be an object`);
2472
+ const skill = entry;
2473
+ if (typeof skill.id !== "string" || skill.id === "") throw new Error(`card.skills[${index}].id is required`);
2474
+ if (typeof skill.name !== "string" || skill.name === "") throw new Error(`card.skills[${index}].name is required`);
2475
+ return {
2476
+ id: skill.id,
2477
+ name: skill.name,
2478
+ ...typeof skill.description === "string" ? { description: skill.description } : {},
2479
+ ...Array.isArray(skill.tags) ? { tags: skill.tags.filter((tag) => typeof tag === "string") } : {}
2480
+ };
2481
+ });
2482
+ }
2483
+ if (raw.acceptsTasks !== void 0) {
2484
+ if (typeof raw.acceptsTasks !== "boolean") throw new Error("card.acceptsTasks must be a boolean");
2485
+ card.acceptsTasks = raw.acceptsTasks;
2486
+ }
2487
+ if (raw.concurrency !== void 0) {
2488
+ if (!Number.isInteger(raw.concurrency) || raw.concurrency < 0) throw new Error("card.concurrency must be a non-negative integer");
2489
+ card.concurrency = raw.concurrency;
2490
+ }
2491
+ for (const key of ["inputModes", "outputModes"]) {
2492
+ const list = raw[key];
2493
+ if (list === void 0) continue;
2494
+ if (!Array.isArray(list)) throw new Error(`card.${key} must be an array of strings`);
2495
+ card[key] = list.filter((entry) => typeof entry === "string");
2496
+ }
2497
+ if (raw.runner !== void 0) {
2498
+ if (!["herdr", "cli", "http", "none"].includes(String(raw.runner))) throw new Error("card.runner must be one of herdr | cli | http | none");
2499
+ card.runner = raw.runner;
2500
+ }
2501
+ if (raw.endpoint !== void 0) {
2502
+ if (typeof raw.endpoint !== "string" || raw.endpoint === "") throw new Error("card.endpoint must be a non-empty string");
2503
+ card.endpoint = raw.endpoint;
2504
+ }
2505
+ return card;
2506
+ };
1533
2507
  const requirePeer = (sessionId) => {
1534
2508
  const bound = registry.peerFor(sessionId);
1535
2509
  if (bound !== void 0) {
@@ -1546,16 +2520,26 @@ function hubTools(hub, registry, options) {
1546
2520
  const receipt = (message) => ({
1547
2521
  ok: true,
1548
2522
  id: message.id,
2523
+ // The sender needs this back: it is the cursor to record.
2524
+ ...message.seq !== void 0 ? { seq: message.seq } : {},
1549
2525
  from: message.from,
1550
2526
  to: message.to,
1551
2527
  kind: message.kind,
1552
2528
  ...message.ref !== void 0 ? { ref: message.ref } : {},
1553
2529
  ...message.channel !== void 0 ? { channel: message.channel } : {},
2530
+ // A2A aliases (additive): a task receipt answers with `taskId`, and a
2531
+ // threaded message carries its `contextId`.
2532
+ ...message.kind === "task" ? { taskId: message.id } : {},
2533
+ ...message.threadId !== void 0 ? { contextId: message.threadId } : {},
1554
2534
  ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
1555
2535
  ...message.hop !== void 0 ? { hop: message.hop } : {},
1556
2536
  ts: message.ts
1557
2537
  });
1558
- const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
2538
+ const presentWait = (result) => {
2539
+ if (result.type === "timeout") return result;
2540
+ if (result.type === "messages") return { type: "messages", messages: result.messages.map(present) };
2541
+ return { type: "message", message: present(result.message) };
2542
+ };
1559
2543
  const checkControl = (peer) => {
1560
2544
  const herdr = options.herdr;
1561
2545
  if (herdr === void 0) {
@@ -1568,17 +2552,27 @@ function hubTools(hub, registry, options) {
1568
2552
  return herdr;
1569
2553
  };
1570
2554
  const requireManager = (peer, action, sessionId) => {
1571
- if (registry.tokenFor(sessionId)?.role === "manager") return;
1572
- const managers = options.managerPeers ?? "all";
1573
- if (managers !== "all" && !managers.has(peer)) {
1574
- throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
1575
- }
2555
+ if (isManagerCaller(registry, sessionId, peer, options)) return;
2556
+ throw new Error(
2557
+ options.managerTokenRequired === true ? `peer '${peer}' is not a hub manager \u2014 ${action} requires the manager token (Authorization: Bearer <token>)` : `peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`
2558
+ );
1576
2559
  };
1577
2560
  const asStatuses = (value) => {
1578
2561
  if (!Array.isArray(value)) return void 0;
1579
2562
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
1580
2563
  return statuses.length > 0 ? statuses : void 0;
1581
2564
  };
2565
+ const requireArgs = (tool, handler) => {
2566
+ const required = tool.inputSchema.required ?? [];
2567
+ if (required.length === 0) return handler;
2568
+ return async (args, sessionId) => {
2569
+ const missing = required.filter((name) => args?.[name] === void 0);
2570
+ if (missing.length > 0) {
2571
+ throw new Error(`missing required argument(s) for ${tool.name}: ${missing.join(", ")}`);
2572
+ }
2573
+ return handler(args, sessionId);
2574
+ };
2575
+ };
1582
2576
  const wrap = (peerAware, handler) => async (args, sessionId) => {
1583
2577
  const peer = peerAware ? requirePeer(sessionId) : "";
1584
2578
  return handler(args, peer, sessionId);
@@ -1592,13 +2586,20 @@ function hubTools(hub, registry, options) {
1592
2586
  alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
1593
2587
  }, ["peerId"]),
1594
2588
  handler: async (args, sessionId) => {
1595
- const peerId = String(args.peerId);
2589
+ const boundId = registry.peerFor(sessionId);
2590
+ const peerId = args.peerId === void 0 ? boundId : String(args.peerId);
2591
+ if (peerId === void 0) {
2592
+ throw new Error('no peer to register \u2014 pass peerId (e.g. {"peerId":"mavis"}); this session is not bound to a peer yet');
2593
+ }
1596
2594
  if (!PEER_ID_PATTERN.test(peerId)) {
1597
2595
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
1598
2596
  }
1599
2597
  if (peerId === BROADCAST) {
1600
2598
  throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
1601
2599
  }
2600
+ if (peerId === HUB_PEER) {
2601
+ throw new Error(`reserved peer id: ${peerId} (the hub uses it for its own notices)`);
2602
+ }
1602
2603
  const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
1603
2604
  const tokenPeer = registry.tokenFor(sessionId)?.peer;
1604
2605
  if (tokenPeer !== void 0 && peerId !== tokenPeer) {
@@ -1617,8 +2618,8 @@ function hubTools(hub, registry, options) {
1617
2618
  if (registry.attachedCount(current) === 0) hub.unregister(current);
1618
2619
  }
1619
2620
  }
1620
- if (!hub.has(peerId)) hub.register(peerId);
1621
2621
  registry.bindPeer(sessionId, peerId);
2622
+ if (!hub.has(peerId)) hub.register(peerId);
1622
2623
  registry.clearSuppress(sessionId);
1623
2624
  hub.touch(peerId);
1624
2625
  if (alias !== void 0) hub.setAlias(peerId, alias);
@@ -1646,7 +2647,7 @@ function hubTools(hub, registry, options) {
1646
2647
  if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
1647
2648
  const target = String(args.peer);
1648
2649
  requireManager(caller, `kicking '${target}'`, sessionId);
1649
- if (!hub.has(target)) {
2650
+ if (!hub.has(target) && !hub.isParked(target)) {
1650
2651
  return { ok: true, peerId: null, kicked: false };
1651
2652
  }
1652
2653
  hub.unregister(target);
@@ -1694,32 +2695,67 @@ function hubTools(hub, registry, options) {
1694
2695
  },
1695
2696
  {
1696
2697
  name: "bridge_chat",
1697
- 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, a group id, or "all" to broadcast. Optional `threadId` groups multi-turn dialogs; optional `ref` marks a reply-to.',
2698
+ 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, a group id, or "all" to broadcast. Optional `threadId` groups multi-turn dialogs; optional `ref` marks a reply-to and carries the chain depth in `hop`. Reply chains are counted, not enforced (50 hops for chat/ack, 5 for a task replying to a task): past the ceiling the message and its `ref` are delivered unchanged and only `hop` stops counting, so nothing is ever refused and no correlation is lost.',
1698
2699
  inputSchema: schema(
1699
2700
  {
1700
2701
  to: str('Target peerId, group id, or "all" to broadcast.'),
1701
2702
  message: str("The message text."),
1702
2703
  threadId: optStr("Optional conversation / workflow thread id."),
1703
- ref: optStr("Optional reply-to message id.")
2704
+ priority: priorityField(),
2705
+ ref: optStr("Optional reply-to message id."),
2706
+ expiresAt: int("Optional absolute epoch-ms deadline. A message still queued (or about to be handed over) after it is dropped and counted as droppedExpired instead of delivered \u2014 a stale instruction is worse than none.")
1704
2707
  },
1705
2708
  ["to", "message"]
1706
2709
  ),
1707
2710
  handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message), {
1708
2711
  ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
1709
- ...args.ref !== void 0 ? { ref: String(args.ref) } : {}
2712
+ ...optPriority(args.priority),
2713
+ ...args.ref !== void 0 ? { ref: String(args.ref) } : {},
2714
+ ...args.expiresAt !== void 0 ? { expiresAt: deadline(args.expiresAt) } : {}
1710
2715
  })))
1711
2716
  },
2717
+ {
2718
+ name: "bridge_notice",
2719
+ description: 'Send a system-flavoured notice to another agent (peer, group id, or "all"). Use it for things the recipient should see differently from chat: "your mailbox dropped 3 messages", "hub restarting", "that task was cancelled". `level` is info | warn | error (default info). The payload is structured ({message, level}) rather than plain text, and the message is a normal routed message \u2014 it queues for a parked peer like any other. It does NOT create a task ledger row.',
2720
+ inputSchema: schema(
2721
+ {
2722
+ to: str('Target peerId, group id, or "all" to broadcast.'),
2723
+ message: str("The notice text."),
2724
+ level: { type: "string", enum: ["info", "warn", "error"], description: "Severity (default info)." },
2725
+ threadId: optStr("Optional conversation / workflow thread id."),
2726
+ priority: priorityField()
2727
+ },
2728
+ ["to", "message"]
2729
+ ),
2730
+ handler: wrap(true, async (args, peer) => {
2731
+ const level = args.level === void 0 ? void 0 : String(args.level);
2732
+ if (level !== void 0 && level !== "info" && level !== "warn" && level !== "error") {
2733
+ throw new Error(`invalid level: ${level} (expected info | warn | error)`);
2734
+ }
2735
+ const payload = {
2736
+ message: String(args.message),
2737
+ ...level !== void 0 ? { level } : {}
2738
+ };
2739
+ return receipt(hub.send(peer, String(args.to), "notice", encodeContent(payload), {
2740
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
2741
+ ...optPriority(args.priority)
2742
+ }));
2743
+ })
2744
+ },
1712
2745
  {
1713
2746
  name: "bridge_task",
1714
- description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack.',
2747
+ description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack; any positive duration is honored, including multi-day deadlines (the hub re-arms its timer instead of overflowing it). Optional `ref` names the task this one answers \u2014 when the parent is itself a task the new one is a re-delegation, and `hop` counts that chain depth (saturating at 5; the task is always delivered).',
1715
2748
  inputSchema: schema(
1716
2749
  {
1717
2750
  to: str('Target peerId, group id, or "all" to broadcast.'),
1718
2751
  prompt: str("What the receiving agent should do."),
1719
2752
  context: optStr("Optional background information for the task."),
1720
2753
  deliverable: optStr("Optional expected deliverable description."),
1721
- timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1722
- threadId: optStr("Optional conversation / workflow thread id.")
2754
+ timeoutMs: int("Optional soft SLA in ms from now (e.g. 30000). No upper bound \u2014 a deadline past ~24.8 days is honored as well."),
2755
+ threadId: optStr("Optional conversation / workflow thread id."),
2756
+ priority: priorityField(),
2757
+ ref: optStr("Optional: the task message id this one answers (makes it a re-delegation when the parent is a task; that chain is capped at 5 hops)."),
2758
+ expiresAt: int("Optional absolute epoch-ms deadline for DELIVERY (distinct from timeoutMs, which is the assignee's SLA after it arrives). A task still undelivered at that moment is dropped and counted as droppedExpired.")
1723
2759
  },
1724
2760
  ["to", "prompt"]
1725
2761
  ),
@@ -1728,8 +2764,9 @@ function hubTools(hub, registry, options) {
1728
2764
  ...args.context !== void 0 ? { context: String(args.context) } : {},
1729
2765
  ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {},
1730
2766
  ...args.timeoutMs !== void 0 ? { timeoutMs: Number(args.timeoutMs) } : {},
1731
- ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {}
1732
- })))
2767
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
2768
+ ...optPriority(args.priority)
2769
+ }, args.ref === void 0 ? void 0 : String(args.ref), args.expiresAt === void 0 ? void 0 : deadline(args.expiresAt))))
1733
2770
  },
1734
2771
  {
1735
2772
  name: "bridge_ack",
@@ -1737,21 +2774,24 @@ function hubTools(hub, registry, options) {
1737
2774
  inputSchema: schema(
1738
2775
  {
1739
2776
  ref: str("The id of the message being acknowledged."),
1740
- status: { type: "string", enum: ["accepted", "working", "rejected", "done", "failed"], description: "acknowledgement status." },
2777
+ status: { type: "string", enum: [...CLIENT_ACK_STATUSES], description: "acknowledgement status. `input-required` is an INTERRUPT: it asks the sender for information without ending the task \u2014 the sender answers with bridge_task_input and work resumes. To stop a task use bridge_task_cancel, not an ack." },
1741
2778
  note: optStr("Optional explanation for the acknowledgement."),
2779
+ artifacts: { type: "array", description: "Optional output attached to this ack: [{name, mimeType?, text? | url?}]. A `done` ack with artifacts is how you hand back what you produced; they show up in bridge_task_status.", items: { type: "object" } },
1742
2780
  progress: int("Optional 0-100 progress (meaningful with status=working).")
1743
2781
  },
1744
2782
  ["ref", "status"]
1745
2783
  ),
1746
2784
  handler: wrap(true, async (args, peer) => {
1747
2785
  const status = String(args.status);
1748
- const valid = ["accepted", "working", "rejected", "done", "failed"];
2786
+ const valid = CLIENT_ACK_STATUSES;
1749
2787
  if (!valid.includes(status)) {
1750
2788
  throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
1751
2789
  }
2790
+ const artifacts = parseArtifacts(args.artifacts);
1752
2791
  return receipt(hub.sendAck(peer, String(args.ref), {
1753
2792
  status,
1754
2793
  ...args.note !== void 0 ? { note: String(args.note) } : {},
2794
+ ...artifacts !== void 0 ? { artifacts } : {},
1755
2795
  ...args.progress !== void 0 ? { progress: Number(args.progress) } : {}
1756
2796
  }));
1757
2797
  })
@@ -1790,26 +2830,99 @@ function hubTools(hub, registry, options) {
1790
2830
  },
1791
2831
  {
1792
2832
  name: "bridge_task_status",
1793
- description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref).",
2833
+ description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref). Readable by the sender, by an assignee (a group task carries the GROUP id in `to`, so its assignees read it through their assignee row) and by a group member; everyone else needs manager rights.",
1794
2834
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1795
2835
  handler: wrap(true, async (args, peer, sessionId) => {
1796
2836
  const ref = String(args.ref);
1797
2837
  const task = hub.taskOf(ref);
1798
2838
  if (task === void 0) throw new Error(`unknown task: ${ref}`);
1799
- if (task.from !== peer && task.to !== peer && task.to !== BROADCAST) {
2839
+ const isAssignee = task.assignees.some((assignee) => assignee.peerId === peer);
2840
+ const isGroupMember = hub.groupOf(task.to)?.members.includes(peer) ?? false;
2841
+ if (task.from !== peer && task.to !== peer && task.to !== BROADCAST && !isAssignee && !isGroupMember) {
1800
2842
  requireManager(peer, `reading task '${ref}'`, sessionId);
1801
2843
  }
1802
- return { task };
2844
+ return {
2845
+ task,
2846
+ // A2A naming aliases: the same task under the names an A2A client
2847
+ // already uses. `task.id` stays authoritative.
2848
+ taskId: task.id,
2849
+ ...task.threadId !== void 0 ? { contextId: task.threadId } : {},
2850
+ a2aState: a2aState(task.status)
2851
+ };
1803
2852
  })
1804
2853
  },
2854
+ {
2855
+ name: "bridge_task_cancel",
2856
+ description: "Cancel a delegated task. Either side may do it: the sender stops work it no longer wants, an assignee reports it will not finish. The cancel is recorded on the ledger (`canceled`, terminal) and routed to the OTHER side so whoever did not press the button finds out. Cancelling an already-terminal task is an error, not a silent no-op.",
2857
+ inputSchema: schema(
2858
+ {
2859
+ ref: str("Task message id (the id returned by bridge_task)."),
2860
+ reason: optStr("Optional reason, delivered with the cancel and kept on the timeline.")
2861
+ },
2862
+ ["ref"]
2863
+ ),
2864
+ handler: wrap(true, async (args, peer) => receipt(hub.cancelTask(peer, String(args.ref), args.reason === void 0 ? void 0 : String(args.reason))))
2865
+ },
2866
+ {
2867
+ name: "bridge_task_input",
2868
+ description: "Answer a task whose assignee acked `input-required`: only the task's SENDER can answer it. The answer is delivered to the waiting assignee as an ack carrying the task id (so its `bridge_wait { ref }` picks it up) and the task resumes (`working`). An assignee does not call this \u2014 it resumes by acking `working` itself.",
2869
+ inputSchema: schema(
2870
+ {
2871
+ ref: str("Task message id."),
2872
+ input: str("The information the assignee asked for.")
2873
+ },
2874
+ ["ref", "input"]
2875
+ ),
2876
+ handler: wrap(true, async (args, peer) => receipt(hub.taskInput(peer, String(args.ref), String(args.input))))
2877
+ },
2878
+ {
2879
+ name: "bridge_announce",
2880
+ description: "Say what you can do, so other agents can find you: skills (id + name, optional tags), whether you accept tasks, how many at once, your input/output modes, and how you are executed when not polling. Replaces your previous card. `bridge_discover` only offers peers that set acceptsTasks \u2014 announcing a skill alone does not mean you want work.",
2881
+ inputSchema: schema(
2882
+ {
2883
+ card: {
2884
+ type: "object",
2885
+ description: 'Your card: {skills?: [{id, name, description?, tags?}], acceptsTasks?: boolean, concurrency?: number, inputModes?: string[], outputModes?: string[], runner?: "herdr"|"cli"|"http"|"none", endpoint?: string}.'
2886
+ }
2887
+ },
2888
+ ["card"]
2889
+ ),
2890
+ handler: wrap(true, async (args, peer) => {
2891
+ const profile = hub.announceCard(peer, parseCard(args.card));
2892
+ return { ok: true, peer, card: profile.card };
2893
+ })
2894
+ },
2895
+ {
2896
+ name: "bridge_discover",
2897
+ description: "Find a peer to hand work to. Returns the peers whose announced card matches, each with its current load (queued mailbox depth, live waiters, non-terminal tasks assigned to it). By default only peers that announced `acceptsTasks: true` are returned \u2014 pass acceptsTasks:false to list peers regardless. Peers that never announced a card are never returned.",
2898
+ inputSchema: schema(
2899
+ {
2900
+ skill: optStr("Only peers declaring this skill id."),
2901
+ tag: optStr("Only peers whose skills carry this tag."),
2902
+ acceptsTasks: { type: "boolean", description: "Require the peer to accept tasks (default true). Pass false to drop that requirement." },
2903
+ idleOnly: { type: "boolean", description: "Only peers with nothing queued, no waiter and no running task." }
2904
+ },
2905
+ []
2906
+ ),
2907
+ handler: wrap(true, async (args) => ({
2908
+ peers: hub.discover({
2909
+ ...args.skill !== void 0 ? { skill: String(args.skill) } : {},
2910
+ ...args.tag !== void 0 ? { tag: String(args.tag) } : {},
2911
+ ...args.acceptsTasks !== void 0 ? { acceptsTasks: args.acceptsTasks === true } : {},
2912
+ ...args.idleOnly !== void 0 ? { idleOnly: args.idleOnly === true } : {}
2913
+ })
2914
+ }))
2915
+ },
1805
2916
  {
1806
2917
  name: "bridge_wait",
1807
- description: "Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. `ref` waits specifically for the ack of that task id. `threadId` narrows to one conversation thread. Loop this tool to hold a real-time conversation.",
2918
+ description: 'Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. `ref` waits specifically for the ack of that task id. `threadId` narrows to one conversation thread. Loop this tool to hold a real-time conversation. With `ack: "manual"` the message is LEASED rather than consumed: it comes back (with `attempts` incremented) if you do not call bridge_commit in time, and is dead-lettered after too many hand-outs. Use it when losing a message would be worse than handling one twice.',
1808
2919
  inputSchema: schema({
1809
2920
  from: optStr("Only wait for messages from this peerId."),
1810
2921
  ref: optStr("Only wait for the ack of this task message id (bridge_task id)."),
1811
2922
  threadId: optStr("Only wait for messages carrying this threadId."),
1812
- timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
2923
+ timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`),
2924
+ maxMessages: int('Take up to this many already-queued messages in one call (default 1). With > 1 the result is {type:"messages", messages:[\u2026]} instead of {type:"message", message:{\u2026}} \u2014 the shape follows the request, not how many happened to be queued.'),
2925
+ ack: { type: "string", enum: ["auto", "manual"], description: '"auto" (default) consumes the message; "manual" leases it until bridge_commit (or bridge_nack).' }
1813
2926
  }),
1814
2927
  handler: wrap(true, async (args, peer) => presentWait(await hub.wait(
1815
2928
  peer,
@@ -1817,14 +2930,39 @@ function hubTools(hub, registry, options) {
1817
2930
  args.from === void 0 ? void 0 : String(args.from),
1818
2931
  void 0,
1819
2932
  args.ref === void 0 ? void 0 : String(args.ref),
1820
- args.threadId === void 0 ? void 0 : String(args.threadId)
2933
+ args.threadId === void 0 ? void 0 : String(args.threadId),
2934
+ args.ack === "manual",
2935
+ args.maxMessages === void 0 ? 1 : Math.max(1, Number(args.maxMessages))
1821
2936
  )))
1822
2937
  },
1823
2938
  {
1824
2939
  name: "bridge_poll",
1825
- description: "Non-blocking: drain every message currently queued for you. Empty list when nothing is waiting. `from` narrows to one sender.",
1826
- inputSchema: schema({ from: optStr("Only drain messages from this peerId.") }),
1827
- handler: wrap(true, async (args, peer) => ({ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from)).map(present) }))
2940
+ description: 'Non-blocking: drain every message currently queued for you. Empty list when nothing is waiting. `from` narrows to one sender. `ack: "manual"` leases what it drains (see bridge_wait) instead of consuming it.',
2941
+ inputSchema: schema({
2942
+ from: optStr("Only drain messages from this peerId."),
2943
+ ack: { type: "string", enum: ["auto", "manual"], description: '"auto" (default) consumes; "manual" leases until bridge_commit.' }
2944
+ }),
2945
+ handler: wrap(true, async (args, peer) => ({
2946
+ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from), { manual: args.ack === "manual" }).map(present)
2947
+ }))
2948
+ },
2949
+ {
2950
+ name: "bridge_commit",
2951
+ description: 'Acknowledge messages you received under a manual lease (bridge_wait / bridge_poll with ack: "manual"). Until you commit, the hub keeps them in flight and hands them out again once the lease expires \u2014 committing is what makes handling them final. Returns how many leases were released.',
2952
+ inputSchema: schema(
2953
+ { ids: { type: "array", items: { type: "string" }, description: "Message ids you received and have finished handling." } },
2954
+ ["ids"]
2955
+ ),
2956
+ handler: wrap(true, async (args, peer) => ({ committed: hub.commit(peer, idList(args.ids, "bridge_commit")) }))
2957
+ },
2958
+ {
2959
+ name: "bridge_nack",
2960
+ description: "Give leased messages back NOW instead of waiting for the lease to expire \u2014 use it when handling failed. They return to the head of your queue (another session of the same peer can retry), their attempt count is kept, and a message that keeps failing ends up dead-lettered instead of looping. It also revives a dead-lettered message (resetting its attempts). Returns how many were requeued.",
2961
+ inputSchema: schema(
2962
+ { ids: { type: "array", items: { type: "string" }, description: "Message ids to give back." } },
2963
+ ["ids"]
2964
+ ),
2965
+ handler: wrap(true, async (args, peer) => ({ requeued: hub.nack(peer, idList(args.ids, "bridge_nack")) }))
1828
2966
  },
1829
2967
  {
1830
2968
  name: "bridge_status",
@@ -1832,6 +2970,21 @@ function hubTools(hub, registry, options) {
1832
2970
  inputSchema: schema({}),
1833
2971
  handler: wrap(true, async () => hub.status(livePeersFor(registry)))
1834
2972
  },
2973
+ {
2974
+ name: "bridge_metrics",
2975
+ description: "Operational counters, per peer and global. Per peer: sent / received / acked (traffic counters that survive a drained queue), queued, inFlight, deadLettered, droppedOverflow, droppedExpired, connected. Global: messagesTotal (the history seq \u2014 a true count, not a rolling window), peers / connectedPeers / parkedPeers, waitersNow, queuedTotal, tasksByStatus, and the drop / lease totals. Every number is derived from live state or from a counter the hub already keeps; nothing is estimated. Counters are per run and reset on restart.",
2976
+ inputSchema: schema({}),
2977
+ handler: wrap(true, async () => hub.metrics())
2978
+ },
2979
+ {
2980
+ name: "bridge_events",
2981
+ description: "Read the hub's own operational event stream, oldest first. These are what the HUB did, not what peers said: `peer_joined` / `peer_left` / `peer_parked` / `mailbox_dropped` / `task_transition` / `runner_started` / `runner_finished`. Each event has a monotonic `seq` (never resets in a run), a `ts`, and a kind-specific `detail`. Pass `since` (exclusive) with the `lastSeq` of your previous call to poll for new events only. `truncated: true` means the window you asked for is older than the ring still holds, so you missed entries \u2014 re-read from the start rather than assuming you saw everything. Events are per-run and in-memory; a restart starts a new sequence.",
2982
+ inputSchema: schema({
2983
+ since: { type: "number", description: "Return only events with a seq greater than this (exclusive)." },
2984
+ limit: { type: "number", description: "Maximum events to return, newest kept (default 100, max 500)." }
2985
+ }),
2986
+ handler: wrap(true, async (args) => hub.events(args))
2987
+ },
1835
2988
  {
1836
2989
  name: "bridge_peers",
1837
2990
  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.",
@@ -1844,10 +2997,16 @@ function hubTools(hub, registry, options) {
1844
2997
  inputSchema: schema({
1845
2998
  peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'),
1846
2999
  channel: optStr("Read a group channel history instead of a peer conversation (you must be a member, or a manager)."),
1847
- limit: int("How many messages to return (default 20).")
3000
+ limit: int("How many messages to return (default 20)."),
3001
+ since: int('Resume cursor: return only messages with seq greater than this. Read bridge_status.lastSeq to start from "now"; omit to include history recorded before cursors existed.')
1848
3002
  }),
1849
3003
  handler: wrap(true, async (args, peer, sessionId) => {
1850
3004
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
3005
+ const sinceRaw = args.since === void 0 ? void 0 : Number(args.since);
3006
+ if (sinceRaw !== void 0 && (!Number.isFinite(sinceRaw) || sinceRaw < 0)) {
3007
+ throw new Error(`invalid since: ${String(args.since)} (expected a non-negative sequence number)`);
3008
+ }
3009
+ const since = sinceRaw;
1851
3010
  const target = args.peer === void 0 ? peer : String(args.peer);
1852
3011
  if (target !== peer) {
1853
3012
  requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
@@ -1857,9 +3016,9 @@ function hubTools(hub, registry, options) {
1857
3016
  const group = hub.groupOf(groupId);
1858
3017
  if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1859
3018
  if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
1860
- return { messages: hub.historyChannel(groupId, limit).map(present) };
3019
+ return { messages: hub.historyChannel(groupId, limit, since).map(present) };
1861
3020
  }
1862
- const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
3021
+ const messages = target === BROADCAST ? hub.historyAll(limit, since) : hub.history(target, limit, since);
1863
3022
  return { messages: messages.map(present) };
1864
3023
  })
1865
3024
  },
@@ -2135,11 +3294,11 @@ function hubTools(hub, registry, options) {
2135
3294
  return read === null ? { matched: null } : { matched: read };
2136
3295
  })
2137
3296
  }
2138
- ];
3297
+ ].map((tool) => ({ ...tool, handler: requireArgs(tool, tool.handler) }));
2139
3298
  }
2140
3299
 
2141
3300
  // src/mcp-server.ts
2142
- import { randomUUID as randomUUID2 } from "node:crypto";
3301
+ import { randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
2143
3302
  import { readFileSync as readFileSync2 } from "node:fs";
2144
3303
  var ADMIN_PATH = "/admin";
2145
3304
  var ADMIN_API_PATH = "/admin/api";
@@ -2154,6 +3313,7 @@ var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
2154
3313
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
2155
3314
  var MAX_BODY_BYTES = 1048576;
2156
3315
  var HEARTBEAT_MS = 2e4;
3316
+ var DEFAULT_SESSION_TTL_MS = 30 * 6e4;
2157
3317
  var SessionRegistry = class {
2158
3318
  sessions = /* @__PURE__ */ new Set();
2159
3319
  /** sessionId → peerId claimed via `bridge_register`. */
@@ -2166,8 +3326,17 @@ var SessionRegistry = class {
2166
3326
  ensureSession(sessionId) {
2167
3327
  const id = sessionId ?? randomUUID2();
2168
3328
  this.sessions.add(id);
3329
+ this.lastUsedAt.set(id, Date.now());
2169
3330
  return id;
2170
3331
  }
3332
+ /** Has this session id ever been minted here and not terminated since?
3333
+ * A request presenting an id the registry does not know must be answered 404
3334
+ * (spec MUST) so the client starts a new session, instead of being adopted
3335
+ * silently — which is how a client kept talking to a hub that had restarted
3336
+ * and already forgotten it. */
3337
+ hasSession(sessionId) {
3338
+ return this.sessions.has(sessionId);
3339
+ }
2171
3340
  /** Peer bound to a session, if any. */
2172
3341
  peerFor(sessionId) {
2173
3342
  if (sessionId === void 0) return void 0;
@@ -2223,6 +3392,47 @@ var SessionRegistry = class {
2223
3392
  isSuppressed(sessionId) {
2224
3393
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
2225
3394
  }
3395
+ /** Session → the last time a request presented it.
3396
+ *
3397
+ * The registry is the only thing that knows a session exists, so it is also the
3398
+ * only thing that can decide one is finished. Without this the maps below only
3399
+ * ever grew, `hasSession` was true forever, and "this session is over" was not
3400
+ * expressible — so the 404 path could never fire for a session the hub had
3401
+ * genuinely finished with. */
3402
+ lastUsedAt = /* @__PURE__ */ new Map();
3403
+ /** Record that `sessionId` was just used (a request carried its id). */
3404
+ touch(sessionId) {
3405
+ if (sessionId !== void 0 && this.sessions.has(sessionId)) this.lastUsedAt.set(sessionId, Date.now());
3406
+ }
3407
+ /**
3408
+ * Forget sessions that are finished: no live SSE stream, and unused for `ttlMs`.
3409
+ * Returns the ids removed. `ttlMs <= 0` disables reaping.
3410
+ *
3411
+ * Safe because a client that gets 404 is REQUIRED by the spec to send a fresh
3412
+ * `initialize`, and the hub auto-registers it under the same peer id — it comes
3413
+ * back with its mailbox intact. A live SSE stream pins a session regardless of
3414
+ * age: that is the one signal that means "still connected", and it wins.
3415
+ */
3416
+ sweep(now, ttlMs) {
3417
+ if (ttlMs <= 0) return [];
3418
+ const removed = [];
3419
+ for (const sessionId of [...this.sessions]) {
3420
+ if (this.liveStreams.has(sessionId)) continue;
3421
+ const last = this.lastUsedAt.get(sessionId) ?? now;
3422
+ if (now - last <= ttlMs) continue;
3423
+ this.sessions.delete(sessionId);
3424
+ this.lastUsedAt.delete(sessionId);
3425
+ this.peerBindings.delete(sessionId);
3426
+ this.clientNames.delete(sessionId);
3427
+ this.clientVersions.delete(sessionId);
3428
+ this.sessionIps.delete(sessionId);
3429
+ this.tokenBindings.delete(sessionId);
3430
+ this.managerSessions.delete(sessionId);
3431
+ this.suppressedAuto.delete(sessionId);
3432
+ removed.push(sessionId);
3433
+ }
3434
+ return removed;
3435
+ }
2226
3436
  /** Session → client IP (remote mode: the admin roster shows where a peer
2227
3437
  * connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
2228
3438
  sessionIps = /* @__PURE__ */ new Map();
@@ -2241,6 +3451,19 @@ var SessionRegistry = class {
2241
3451
  if (sessionId === void 0) return void 0;
2242
3452
  return this.tokenBindings.get(sessionId);
2243
3453
  }
3454
+ /** Sessions that presented the loopback manager token (`--manager-token`).
3455
+ * Distinct from a token-table identity: loopback mode has no token table, so
3456
+ * the manager secret is the only thing separating "the operator's GUI" from
3457
+ * "any other local MCP client". */
3458
+ managerSessions = /* @__PURE__ */ new Set();
3459
+ /** Remember that this session proved it holds the manager token. */
3460
+ markManagerSession(sessionId) {
3461
+ this.managerSessions.add(sessionId);
3462
+ }
3463
+ /** Did this session present the manager token? */
3464
+ isManagerSession(sessionId) {
3465
+ return sessionId !== void 0 && this.managerSessions.has(sessionId);
3466
+ }
2244
3467
  /** Mark a session's SSE stream open (server→client channel alive). */
2245
3468
  markSseOpen(sessionId) {
2246
3469
  this.liveStreams.add(sessionId);
@@ -2299,7 +3522,7 @@ var SessionRegistry = class {
2299
3522
  var McpStreamableHttpServer = class {
2300
3523
  constructor(tools, info, registry, log2 = () => {
2301
3524
  }, onInitialize = () => {
2302
- }, authenticate, adminApi, heartbeatMs) {
3525
+ }, authenticate, adminApi, heartbeatMs, originPolicy, managerToken, sessionTtlMs = DEFAULT_SESSION_TTL_MS) {
2303
3526
  this.tools = tools;
2304
3527
  this.info = info;
2305
3528
  this.registry = registry;
@@ -2308,29 +3531,54 @@ var McpStreamableHttpServer = class {
2308
3531
  this.authenticate = authenticate;
2309
3532
  this.adminApi = adminApi;
2310
3533
  this.heartbeatMs = heartbeatMs;
3534
+ this.originPolicy = originPolicy;
3535
+ this.managerToken = managerToken;
3536
+ this.sessionTtlMs = sessionTtlMs;
2311
3537
  }
2312
3538
  sseStreams = /* @__PURE__ */ new Map();
2313
3539
  heartbeatTimer;
3540
+ sessionSweepTimer;
3541
+ /** Does this request carry the loopback manager token? */
3542
+ presentedManagerToken(req) {
3543
+ if (this.managerToken === void 0) return false;
3544
+ const header = headerValue(req.headers.authorization);
3545
+ if (header === void 0) return false;
3546
+ const match = /^Bearer\s+(.+)$/i.exec(header);
3547
+ return match !== null && secretEquals(match[1].trim(), this.managerToken);
3548
+ }
2314
3549
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
2315
3550
  attach(server, path2) {
2316
3551
  this.heartbeatTimer = setInterval(() => {
2317
3552
  if (this.sseStreams.size === 0) return;
2318
- this.notifyAll("notifications/message", {
3553
+ this.notifyAll(SSE_METHOD, {
2319
3554
  level: "debug",
2320
3555
  logger: "bridge",
2321
3556
  data: { event: "heartbeat", ts: Date.now() }
2322
3557
  });
2323
3558
  }, this.heartbeatMs ?? HEARTBEAT_MS);
2324
3559
  this.heartbeatTimer.unref?.();
3560
+ if (this.sessionTtlMs > 0) {
3561
+ const interval = Math.min(6e4, Math.max(1e3, this.sessionTtlMs / 4));
3562
+ this.sessionSweepTimer = setInterval(() => {
3563
+ const removed = this.registry.sweep(Date.now(), this.sessionTtlMs);
3564
+ if (removed.length > 0) this.log(`sessions reaped: ${removed.length}`);
3565
+ }, interval);
3566
+ this.sessionSweepTimer.unref?.();
3567
+ }
2325
3568
  server.on("request", (req, res) => {
2326
3569
  const url = new URL(req.url ?? "/", "http://localhost");
3570
+ if (!this.originAllowed(req)) {
3571
+ res.writeHead(403, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8", "Mcp-Protocol-Version": messageProtocolVersion(req) });
3572
+ res.end(JSON.stringify({ error: `origin not allowed: ${headerValue(req.headers.origin) ?? ""}` }));
3573
+ return;
3574
+ }
2327
3575
  if (url.pathname === ADMIN_PATH && req.method === "GET") {
2328
3576
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
2329
3577
  res.end(adminPage());
2330
3578
  return;
2331
3579
  }
2332
3580
  if (url.pathname === "/healthz") {
2333
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...corsHeaders() });
3581
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...this.corsFor(req) });
2334
3582
  res.end(JSON.stringify({ ok: true, ts: Date.now() }));
2335
3583
  return;
2336
3584
  }
@@ -2351,15 +3599,16 @@ var McpStreamableHttpServer = class {
2351
3599
  return;
2352
3600
  }
2353
3601
  if (req.method === "OPTIONS") {
2354
- res.writeHead(204, corsHeaders()).end();
3602
+ res.writeHead(204, this.corsFor(req)).end();
2355
3603
  return;
2356
3604
  }
2357
- res.writeHead(405, corsHeaders()).end();
3605
+ res.writeHead(405, { ...this.corsFor(req), "Mcp-Protocol-Version": messageProtocolVersion(req) }).end();
2358
3606
  });
2359
3607
  }
2360
3608
  /** Close all open SSE streams (called on server shutdown). */
2361
3609
  close() {
2362
3610
  if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
3611
+ if (this.sessionSweepTimer !== void 0) clearInterval(this.sessionSweepTimer);
2363
3612
  for (const stream of this.sseStreams.values()) stream.end();
2364
3613
  this.sseStreams.clear();
2365
3614
  }
@@ -2387,33 +3636,107 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2387
3636
  }
2388
3637
  }
2389
3638
  }
3639
+ /**
3640
+ * Cross-origin gate. A request is allowed when:
3641
+ * - it carries no `Origin` (curl, MCP clients, the desktop app — never browsers), or
3642
+ * - the origin is explicitly listed (`--allow-origins`, or 'all'), or
3643
+ * - the origin is a loopback hostname (the bundled admin page, local tooling), or
3644
+ * - it presents a valid bearer token AND its origin host matches the `Host`
3645
+ * header (the admin page behind a TLS reverse proxy, where the hub cannot
3646
+ * know its own public origin).
3647
+ *
3648
+ * The last clause is deliberately credential-gated, not identity-gated: with
3649
+ * `--allow-join` the transport mints an anonymous identity for a request that
3650
+ * carries nothing at all, so testing only `authenticate(req) !== undefined`
3651
+ * would re-open this branch to exactly the rebinding page it exists to stop.
3652
+ * A real `Authorization` header is required as well.
3653
+ */
3654
+ originAllowed(req) {
3655
+ const origin = headerValue(req.headers.origin);
3656
+ if (origin === void 0) return true;
3657
+ const policy = this.originPolicy;
3658
+ if (policy?.allow === "all") return true;
3659
+ if (policy?.allow?.has(origin) === true) return true;
3660
+ if (origin.includes(",") || /\s/.test(origin)) return false;
3661
+ const originHost = originHostname(origin);
3662
+ if (originHost === void 0) return false;
3663
+ if (isLoopbackHost(originHost)) return true;
3664
+ const identity = this.authenticate?.(req);
3665
+ if (identity === void 0 || identity.anonymous === true) return false;
3666
+ const host = headerValue(req.headers.host);
3667
+ return host !== void 0 && hostHeaderName(host) === originHost;
3668
+ }
3669
+ /** CORS headers for one request: echoes the origin only when it is allowed. */
3670
+ corsFor(req) {
3671
+ if (req === void 0) return corsHeaders();
3672
+ const origin = headerValue(req.headers.origin);
3673
+ return corsHeaders(origin !== void 0 && this.originAllowed(req) ? origin : void 0);
3674
+ }
2390
3675
  handleGet(req, res) {
2391
- if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
2392
- res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
2393
- res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
3676
+ let streamIdentity;
3677
+ if (this.authenticate !== void 0) {
3678
+ streamIdentity = this.authenticate(req);
3679
+ if (streamIdentity === void 0) {
3680
+ res.writeHead(401, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8", "Mcp-Protocol-Version": messageProtocolVersion(req) });
3681
+ res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
3682
+ return;
3683
+ }
3684
+ const existing = this.registry.sessionIdFor(req);
3685
+ const bound = existing === void 0 ? void 0 : this.registry.tokenFor(existing);
3686
+ const bothAnonymous = bound?.anonymous === true && streamIdentity.anonymous === true;
3687
+ if (bound !== void 0 && !bothAnonymous && (bound.peer !== streamIdentity.peer || bound.role !== streamIdentity.role)) {
3688
+ this.unauthorized(req, res, null, "token does not match this session");
3689
+ return;
3690
+ }
3691
+ }
3692
+ if (this.protocolVersionRejected(req, res)) return;
3693
+ const streamSession = this.registry.sessionIdFor(req);
3694
+ if (streamSession !== void 0 && !this.registry.hasSession(streamSession)) {
3695
+ this.jsonRpcError(req, res, null, -32001, "session not found \u2014 send a new initialize request", void 0, 404);
2394
3696
  return;
2395
3697
  }
2396
- const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
2397
- const identity = this.authenticate?.(req);
2398
- if (identity !== void 0) this.registry.bindToken(sessionId, identity);
3698
+ this.registry.touch(streamSession);
3699
+ const sessionId = this.registry.ensureSession(streamSession);
3700
+ if (this.presentedManagerToken(req)) this.registry.markManagerSession(sessionId);
3701
+ if (streamIdentity !== void 0) this.registry.bindToken(sessionId, streamIdentity);
2399
3702
  res.writeHead(200, {
2400
- ...corsHeaders(),
3703
+ ...this.corsFor(req),
2401
3704
  "Content-Type": "text/event-stream; charset=utf-8",
2402
3705
  "Cache-Control": "no-cache",
2403
3706
  Connection: "keep-alive",
3707
+ // nginx (and anything else speaking X-Accel) buffers proxied responses by
3708
+ // default, which breaks SSE completely: events sit in the proxy until its
3709
+ // buffer fills or the stream ends, so a client never sees a push. This header
3710
+ // is the in-band way to turn that off — it needs no proxy configuration, and
3711
+ // a proxy that ignores it is unaffected.
3712
+ "X-Accel-Buffering": "no",
3713
+ // Same header the POST path sets: the client should see the version the
3714
+ // server is speaking on every 200, not only on JSON responses.
3715
+ "Mcp-Protocol-Version": messageProtocolVersion(req),
2404
3716
  ...req.headers["mcp-session-id"] ? {} : { "Mcp-Session-Id": sessionId }
2405
3717
  });
2406
3718
  res.write(": connected\n\n");
2407
3719
  this.sseStreams.set(sessionId, res);
2408
3720
  this.registry.markSseOpen(sessionId);
2409
3721
  req.on("close", () => {
2410
- this.sseStreams.delete(sessionId);
2411
- this.registry.markSseClosed(sessionId);
3722
+ if (this.sseStreams.get(sessionId) === res) this.sseStreams.delete(sessionId);
3723
+ if (!this.sseStreams.has(sessionId)) this.registry.markSseClosed(sessionId);
2412
3724
  });
2413
3725
  }
2414
3726
  /** Reject a request without a valid token (remote auth mode). */
2415
- unauthorized(res, id, why) {
2416
- res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
3727
+ unauthorized(req, res, id, why) {
3728
+ res.writeHead(401, {
3729
+ ...this.corsFor(req),
3730
+ "Content-Type": "application/json; charset=utf-8",
3731
+ // MCP authorization spec: a 401 MUST carry WWW-Authenticate. The hub does not
3732
+ // implement OAuth (authorization is OPTIONAL in MCP), so there is no
3733
+ // resource_metadata to advertise — the bare scheme is still required.
3734
+ "WWW-Authenticate": "Bearer",
3735
+ // Every response names the protocol version the server is speaking, errors
3736
+ // included: a client that only sees 401s still needs to know what it is
3737
+ // talking to before it can decide whether to retry.
3738
+ "Mcp-Protocol-Version": messageProtocolVersion(req)
3739
+ });
2417
3740
  res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
2418
3741
  }
2419
3742
  /** Management API for the bundled admin page: issue/revoke tokens and list
@@ -2421,7 +3744,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2421
3744
  * and must resolve to a MANAGER role — an agent token gets 403. */
2422
3745
  async handleAdminApi(req, res, url) {
2423
3746
  const respond = (status, body) => {
2424
- res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
3747
+ res.writeHead(status, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8" });
2425
3748
  res.end(JSON.stringify(body));
2426
3749
  };
2427
3750
  const identity = this.authenticate?.(req);
@@ -2470,49 +3793,57 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2470
3793
  if (this.authenticate !== void 0) {
2471
3794
  authIdentity = this.authenticate(req);
2472
3795
  if (authIdentity === void 0) {
2473
- this.unauthorized(res, null, "missing or invalid bearer token");
3796
+ this.unauthorized(req, res, null, "missing or invalid bearer token");
2474
3797
  return;
2475
3798
  }
2476
3799
  const existingSession = this.registry.sessionIdFor(req);
2477
3800
  const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
2478
3801
  const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
2479
3802
  if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
2480
- this.unauthorized(res, null, "token does not match this session");
3803
+ this.unauthorized(req, res, null, "token does not match this session");
2481
3804
  return;
2482
3805
  }
2483
3806
  if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
2484
3807
  this.registry.bindToken(existingSession, authIdentity);
2485
3808
  }
2486
3809
  }
3810
+ if (this.protocolVersionRejected(req, res)) return;
3811
+ const presentedSession = this.registry.sessionIdFor(req);
3812
+ if (presentedSession !== void 0 && !this.registry.hasSession(presentedSession)) {
3813
+ this.jsonRpcError(req, res, null, -32001, "session not found \u2014 send a new initialize request", void 0, 404);
3814
+ return;
3815
+ }
3816
+ this.registry.touch(presentedSession);
2487
3817
  let body;
2488
3818
  try {
2489
3819
  body = await readBody(req);
2490
3820
  } catch (error) {
2491
- this.jsonRpcError(res, null, -32700, `parse error: ${error.message}`);
3821
+ this.jsonRpcError(req, res, null, -32700, `parse error: ${error.message}`);
2492
3822
  return;
2493
3823
  }
2494
3824
  let message;
2495
3825
  try {
2496
3826
  message = JSON.parse(body.toString("utf8"));
2497
3827
  } catch {
2498
- this.jsonRpcError(res, null, -32700, "parse error: invalid JSON");
3828
+ this.jsonRpcError(req, res, null, -32700, "parse error: invalid JSON");
2499
3829
  return;
2500
3830
  }
2501
3831
  if (typeof message !== "object" || message === null || message.method === void 0) {
2502
- this.jsonRpcError(res, message?.id ?? null, -32600, "invalid request");
3832
+ this.jsonRpcError(req, res, message?.id ?? null, -32600, "invalid request");
2503
3833
  return;
2504
3834
  }
2505
3835
  const id = message.id ?? null;
2506
3836
  const sessionId = this.registry.sessionIdFor(req);
2507
3837
  if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
3838
+ if (sessionId !== void 0 && this.presentedManagerToken(req)) this.registry.markManagerSession(sessionId);
2508
3839
  if (id === null) {
2509
- res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
3840
+ res.writeHead(202, { ...this.corsFor(req), "Content-Length": "0" }).end();
2510
3841
  return;
2511
3842
  }
2512
3843
  try {
2513
3844
  const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
2514
3845
  res.writeHead(200, {
2515
- ...corsHeaders(),
3846
+ ...this.corsFor(req),
2516
3847
  "Content-Type": "application/json; charset=utf-8",
2517
3848
  "Mcp-Protocol-Version": messageProtocolVersion(req),
2518
3849
  ...extraHeaders
@@ -2520,7 +3851,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2520
3851
  res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
2521
3852
  } catch (error) {
2522
3853
  const code = error.code ?? -32603;
2523
- this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
3854
+ this.jsonRpcError(req, res, id, code, error.message);
2524
3855
  }
2525
3856
  }
2526
3857
  async dispatch(message, sessionId, authIdentity) {
@@ -2583,11 +3914,27 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2583
3914
  throw rpcError(-32601, `method not found: ${method}`);
2584
3915
  }
2585
3916
  }
2586
- jsonRpcError(res, id, code, message, data, protocolVersion) {
2587
- res.writeHead(code === -32700 || code === -32600 ? 400 : 200, {
2588
- ...corsHeaders(),
3917
+ /** Spec MUST: a request carrying an `MCP-Protocol-Version` this server does
3918
+ * not implement gets 400. Absent is fine — an absent header means the client is
3919
+ * on the newest revision this server knows, which is what responses advertise
3920
+ * (`messageProtocolVersion` returns LATEST_VERSION) —
3921
+ * but *present and unknown* is a client speaking a revision whose rules this
3922
+ * server would otherwise be guessing at. Writes the response itself and
3923
+ * reports whether it did. */
3924
+ protocolVersionRejected(req, res) {
3925
+ const presented = req.headers["mcp-protocol-version"];
3926
+ if (presented === void 0) return false;
3927
+ if (typeof presented === "string" && SUPPORTED_VERSIONS.includes(presented)) return false;
3928
+ this.jsonRpcError(req, res, null, -32600, `unsupported MCP-Protocol-Version: ${String(presented).slice(0, 64)} (supported: ${SUPPORTED_VERSIONS.join(", ")})`, void 0, 400);
3929
+ return true;
3930
+ }
3931
+ jsonRpcError(req, res, id, code, message, data, status) {
3932
+ const code400 = code === -32700 || code === -32600;
3933
+ const httpStatus = status ?? (code400 ? 400 : 200);
3934
+ res.writeHead(httpStatus, {
3935
+ ...this.corsFor(req),
2589
3936
  "Content-Type": "application/json; charset=utf-8",
2590
- ...protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}
3937
+ "Mcp-Protocol-Version": messageProtocolVersion(req)
2591
3938
  });
2592
3939
  res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, ...data !== void 0 ? { data } : {} } }));
2593
3940
  }
@@ -2598,7 +3945,8 @@ function rpcError(code, message) {
2598
3945
  return error;
2599
3946
  }
2600
3947
  function messageProtocolVersion(req) {
2601
- return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
3948
+ const presented = req.headers["mcp-protocol-version"];
3949
+ return typeof presented === "string" && SUPPORTED_VERSIONS.includes(presented) ? presented : LATEST_VERSION;
2602
3950
  }
2603
3951
  function clientIp(req) {
2604
3952
  const forwarded = req.headers["x-forwarded-for"];
@@ -2608,10 +3956,44 @@ function clientIp(req) {
2608
3956
  }
2609
3957
  return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
2610
3958
  }
2611
- function corsHeaders() {
3959
+ function headerValue(value) {
3960
+ const raw = Array.isArray(value) ? value[0] : value;
3961
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
3962
+ }
3963
+ function secretEquals(a, b) {
3964
+ const left = Buffer.from(a, "utf8");
3965
+ const right = Buffer.from(b, "utf8");
3966
+ return left.length === right.length && timingSafeEqual(left, right);
3967
+ }
3968
+ function originHostname(origin) {
3969
+ try {
3970
+ return new URL(origin).hostname.toLowerCase();
3971
+ } catch {
3972
+ return void 0;
3973
+ }
3974
+ }
3975
+ function hostHeaderName(host) {
3976
+ try {
3977
+ return new URL(`http://${host}`).hostname.toLowerCase();
3978
+ } catch {
3979
+ return void 0;
3980
+ }
3981
+ }
3982
+ function isLoopbackHost(host) {
3983
+ const name = host.trim().toLowerCase().replace(/\.$/, "");
3984
+ if (name === "localhost" || name === "::1" || name === "[::1]") return true;
3985
+ const quad = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
3986
+ return quad !== null && quad.slice(1).every((octet) => Number(octet) <= 255);
3987
+ }
3988
+ var ALLOWED_REQUEST_HEADERS = "content-type, mcp-session-id, mcp-protocol-version, authorization";
3989
+ function corsHeaders(allowOrigin) {
2612
3990
  return {
2613
- "Access-Control-Allow-Origin": "*",
2614
- "Access-Control-Allow-Headers": "*",
3991
+ Vary: "Origin",
3992
+ ...allowOrigin !== void 0 ? { "Access-Control-Allow-Origin": allowOrigin } : {},
3993
+ "Access-Control-Allow-Headers": ALLOWED_REQUEST_HEADERS,
3994
+ // Exposed so a cross-origin browser client can actually READ the version the
3995
+ // server is speaking: without this the header is set but invisible to script.
3996
+ "Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version",
2615
3997
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
2616
3998
  };
2617
3999
  }
@@ -2637,7 +4019,7 @@ function readBody(req) {
2637
4019
  import { DatabaseSync } from "node:sqlite";
2638
4020
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2639
4021
  function messageToRow(message) {
2640
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
4022
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts, message.hop ?? null, message.seq ?? null, message.expiresAt ?? null];
2641
4023
  }
2642
4024
  function rowToMessage(row) {
2643
4025
  const id = row.id;
@@ -2657,6 +4039,15 @@ function rowToMessage(row) {
2657
4039
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2658
4040
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2659
4041
  ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
4042
+ // Chain depth: rows written before the hop column restore without it
4043
+ // (treated as a chain origin by importHistory — never as a bogus depth).
4044
+ ...typeof row.hop === "number" && Number.isFinite(row.hop) && row.hop > 0 ? { hop: row.hop } : {},
4045
+ // Cursors: rows written before the mseq column restore without it, and are
4046
+ // then reachable only without a `since` cursor (never mistaken for seq 0).
4047
+ ...typeof row.mseq === "number" && Number.isFinite(row.mseq) && row.mseq > 0 ? { seq: row.mseq } : {},
4048
+ // Deadline: restored so a message that expired while the hub was down is
4049
+ // dropped rather than delivered late.
4050
+ ...typeof row.expires_at === "number" && Number.isFinite(row.expires_at) && row.expires_at > 0 ? { expiresAt: row.expires_at } : {},
2660
4051
  ts
2661
4052
  };
2662
4053
  }
@@ -2667,15 +4058,15 @@ var SQLiteStateStore = class {
2667
4058
  this.db.exec("PRAGMA journal_mode = WAL");
2668
4059
  this.db.exec(`
2669
4060
  CREATE TABLE IF NOT EXISTS profiles (
2670
- id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
4061
+ id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER, card TEXT
2671
4062
  );
2672
4063
  CREATE TABLE IF NOT EXISTS messages (
2673
4064
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2674
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
4065
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER, mseq INTEGER, expires_at INTEGER
2675
4066
  );
2676
4067
  CREATE TABLE IF NOT EXISTS mailboxes (
2677
4068
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2678
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
4069
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER, mseq INTEGER, expires_at INTEGER
2679
4070
  );
2680
4071
  CREATE TABLE IF NOT EXISTS groups (
2681
4072
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
@@ -2685,11 +4076,17 @@ var SQLiteStateStore = class {
2685
4076
  );
2686
4077
  `);
2687
4078
  for (const table of ["messages", "mailboxes"]) {
2688
- try {
2689
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2690
- } catch {
4079
+ for (const column of ["thread_id TEXT", "hop INTEGER", "mseq INTEGER", "expires_at INTEGER"]) {
4080
+ try {
4081
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column}`);
4082
+ } catch {
4083
+ }
2691
4084
  }
2692
4085
  }
4086
+ try {
4087
+ this.db.exec("ALTER TABLE profiles ADD COLUMN card TEXT");
4088
+ } catch {
4089
+ }
2693
4090
  log2.info(`sqlite state open: ${file}`);
2694
4091
  }
2695
4092
  db;
@@ -2701,24 +4098,24 @@ var SQLiteStateStore = class {
2701
4098
  /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2702
4099
  flush(snapshot) {
2703
4100
  const insert = this.db.prepare(
2704
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
4101
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop, mseq, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2705
4102
  );
2706
4103
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2707
4104
  insert.run(...messageToRow(message));
2708
4105
  }
2709
4106
  this.db.exec("DELETE FROM mailboxes");
2710
4107
  const insertMail = this.db.prepare(
2711
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
4108
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop, mseq, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2712
4109
  );
2713
4110
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2714
4111
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
2715
4112
  }
2716
4113
  this.db.exec("DELETE FROM profiles");
2717
4114
  const insertProfile = this.db.prepare(
2718
- "INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
4115
+ "INSERT INTO profiles (id, alias, client_name, client_version, registered_at, card) VALUES (?, ?, ?, ?, ?, ?)"
2719
4116
  );
2720
4117
  for (const profile of snapshot.profiles) {
2721
- insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
4118
+ insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt, profile.card === void 0 ? null : JSON.stringify(profile.card));
2722
4119
  }
2723
4120
  this.db.exec("DELETE FROM groups");
2724
4121
  const insertGroup = this.db.prepare(
@@ -2750,7 +4147,9 @@ var SQLiteStateStore = class {
2750
4147
  ...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
2751
4148
  ...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
2752
4149
  ...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
2753
- registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
4150
+ registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now(),
4151
+ // A corrupt card row must not take the whole roster down.
4152
+ ...cardField(row.card)
2754
4153
  });
2755
4154
  }
2756
4155
  const messages = [];
@@ -2798,10 +4197,19 @@ var SQLiteStateStore = class {
2798
4197
  this.db.close();
2799
4198
  }
2800
4199
  };
4200
+ function cardField(raw) {
4201
+ if (typeof raw !== "string" || raw === "") return {};
4202
+ try {
4203
+ const parsed = JSON.parse(raw);
4204
+ return typeof parsed === "object" && parsed !== null ? { card: parsed } : {};
4205
+ } catch {
4206
+ return {};
4207
+ }
4208
+ }
2801
4209
 
2802
4210
  // src/index.ts
2803
4211
  var SERVER_NAME = "agent-comm-hub";
2804
- var SERVER_VERSION = "0.9.0";
4212
+ var SERVER_VERSION = "0.10.0";
2805
4213
  var DEFAULT_HOST = "127.0.0.1";
2806
4214
  var DEFAULT_PORT = 18764;
2807
4215
  var DEFAULT_PATH = "/mcp";
@@ -2852,23 +4260,60 @@ function writeRosterFile(file, profiles) {
2852
4260
  writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2853
4261
  renameSync2(tmp, file);
2854
4262
  }
4263
+ function readManagerTokenFile(stateFile) {
4264
+ if (stateFile === void 0) return void 0;
4265
+ const file = join(dirname2(stateFile), "manager.token");
4266
+ try {
4267
+ const raw = readFileSync3(file, "utf8").trim();
4268
+ return raw === "" ? void 0 : raw;
4269
+ } catch (error) {
4270
+ if (error.code === "ENOENT") return void 0;
4271
+ throw new Error(`cannot read the manager token ${file}: ${error.message}`);
4272
+ }
4273
+ }
2855
4274
  function startHub(config = {}, log2 = console) {
2856
4275
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
2857
4276
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
4277
+ const managerToken = resolved.managerToken ?? readManagerTokenFile(resolved.stateFile);
4278
+ if (!isLoopbackHost(resolved.host) && resolved.authTokens === void 0 && resolved.insecure !== true) {
4279
+ throw new Error(
4280
+ `refusing to bind ${resolved.host} without --auth-tokens: the hub has no authentication, so every client that can reach the port would be trusted. Pass --auth-tokens <file> (recommended), or --insecure to accept the risk on an already-isolated network.`
4281
+ );
4282
+ }
4283
+ if (managerToken === void 0 && resolved.managerPeers !== "all" && !isLoopbackHost(resolved.host)) {
4284
+ log2.warn(
4285
+ `manager peer ids are claimable by any client that can reach ${resolved.host}:${resolved.port} \u2014 set --manager-token (or run with --auth-tokens) to require a credential`
4286
+ );
4287
+ }
4288
+ if (managerToken !== void 0 && resolved.authTokens !== void 0) {
4289
+ log2.warn(
4290
+ "both --manager-token and --auth-tokens are set: the token table owns the Authorization header, so the manager token is ignored. Grant manager rights with `auth add <peer> --role manager` instead."
4291
+ );
4292
+ }
2858
4293
  let mcp;
2859
4294
  let dbStore;
2860
4295
  const pushRoster = () => {
2861
- mcp.notifyAll("notifications/message", {
4296
+ mcp.notifyAll(SSE_METHOD, {
2862
4297
  level: "info",
2863
4298
  logger: "bridge",
2864
- data: { event: "peers_changed", peers: rosterFor(hub, registry) }
4299
+ data: { event: SSE_EVENT_PEERS_CHANGED, peers: rosterFor(hub, registry) }
2865
4300
  });
2866
4301
  };
4302
+ const streamCursor = /* @__PURE__ */ new Map();
4303
+ const oneStreamPer = (key, candidates) => {
4304
+ if (candidates.length === 0) return [];
4305
+ const streamed = registry.liveSessions();
4306
+ const open = candidates.filter((id) => streamed.has(id));
4307
+ const pool = open.length > 0 ? open : candidates;
4308
+ const at = (streamCursor.get(key) ?? 0) % pool.length;
4309
+ streamCursor.set(key, (at + 1) % pool.length);
4310
+ return [pool[at]];
4311
+ };
2867
4312
  const pushQueued = (message, target) => {
2868
- mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
4313
+ mcp.notify(oneStreamPer(`peer:${target}`, registry.sessionsForPeer(target)), SSE_METHOD, {
2869
4314
  level: "info",
2870
4315
  logger: "bridge",
2871
- data: { event: "message", message: present(message) }
4316
+ data: { event: SSE_EVENT_MESSAGE, message: present(message) }
2872
4317
  });
2873
4318
  };
2874
4319
  const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
@@ -2877,15 +4322,15 @@ function startHub(config = {}, log2 = console) {
2877
4322
  for (const sessionId of registry.liveSessions()) {
2878
4323
  const peer = registry.peerFor(sessionId);
2879
4324
  if (peer === void 0) continue;
2880
- if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
4325
+ if (isManagerCaller(registry, sessionId, peer, { managerPeers: managerSet, managerTokenRequired: managerToken !== void 0 })) {
2881
4326
  managers.push(sessionId);
2882
4327
  }
2883
4328
  }
2884
4329
  if (managers.length > 0) {
2885
- mcp.notify(managers, "notifications/message", {
4330
+ mcp.notify(oneStreamPer("relay", managers), SSE_METHOD, {
2886
4331
  level: "info",
2887
4332
  logger: "bridge",
2888
- data: { event: "message", message: present(message) }
4333
+ data: { event: SSE_EVENT_MESSAGE, message: present(message) }
2889
4334
  });
2890
4335
  }
2891
4336
  };
@@ -2919,12 +4364,64 @@ function startHub(config = {}, log2 = console) {
2919
4364
  }, 500);
2920
4365
  saveTimer.unref?.();
2921
4366
  };
4367
+ let runnerRegistry;
4368
+ const runnerQueue = /* @__PURE__ */ new Map();
4369
+ const taskPayload = (content) => {
4370
+ try {
4371
+ const parsed = JSON.parse(content);
4372
+ return {
4373
+ prompt: typeof parsed.prompt === "string" ? parsed.prompt : content,
4374
+ ...typeof parsed.context === "string" ? { context: parsed.context } : {},
4375
+ ...typeof parsed.deliverable === "string" ? { deliverable: parsed.deliverable } : {},
4376
+ ...typeof parsed.threadId === "string" ? { threadId: parsed.threadId } : {}
4377
+ };
4378
+ } catch {
4379
+ return { prompt: content };
4380
+ }
4381
+ };
4382
+ function runTask(message, target) {
4383
+ const runner = runnerRegistry?.get(target);
4384
+ if (runner === void 0) return;
4385
+ const previous = runnerQueue.get(target) ?? Promise.resolve();
4386
+ const next = previous.then(async () => {
4387
+ const payload = taskPayload(message.content);
4388
+ hub.noteEvent("runner_started", { peer: target, detail: { taskId: message.id, runner: runner.name } });
4389
+ try {
4390
+ const result = await runner.run({ taskId: message.id, peer: target, ...payload });
4391
+ hub.noteEvent("runner_finished", { peer: target, detail: { taskId: message.id, runner: runner.name, status: result.status } });
4392
+ hub.ackForRunner(target, message.id, { status: result.status, note: result.note });
4393
+ log2.info(`runner ${runner.name}: task ${message.id} for '${target}' -> ${result.status}`);
4394
+ } catch (error) {
4395
+ hub.noteEvent("runner_finished", { peer: target, detail: { taskId: message.id, runner: runner.name, status: "failed", error: error.message } });
4396
+ try {
4397
+ hub.ackForRunner(target, message.id, { status: "failed", note: `runner error: ${error.message}` });
4398
+ } catch {
4399
+ }
4400
+ log2.warn(`runner ${runner.name} errored on task ${message.id}: ${error.message}`);
4401
+ }
4402
+ });
4403
+ runnerQueue.set(target, next.catch(() => {
4404
+ }));
4405
+ }
2922
4406
  const hub = new AgentHub({
2923
4407
  maxQueue: resolved.maxQueue,
2924
4408
  historyLimit: resolved.historyLimit,
2925
4409
  waitTimeoutMs: resolved.waitTimeoutMs,
2926
4410
  connectedWindowMs: resolved.connectedWindowMs,
2927
4411
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
4412
+ parkedTtlMs: resolved.parkedTtlMs,
4413
+ leaseTtlMs: resolved.leaseTtlMs,
4414
+ maxAttempts: resolved.maxAttempts,
4415
+ ratePerMin: resolved.ratePerMin,
4416
+ // A parked mailbox that expires is data loss: say so, and make the store
4417
+ // re-snapshot so the dropped rows cannot resurrect on the next restart.
4418
+ onMailboxExpired: (peerId, dropped) => {
4419
+ log2.warn(`parked mailbox for '${peerId}' expired after ${resolved.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS}ms: ${dropped} message(s) dropped`);
4420
+ if (dbStore !== void 0) {
4421
+ dbDirty = true;
4422
+ scheduleSave();
4423
+ }
4424
+ },
2928
4425
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
2929
4426
  // Remote persistence: buffer every history append; re-snapshot when a
2930
4427
  // mailbox is consumed so drained messages cannot resurrect on restart.
@@ -2980,6 +4477,17 @@ function startHub(config = {}, log2 = console) {
2980
4477
  } catch (error) {
2981
4478
  log2.warn(`message notification failed: ${error.message}`);
2982
4479
  }
4480
+ },
4481
+ // A task that reached a mailbox has nobody polling for it. If this peer has
4482
+ // a runner configured, wake it — that is the whole point of a runner.
4483
+ // `runnerRegistry` is filled in below, once the herdr adapter exists; this
4484
+ // hook only ever fires after the hub is serving.
4485
+ onTaskQueued: (message, target) => {
4486
+ try {
4487
+ runTask(message, target);
4488
+ } catch (error) {
4489
+ log2.warn(`runner dispatch failed for '${target}': ${error.message}`);
4490
+ }
2983
4491
  }
2984
4492
  });
2985
4493
  const registry = new SessionRegistry();
@@ -3032,6 +4540,8 @@ function startHub(config = {}, log2 = console) {
3032
4540
  socketPath: resolved.herdrSocketPath,
3033
4541
  sendRequest: resolved.herdrSendRequest
3034
4542
  });
4543
+ runnerRegistry = new RunnerRegistry(resolved.runners ?? [], { herdr });
4544
+ if (runnerRegistry.size > 0) log2.info(`runners configured for: ${runnerRegistry.peers().join(", ")}`);
3035
4545
  let auth;
3036
4546
  let authReload;
3037
4547
  if (resolved.authTokens !== void 0) {
@@ -3057,7 +4567,8 @@ function startHub(config = {}, log2 = console) {
3057
4567
  waitTimeoutMs: resolved.waitTimeoutMs,
3058
4568
  herdr,
3059
4569
  herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
3060
- managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
4570
+ managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers),
4571
+ managerTokenRequired: managerToken !== void 0
3061
4572
  }),
3062
4573
  { name: SERVER_NAME, version: SERVER_VERSION },
3063
4574
  registry,
@@ -3084,12 +4595,15 @@ function startHub(config = {}, log2 = console) {
3084
4595
  },
3085
4596
  auth,
3086
4597
  authTokensAdmin(resolved.authTokens, authReload),
3087
- resolved.heartbeatMs
4598
+ resolved.heartbeatMs,
4599
+ resolved.allowOrigins === void 0 || resolved.allowOrigins.length === 0 ? void 0 : { allow: new Set(resolved.allowOrigins) },
4600
+ managerToken,
4601
+ resolved.sessionTtlMs
3088
4602
  );
3089
4603
  const server = createServer();
3090
4604
  mcp.attach(server, resolved.path);
3091
4605
  server.on("error", (error) => log2.warn(`hub http server error: ${error.message}`));
3092
- server.listen(resolved.port, resolved.host, () => {
4606
+ server.listen(resolved.port, resolved.host.trim(), () => {
3093
4607
  log2.info(`agent-comm-hub listening on http://${resolved.host}:${resolved.port}${resolved.path}`);
3094
4608
  });
3095
4609
  return {
@@ -3123,20 +4637,30 @@ function startHub(config = {}, log2 = console) {
3123
4637
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3124
4638
  import { existsSync as existsSync3 } from "node:fs";
3125
4639
  import { homedir as homedir2 } from "node:os";
3126
- import { dirname as dirname4, join as join2 } from "node:path";
4640
+ import { dirname as dirname4, join as join3 } from "node:path";
3127
4641
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3128
4642
 
3129
4643
  // src/discover.ts
3130
4644
  import { execFileSync } from "node:child_process";
3131
4645
  import { existsSync as existsSync2, readdirSync, accessSync, readFileSync as readFileSync4, constants as fsConstants } from "node:fs";
3132
4646
  import { homedir } from "node:os";
3133
- import { dirname as dirname3, join, sep } from "node:path";
4647
+ import { dirname as dirname3, join as join2, sep } from "node:path";
3134
4648
  import { fileURLToPath } from "node:url";
4649
+ var CREDENTIAL_FILES = [
4650
+ "~/.claude.json",
4651
+ "~/.dsh/.credentials.yaml",
4652
+ "~/.dsh/settings.yaml",
4653
+ "~/.codex/auth.json",
4654
+ "~/.qwen/mcp-oauth-tokens.json"
4655
+ ];
4656
+ function configsForPlatform(agent, platform) {
4657
+ return agent.configs.filter((config) => config.os === void 0 || config.os.includes(platform));
4658
+ }
3135
4659
  function registryFile() {
3136
- return join(dirname3(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
4660
+ return join2(dirname3(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
3137
4661
  }
3138
4662
  function expandHome(file, home) {
3139
- return file.startsWith("~/") ? join(home, file.slice(2)) : file;
4663
+ return file.startsWith("~/") ? join2(home, file.slice(2)) : file;
3140
4664
  }
3141
4665
  function expandConfigFile(file, home) {
3142
4666
  const expanded = expandHome(file, home);
@@ -3146,7 +4670,10 @@ function expandConfigFile(file, home) {
3146
4670
  const suffix = expanded.slice(star + 1);
3147
4671
  const base = prefix.slice(0, prefix.lastIndexOf(sep));
3148
4672
  if (!existsSync2(base)) return [];
3149
- return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
4673
+ if (suffix.startsWith(sep) === false) {
4674
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(suffix)).map((entry) => join2(base, entry.name));
4675
+ }
4676
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(base, entry.name, suffix));
3150
4677
  }
3151
4678
  function validateRegistry(registry) {
3152
4679
  if (!Array.isArray(registry.agents)) throw new Error('registry: missing "agents" array');
@@ -3159,6 +4686,17 @@ function validateRegistry(registry) {
3159
4686
  ids.add(agent.id);
3160
4687
  if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
3161
4688
  if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
4689
+ if (agent.probePaths !== void 0) {
4690
+ if (!Array.isArray(agent.probePaths)) throw new Error(`registry: ${agent.id}: probePaths must be an array`);
4691
+ for (const probePath of agent.probePaths) {
4692
+ if (typeof probePath !== "string" || !probePath.startsWith("~/")) {
4693
+ throw new Error(`registry: ${agent.id}: probePath must be '~'-relative`);
4694
+ }
4695
+ if (probePath.split("/").includes("..")) {
4696
+ throw new Error(`registry: ${agent.id}: probePath must not contain '..'`);
4697
+ }
4698
+ }
4699
+ }
3162
4700
  if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
3163
4701
  for (const config of agent.configs) {
3164
4702
  if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
@@ -3170,14 +4708,23 @@ function validateRegistry(registry) {
3170
4708
  if (config.file.split("*").length > 2) {
3171
4709
  throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
3172
4710
  }
3173
- if (!["json", "toml", "dsh"].includes(config.strategy)) {
4711
+ if (!["json", "toml", "dsh", "yaml-array"].includes(config.strategy)) {
3174
4712
  throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
3175
4713
  }
3176
- if (config.strategy === "json" && (typeof config.section !== "string" || config.entry === null)) {
3177
- throw new Error(`registry: ${agent.id}: json strategy needs a section and an entry`);
4714
+ if (config.os !== void 0 && (!Array.isArray(config.os) || config.os.length === 0 || config.os.some((entry) => !["win32", "darwin", "linux"].includes(entry)))) {
4715
+ throw new Error(`registry: ${agent.id}: invalid config os list`);
3178
4716
  }
3179
- if (config.strategy !== "json" && config.entry !== null) {
3180
- throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
4717
+ if (CREDENTIAL_FILES.includes(config.file)) {
4718
+ throw new Error(
4719
+ `registry: ${agent.id}: ${config.file} holds credentials and must never be written \u2014 point the record at a separate MCP config file, or leave the agent manual (AGENTS.md invariant 6)`
4720
+ );
4721
+ }
4722
+ const keyed = config.strategy === "json" || config.strategy === "yaml-array";
4723
+ if (keyed && (typeof config.section !== "string" || config.entry === null)) {
4724
+ throw new Error(`registry: ${agent.id}: ${config.strategy} strategy needs a section and an entry`);
4725
+ }
4726
+ if (!keyed && config.entry !== null) {
4727
+ throw new Error(`registry: ${agent.id}: only json/yaml-array strategy may carry an entry`);
3181
4728
  }
3182
4729
  }
3183
4730
  if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
@@ -3202,7 +4749,7 @@ function commandOnPath(command, pathEnv, pathext, platform) {
3202
4749
  for (const dir of dirs) {
3203
4750
  const base = dir === "" ? "." : dir;
3204
4751
  for (const ext of extensions) {
3205
- const candidate = join(base, command + ext);
4752
+ const candidate = join2(base, command + ext);
3206
4753
  try {
3207
4754
  accessSync(candidate, fsConstants.X_OK);
3208
4755
  return true;
@@ -3217,7 +4764,7 @@ function readNpmNames(root) {
3217
4764
  for (const entry of readdirSync(root, { withFileTypes: true })) {
3218
4765
  if (!entry.isDirectory()) continue;
3219
4766
  if (entry.name.startsWith("@")) {
3220
- const scopeDir = join(root, entry.name);
4767
+ const scopeDir = join2(root, entry.name);
3221
4768
  for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
3222
4769
  if (sub.isDirectory()) names.push(sub.name);
3223
4770
  }
@@ -3237,13 +4784,13 @@ function npmGlobalRoot(platform) {
3237
4784
  }
3238
4785
  function npmFallbackRoots(home, platform) {
3239
4786
  if (platform === "win32") {
3240
- const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
3241
- return [join(appData, "npm", "node_modules")];
4787
+ const appData = process.env.APPDATA ?? join2(home, "AppData", "Roaming");
4788
+ return [join2(appData, "npm", "node_modules")];
3242
4789
  }
3243
4790
  return [
3244
4791
  "/usr/local/lib/node_modules",
3245
4792
  "/usr/lib/node_modules",
3246
- ...existsSync2(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
4793
+ ...existsSync2(join2(home, ".nvm", "versions", "node")) ? readdirSync(join2(home, ".nvm", "versions", "node")).map((dir) => join2(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
3247
4794
  ];
3248
4795
  }
3249
4796
  function discover(registry, options = {}) {
@@ -3273,21 +4820,25 @@ function discover(registry, options = {}) {
3273
4820
  return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
3274
4821
  let source = "none";
3275
4822
  const configFiles = [];
3276
- for (const config of agent.configs) {
4823
+ for (const config of configsForPlatform(agent, platform)) {
3277
4824
  for (const file of expandConfigFile(config.file, home)) {
3278
- if (existsSync2(file)) {
3279
- configFiles.push(file);
3280
- if (source === "none") source = "config";
3281
- }
4825
+ if (existsSync2(file)) configFiles.push(file);
3282
4826
  }
3283
4827
  }
3284
- if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
4828
+ let installed = false;
4829
+ if (agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
3285
4830
  source = "path";
3286
- }
3287
- if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
4831
+ installed = true;
4832
+ } else if (npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
3288
4833
  source = "npm";
4834
+ installed = true;
4835
+ } else if ((agent.probePaths ?? []).some((probePath) => existsSync2(expandHome(probePath, home)))) {
4836
+ source = "probe-path";
4837
+ installed = true;
3289
4838
  }
3290
- return { id: agent.id, source, configFiles, present: source !== "none" };
4839
+ const configOnly = !installed && configFiles.length > 0;
4840
+ if (!installed && configOnly) source = "config";
4841
+ return { id: agent.id, source, configFiles, present: installed, configOnly };
3291
4842
  });
3292
4843
  }
3293
4844
  function runDiscover(options = {}) {
@@ -3306,7 +4857,7 @@ function runDiscover(options = {}) {
3306
4857
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
3307
4858
  var DEFAULT_SERVER = "agent-hub";
3308
4859
  function defaultSkillSrc() {
3309
- return join2(dirname4(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
4860
+ return join3(dirname4(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
3310
4861
  }
3311
4862
  function stamp() {
3312
4863
  const now = /* @__PURE__ */ new Date();
@@ -3342,6 +4893,19 @@ function resolveSection(doc, section) {
3342
4893
  }
3343
4894
  return node;
3344
4895
  }
4896
+ function sameJson(a, b) {
4897
+ if (a === b) return true;
4898
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
4899
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
4900
+ if (Array.isArray(a) && Array.isArray(b)) {
4901
+ return a.length === b.length && a.every((item, index) => sameJson(item, b[index]));
4902
+ }
4903
+ const left = a;
4904
+ const right = b;
4905
+ const leftKeys = Object.keys(left);
4906
+ if (leftKeys.length !== Object.keys(right).length) return false;
4907
+ return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameJson(left[key], right[key]));
4908
+ }
3345
4909
  async function mergeJsonServer(file, section, entry, opts) {
3346
4910
  if (!existsSync3(file)) return "skipped";
3347
4911
  const doc = await readJson(file);
@@ -3355,10 +4919,7 @@ async function mergeJsonServer(file, section, entry, opts) {
3355
4919
  await writeJsonNoBom(file, doc);
3356
4920
  return "removed";
3357
4921
  }
3358
- if (has) {
3359
- const existing = servers[opts.serverName];
3360
- if (existing?.url === opts.url) return "unchanged";
3361
- }
4922
+ if (has && sameJson(servers[opts.serverName], entry)) return "unchanged";
3362
4923
  servers[opts.serverName] = entry;
3363
4924
  await backup(file);
3364
4925
  await writeJsonNoBom(file, doc);
@@ -3369,19 +4930,25 @@ async function mergeTomlSection(file, opts) {
3369
4930
  const text = await readFile(file, "utf8");
3370
4931
  const marker = `[mcp_servers.${opts.serverName}]`;
3371
4932
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
4933
+ const sectionRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\][^\\r\\n]*(\\r?\\n(?!\\[).*)*(\\r?\\n)?`, "m");
3372
4934
  if (opts.remove) {
3373
4935
  if (!markerRe.test(text)) return "absent";
3374
- const cleaned = text.replace(new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\][^\\r\\n]*(\\r?\\n(?!\\[).*)*(\\r?\\n)?`, "m"), "");
4936
+ const cleaned = text.replace(sectionRe, "");
3375
4937
  await backup(file);
3376
4938
  await writeFile(file, cleaned, "utf8");
3377
4939
  return "removed";
3378
4940
  }
3379
- if (markerRe.test(text)) return "unchanged";
3380
4941
  const block = `
3381
4942
  ${marker}
3382
4943
  type = "streamable-http"
3383
4944
  url = "${opts.url}"
3384
4945
  `;
4946
+ if (markerRe.test(text)) {
4947
+ if (text.includes(block.trimStart())) return "unchanged";
4948
+ await backup(file);
4949
+ await writeFile(file, text.replace(sectionRe, "").trimEnd() + block, "utf8");
4950
+ return "changed";
4951
+ }
3385
4952
  await backup(file);
3386
4953
  await writeFile(file, text.trimEnd() + block, "utf8");
3387
4954
  return "changed";
@@ -3444,6 +5011,82 @@ async function mergeDshPatch(file, opts) {
3444
5011
  await writeFile(file, withoutHubEntries().trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
3445
5012
  return "changed";
3446
5013
  }
5014
+ async function mergeYamlArrayServer(file, opts) {
5015
+ if (!existsSync3(file)) return "skipped";
5016
+ const text = await readFile(file, "utf8");
5017
+ const lines = text.split("\n");
5018
+ const keyIndex = lines.findIndex((line) => /^mcpServers:\s*(#.*)?$/.test(line));
5019
+ const inlineKey = lines.findIndex((line) => /^mcpServers:\s*\S/.test(line));
5020
+ if (keyIndex === -1) {
5021
+ if (inlineKey !== -1) return "unrecognized";
5022
+ }
5023
+ const blockEnd = (from) => {
5024
+ for (let i = from + 1; i < lines.length; i++) {
5025
+ const line = lines[i];
5026
+ if (line.trim() === "" || line.startsWith("#")) continue;
5027
+ if (!/^\s/.test(line)) return i;
5028
+ }
5029
+ return lines.length;
5030
+ };
5031
+ const items = (from, to2) => {
5032
+ const starts = [];
5033
+ for (let i = from; i < to2; i++) {
5034
+ if (/^\s*-\s/.test(lines[i])) starts.push(i);
5035
+ }
5036
+ return starts.map((start, k) => ({ start, end: k + 1 < starts.length ? starts[k + 1] : to2 }));
5037
+ };
5038
+ const isOurs = (item) => lines.slice(item.start, item.end).some((line) => new RegExp(`^\\s*-?\\s*name:\\s*${opts.serverName}\\s*$`).test(line));
5039
+ if (keyIndex === -1) {
5040
+ if (opts.remove) return "absent";
5041
+ await backup(file);
5042
+ const block = `mcpServers:
5043
+ ${yamlArrayItem(opts.serverName, opts.url, opts.entry, " ")}`;
5044
+ await writeFile(file, `${text.replace(/\n*$/, "")}
5045
+ ${block}
5046
+ `, "utf8");
5047
+ return "changed";
5048
+ }
5049
+ const to = blockEnd(keyIndex);
5050
+ const ours = items(keyIndex + 1, to).filter(isOurs);
5051
+ const all = items(keyIndex + 1, to);
5052
+ const bodyLines = lines.slice(keyIndex + 1, to).filter((line) => line.trim() !== "" && !line.trim().startsWith("#"));
5053
+ if (all.length === 0 && bodyLines.length > 0) return "unrecognized";
5054
+ const indent = ours.length > 0 ? lines[ours[0].start].match(/^(\s*)/)?.[1] ?? " " : all.length > 0 ? lines[all[0].start].match(/^(\s*)/)?.[1] ?? " " : " ";
5055
+ if (opts.remove) {
5056
+ if (ours.length === 0) return "absent";
5057
+ await backup(file);
5058
+ const out2 = lines.slice();
5059
+ for (const item of ours.reverse()) out2.splice(item.start, item.end - item.start);
5060
+ await writeFile(file, out2.join("\n"), "utf8");
5061
+ return "removed";
5062
+ }
5063
+ const wanted = yamlArrayItem(opts.serverName, opts.url, opts.entry, indent);
5064
+ if (ours.length === 1 && lines.slice(ours[0].start, ours[0].end).join("\n").trimEnd() === wanted.trimEnd()) {
5065
+ return "unchanged";
5066
+ }
5067
+ await backup(file);
5068
+ const out = lines.slice();
5069
+ const first = ours[0]?.start ?? to;
5070
+ for (const item of ours.reverse()) out.splice(item.start, item.end - item.start);
5071
+ const insertAt = ours.length > 0 ? first : to;
5072
+ out.splice(insertAt, 0, ...wanted.replace(/\n$/, "").split("\n"));
5073
+ await writeFile(file, out.join("\n"), "utf8");
5074
+ return "changed";
5075
+ }
5076
+ function yamlArrayItem(serverName, url, entry, indent) {
5077
+ const substituted = substitute2(entry, { url, serverName });
5078
+ const keys = Object.entries(substituted);
5079
+ const inner = `${indent} `;
5080
+ const head = `${indent}- name: ${serverName}
5081
+ `;
5082
+ return head + keys.map(([key, value]) => `${inner}${key}: ${yamlScalar(value)}
5083
+ `).join("");
5084
+ }
5085
+ function yamlScalar(value) {
5086
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
5087
+ const text = String(value ?? "");
5088
+ return /^[A-Za-z0-9._/:@-]+$/.test(text) ? text : JSON.stringify(text);
5089
+ }
3447
5090
  async function syncSkill(skillDir, skillSrc, remove, log2) {
3448
5091
  if (remove) {
3449
5092
  if (existsSync3(skillDir)) {
@@ -3458,14 +5101,14 @@ async function syncSkill(skillDir, skillSrc, remove, log2) {
3458
5101
  return;
3459
5102
  }
3460
5103
  await mkdir(skillDir, { recursive: true });
3461
- await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
3462
- log2(` skill -> ${join2(skillDir, "SKILL.md")}`);
5104
+ await copyFile(skillSrc, join3(skillDir, "SKILL.md"));
5105
+ log2(` skill -> ${join3(skillDir, "SKILL.md")}`);
3463
5106
  }
3464
5107
  async function rmRecursive(dir) {
3465
5108
  const { rm } = await import("node:fs/promises");
3466
5109
  await rm(dir, { recursive: true, force: true });
3467
5110
  }
3468
- function substitute(entry, values) {
5111
+ function substitute2(entry, values) {
3469
5112
  const out = {};
3470
5113
  for (const [key, value] of Object.entries(entry)) {
3471
5114
  if (typeof value === "string") {
@@ -3483,13 +5126,14 @@ async function runSetup(options = {}) {
3483
5126
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
3484
5127
  const remove = options.remove === true;
3485
5128
  const log2 = options.log ?? ((message) => console.log(message));
3486
- const summary = { done: [], unchanged: [], skipped: [], errors: [] };
5129
+ const summary = { done: [], unchanged: [], skipped: [], refused: [], errors: [] };
3487
5130
  const record = (status, label, file) => {
3488
5131
  if (status === "changed" || status === "removed") summary.done.push(`${label}: ${file}`);
3489
5132
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
3490
5133
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
5134
+ else if (status === "unrecognized") summary.refused.push(`${label}: ${file}`);
3491
5135
  };
3492
- const registry = loadRegistry();
5136
+ const registry = loadRegistry(options.registryFile);
3493
5137
  const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
3494
5138
  const only = options.agent;
3495
5139
  let targetAgents = [];
@@ -3502,16 +5146,43 @@ async function runSetup(options = {}) {
3502
5146
  log2(`configure only: ${only}`);
3503
5147
  }
3504
5148
  } else {
3505
- targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
5149
+ const installed = found.filter((agent) => agent.present);
5150
+ targetAgents = installed.map((agent) => registry.agents.find((entry) => entry.id === agent.id));
5151
+ const configOnly = found.filter((agent) => agent.configOnly);
5152
+ if (options.force === true) {
5153
+ for (const agent of configOnly) {
5154
+ const entry = registry.agents.find((candidate) => candidate.id === agent.id);
5155
+ if (entry !== void 0 && !targetAgents.includes(entry)) targetAgents.push(entry);
5156
+ }
5157
+ } else if (configOnly.length > 0) {
5158
+ for (const agent of configOnly) {
5159
+ summary.skipped.push(`${agent.id}: config-only (${agent.configFiles[0]}) \u2014 agent not installed; use --force or --agent ${agent.id}`);
5160
+ }
5161
+ log2(`skipped (config-only, agent not installed): ${configOnly.map((agent) => agent.id).join(", ")} \u2014 use --force to configure anyway`);
5162
+ }
3506
5163
  const present2 = targetAgents.map((agent) => agent.id);
3507
5164
  log2(`discovered: ${present2.length > 0 ? present2.join(", ") : "none"}`);
3508
5165
  }
3509
5166
  for (const agent of targetAgents) {
3510
- for (const config of agent.configs) {
5167
+ for (const config of configsForPlatform(agent, process.platform)) {
3511
5168
  const nameFor = config.serverName ?? agent.serverName ?? serverName;
3512
5169
  for (const file of expandConfigFile(config.file, home)) {
3513
5170
  try {
3514
- const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName: nameFor }), { serverName: nameFor, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName: nameFor, url, remove }) : await mergeDshPatch(file, { serverName: nameFor, url, remove });
5171
+ let status;
5172
+ switch (config.strategy) {
5173
+ case "json":
5174
+ status = await mergeJsonServer(file, config.section, substitute2(config.entry, { url, serverName: nameFor }), { serverName: nameFor, url, remove });
5175
+ break;
5176
+ case "toml":
5177
+ status = await mergeTomlSection(file, { serverName: nameFor, url, remove });
5178
+ break;
5179
+ case "yaml-array":
5180
+ status = await mergeYamlArrayServer(file, { serverName: nameFor, url, remove, entry: config.entry ?? {} });
5181
+ break;
5182
+ case "dsh":
5183
+ status = await mergeDshPatch(file, { serverName: nameFor, url, remove });
5184
+ break;
5185
+ }
3515
5186
  record(status, agent.id, file);
3516
5187
  } catch (error) {
3517
5188
  summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
@@ -3520,9 +5191,10 @@ async function runSetup(options = {}) {
3520
5191
  }
3521
5192
  }
3522
5193
  }
3523
- const skillDirs = [join2(home, ".agents", "skills", serverName)];
5194
+ const skillDirs = [];
5195
+ if (targetAgents.length > 0 || remove) skillDirs.push(join3(home, ".agents", "skills", serverName));
3524
5196
  for (const agent of targetAgents) {
3525
- if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
5197
+ if (agent.skill !== null) skillDirs.push(join3(expandHome(agent.skill, home), serverName));
3526
5198
  }
3527
5199
  for (const dir of skillDirs) {
3528
5200
  try {
@@ -3542,7 +5214,7 @@ import { execFileSync as execFileSync2 } from "node:child_process";
3542
5214
  import { request } from "node:http";
3543
5215
  import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
3544
5216
  import { homedir as homedir3 } from "node:os";
3545
- import { dirname as dirname5, join as join3 } from "node:path";
5217
+ import { dirname as dirname5, join as join4 } from "node:path";
3546
5218
  import { fileURLToPath as fileURLToPath3 } from "node:url";
3547
5219
  async function runStatus(options = {}) {
3548
5220
  const host = options.host ?? "127.0.0.1";
@@ -3595,7 +5267,7 @@ async function runStatus(options = {}) {
3595
5267
  function runUpdate() {
3596
5268
  const messages = [];
3597
5269
  try {
3598
- const pkgFile = join3(dirname5(fileURLToPath3(import.meta.url)), "..", "package.json");
5270
+ const pkgFile = join4(dirname5(fileURLToPath3(import.meta.url)), "..", "package.json");
3599
5271
  const before = JSON.parse(readFileSync5(pkgFile, "utf8")).version ?? "?";
3600
5272
  messages.push(`current version: ${before}`);
3601
5273
  const script = [
@@ -3640,9 +5312,9 @@ function runService(options) {
3640
5312
  const platform = options.platform ?? process.platform;
3641
5313
  try {
3642
5314
  if (platform === "win32") {
3643
- const appData = process.env.APPDATA ?? join3(homedir3(), "AppData", "Roaming");
3644
- const launcherDir = join3(appData, "agent-comm-hub");
3645
- const vbs = join3(launcherDir, "agent-comm-hub.vbs");
5315
+ const appData = process.env.APPDATA ?? join4(homedir3(), "AppData", "Roaming");
5316
+ const launcherDir = join4(appData, "agent-comm-hub");
5317
+ const vbs = join4(launcherDir, "agent-comm-hub.vbs");
3646
5318
  const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
3647
5319
  const valueName = "agent-comm-hub";
3648
5320
  if (options.action === "install") {
@@ -3675,8 +5347,8 @@ function runService(options) {
3675
5347
  return { ok: true, messages };
3676
5348
  }
3677
5349
  if (platform === "linux") {
3678
- const unitDir = join3(homedir3(), ".config", "systemd", "user");
3679
- const unitFile = join3(unitDir, "agent-comm-hub.service");
5350
+ const unitDir = join4(homedir3(), ".config", "systemd", "user");
5351
+ const unitFile = join4(unitDir, "agent-comm-hub.service");
3680
5352
  if (options.action === "install") {
3681
5353
  const unit = `[Unit]
3682
5354
  Description=agent-comm-hub (multi-peer MCP hub)
@@ -3712,10 +5384,10 @@ WantedBy=default.target
3712
5384
  return { ok: true, messages };
3713
5385
  }
3714
5386
  if (platform === "darwin") {
3715
- const launchAgentsDir = join3(homedir3(), "Library", "LaunchAgents");
5387
+ const launchAgentsDir = join4(homedir3(), "Library", "LaunchAgents");
3716
5388
  const label = "com.agent-comm-hub";
3717
- const plist = join3(launchAgentsDir, `${label}.plist`);
3718
- const logFile = join3(homedir3(), "Library", "Logs", "agent-comm-hub.log");
5389
+ const plist = join4(launchAgentsDir, `${label}.plist`);
5390
+ const logFile = join4(homedir3(), "Library", "Logs", "agent-comm-hub.log");
3719
5391
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
3720
5392
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
3721
5393
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -3784,29 +5456,46 @@ WantedBy=default.target
3784
5456
 
3785
5457
  // src/cli.ts
3786
5458
  import { homedir as homedir4 } from "node:os";
3787
- import { join as join4 } from "node:path";
3788
- var STRING_FLAGS = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
3789
- var DEFAULT_STATE_FILE = join4(homedir4(), ".agent-comm-hub", "roster.json");
3790
- var DEFAULT_DB = join4(homedir4(), ".agent-comm-hub", "state.db");
3791
- var DEFAULT_AUTH_FILE = join4(homedir4(), ".agent-comm-hub", "tokens.json");
5459
+ import { join as join5 } from "node:path";
5460
+ var STRING_FLAGS = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token", "--allow-origins", "--manager-token"]);
5461
+ var DEFAULT_STATE_FILE = join5(homedir4(), ".agent-comm-hub", "roster.json");
5462
+ var DEFAULT_DB = join5(homedir4(), ".agent-comm-hub", "state.db");
5463
+ var DEFAULT_AUTH_FILE = join5(homedir4(), ".agent-comm-hub", "tokens.json");
5464
+ function parseRunners(value) {
5465
+ if (value === void 0) return void 0;
5466
+ let parsed;
5467
+ try {
5468
+ parsed = JSON.parse(value);
5469
+ } catch (error) {
5470
+ throw new Error(`--runner expects JSON: ${error.message}`);
5471
+ }
5472
+ const list = Array.isArray(parsed) ? parsed : [parsed];
5473
+ for (const [index, entry] of list.entries()) {
5474
+ if (typeof entry !== "object" || entry === null) throw new Error(`--runner[${index}] must be an object`);
5475
+ validateRunnerConfig(entry, index);
5476
+ }
5477
+ return list;
5478
+ }
3792
5479
  function parseArgs(argv) {
3793
5480
  const args = {};
3794
- 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", "--heartbeat-ms"]);
3795
- const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token"]);
5481
+ const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--parked-ttl-ms", "--lease-ttl-ms", "--max-attempts", "--herdr-timeout-ms", "--heartbeat-ms", "--session-ttl-ms", "--rate-per-min"]);
5482
+ const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin", "--manager-peers", "--state-file", "--auth-tokens", "--db", "--file", "--role", "--owner", "--token", "--allow-origins", "--manager-token", "--runner"]);
3796
5483
  for (let i = 0; i < argv.length; i++) {
3797
5484
  const flag = argv[i];
3798
5485
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
3799
5486
  args[flag] = true;
3800
5487
  continue;
3801
5488
  }
3802
- if (flag === "--remove" || flag === "--dry-run" || flag === "--reveal" || flag === "--allow-join") {
5489
+ if (flag === "--remove" || flag === "--dry-run" || flag === "--reveal" || flag === "--allow-join" || flag === "--insecure" || flag === "--force") {
3803
5490
  args[flag] = true;
3804
5491
  continue;
3805
5492
  }
3806
5493
  const value = argv[i + 1];
3807
5494
  if (numeric.has(flag)) {
3808
5495
  const parsed = Number(value);
3809
- if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${flag} expects a positive number, got '${value}'`);
5496
+ const zeroMeansOff = flag === "--peer-idle-timeout-ms" || flag === "--parked-ttl-ms" || flag === "--max-attempts" || flag === "--session-ttl-ms" || flag === "--rate-per-min";
5497
+ const bad = !Number.isFinite(parsed) || (zeroMeansOff ? parsed < 0 : parsed <= 0);
5498
+ if (bad) throw new Error(`${flag} expects ${zeroMeansOff ? "a number >= 0" : "a positive number"}, got '${value}'`);
3810
5499
  args[flag] = parsed;
3811
5500
  i++;
3812
5501
  } else if (string.has(flag)) {
@@ -3853,13 +5542,30 @@ Hub options:
3853
5542
  --wait-timeout-ms <n> Long-poll ceiling for bridge_wait (default 60000)
3854
5543
  --default-wait-ms <n> bridge_wait default budget (default 30000)
3855
5544
  --connected-window-ms <n> Peer counts as active within this window (default 30000)
3856
- --peer-idle-timeout-ms <n> Auto-unregister idle peers after this; 0 disables (default 600000)
5545
+ --peer-idle-timeout-ms <n> Park (not delete) idle peers after this; 0 disables the
5546
+ GC entirely (default 600000)
5547
+ --parked-ttl-ms <n> Drop a parked mailbox after this; 0 disables expiry
5548
+ (default 604800000 = 7 days)
5549
+ --session-ttl-ms <n> Forget a session unused this long with no live SSE
5550
+ stream (default 1800000 = 30 min); 0 disables
5551
+ --lease-ttl-ms <n> How long a manual-ack lease lasts before the message
5552
+ is offered again (default 60000)
5553
+ --rate-per-min <n> Per-peer mailbox rate limit (messages per minute);
5554
+ 0 (default) is unlimited. Guards the recipient against
5555
+ a broadcast storm; excess is counted as droppedRate
5556
+ --max-attempts <n> Hand-outs before a leased message is dead-lettered
5557
+ instead of requeued; 0 disables (default 5)
3857
5558
  --herdr-bin <path> herdr CLI binary for bridge_agent_* control tools
3858
5559
  (default herdr, resolved via PATH)
3859
5560
  --herdr-timeout-ms <n> Default cap for one herdr call in ms (default 30000)
5561
+ --runner <json> Executor for a peer that is not polling: one JSON
5562
+ object or an array, e.g.
5563
+ '{"peer":"codex","kind":"cli","command":"codex","args":["exec","{prompt}"]}'
5564
+ or {"peer":"mcode","kind":"herdr"}. Off by default.
3860
5565
  --manager-peers <ids> Comma-separated peer ids allowed to manage the
3861
5566
  roster (rename others / kick), or "all".
3862
- (default agent-hub-cli \u2014 the desktop GUI identity)
5567
+ (default agent-hub-cli,hub-admin \u2014 the desktop
5568
+ GUI and the web admin console identities)
3863
5569
  --state-file <path> Persist the peer roster (aliases, client info) to
3864
5570
  this JSON file so identities survive restarts
3865
5571
  (default ~/.agent-comm-hub/roster.json; "off"
@@ -3884,6 +5590,20 @@ Auth options (agent-comm-hub auth <action>):
3884
5590
  the public internet). Each gets a unique peer id
3885
5591
  + source IP shown in the admin roster for you to
3886
5592
  claim (rename) or issue a token to.
5593
+ --allow-origins <list> Comma-separated browser origins allowed to call the
5594
+ MCP endpoint (e.g. https://hub.example.com). Requests
5595
+ without an Origin header and loopback origins are
5596
+ always allowed; anything else gets 403. This is the
5597
+ DNS-rebinding guard the MCP spec requires.
5598
+ --manager-token <secret> Require this secret (Authorization: Bearer <secret>)
5599
+ to claim a peer id listed in --manager-peers.
5600
+ Without it, <state-file dir>/manager.token is read
5601
+ when present; with neither, manager ids stay a
5602
+ convention that any local client can claim.
5603
+ --insecure Allow --host to bind a non-loopback address with NO
5604
+ token table. Without this, such a start is refused:
5605
+ the hub would accept unauthenticated clients from
5606
+ the whole network.
3887
5607
  --reveal auth list: include the plaintext tokens
3888
5608
 
3889
5609
  Setup options:
@@ -3891,6 +5611,8 @@ Setup options:
3891
5611
  --server-name <name> Config key (default agent-hub)
3892
5612
  --agent <id> Only configure one registry agent (e.g. codex)
3893
5613
  --remove Uninstall instead of install
5614
+ --force Also configure agents whose config file exists but
5615
+ which are not installed (leftover dotfiles)
3894
5616
 
3895
5617
  -h, --help Show this help
3896
5618
  -V, --version Show version
@@ -3916,6 +5638,7 @@ try {
3916
5638
  serverName: args2["--server-name"],
3917
5639
  agent: args2["--agent"],
3918
5640
  remove: args2["--remove"] === true,
5641
+ force: args2["--force"] === true,
3919
5642
  log: (message) => log.info(message)
3920
5643
  });
3921
5644
  process.exit(0);
@@ -4073,6 +5796,12 @@ try {
4073
5796
  defaultWaitMs: args["--default-wait-ms"],
4074
5797
  connectedWindowMs: args["--connected-window-ms"],
4075
5798
  peerIdleTimeoutMs: args["--peer-idle-timeout-ms"],
5799
+ parkedTtlMs: args["--parked-ttl-ms"],
5800
+ runners: parseRunners(args["--runner"]),
5801
+ sessionTtlMs: args["--session-ttl-ms"],
5802
+ leaseTtlMs: args["--lease-ttl-ms"],
5803
+ ratePerMin: args["--rate-per-min"],
5804
+ maxAttempts: args["--max-attempts"],
4076
5805
  herdrBin: args["--herdr-bin"],
4077
5806
  herdrTimeoutMs: args["--herdr-timeout-ms"],
4078
5807
  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 !== ""),
@@ -4080,7 +5809,10 @@ try {
4080
5809
  authTokens: args["--auth-tokens"] === void 0 ? void 0 : String(args["--auth-tokens"]),
4081
5810
  allowJoin: args["--allow-join"] === true,
4082
5811
  db: args["--db"] === void 0 ? DEFAULT_DB : args["--db"] === "off" ? void 0 : String(args["--db"]),
4083
- heartbeatMs: args["--heartbeat-ms"]
5812
+ heartbeatMs: args["--heartbeat-ms"],
5813
+ allowOrigins: args["--allow-origins"] === void 0 ? void 0 : String(args["--allow-origins"]).split(",").map((o) => o.trim()).filter((o) => o !== ""),
5814
+ managerToken: args["--manager-token"] === void 0 ? void 0 : String(args["--manager-token"]),
5815
+ insecure: args["--insecure"] === true
4084
5816
  }, log);
4085
5817
  const shutdown = () => {
4086
5818
  log.info("agent-comm-hub shutting down");