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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createServer } from "node:http";
3
3
  import { randomBytes as randomBytes2 } from "node:crypto";
4
4
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
5
- import { dirname as dirname2 } from "node:path";
5
+ import { dirname as dirname2, join } from "node:path";
6
6
 
7
7
  // src/herdr-ctl.ts
8
8
  import { execFile } from "node:child_process";
@@ -415,6 +415,104 @@ function socketSend(socketPath, method, params) {
415
415
  });
416
416
  }
417
417
 
418
+ // src/runner.ts
419
+ import { execFile as execFile2 } from "node:child_process";
420
+ var DEFAULT_RUN_TIMEOUT_MS = 12e4;
421
+ var MAX_RUN_NOTE_CHARS = 4e3;
422
+ function validateRunnerConfig(config, index) {
423
+ const where = `runners[${index}]`;
424
+ if (typeof config.peer !== "string" || config.peer === "") throw new Error(`${where}: peer must be a non-empty string`);
425
+ if (config.kind !== "cli" && config.kind !== "herdr") throw new Error(`${where}: kind must be 'cli' or 'herdr'`);
426
+ if (config.kind === "cli") {
427
+ if (typeof config.command !== "string" || config.command === "") throw new Error(`${where}: cli needs a command`);
428
+ if (config.args !== void 0 && (!Array.isArray(config.args) || config.args.some((arg) => typeof arg !== "string"))) {
429
+ throw new Error(`${where}: args must be an array of strings`);
430
+ }
431
+ }
432
+ if (config.timeoutMs !== void 0 && (!Number.isFinite(config.timeoutMs) || config.timeoutMs <= 0)) {
433
+ throw new Error(`${where}: timeoutMs must be a positive number`);
434
+ }
435
+ }
436
+ var truncate = (text) => text.length > MAX_RUN_NOTE_CHARS ? `${text.slice(0, MAX_RUN_NOTE_CHARS)}\u2026 (truncated)` : text;
437
+ function substitute(arg, request) {
438
+ const values = {
439
+ prompt: request.prompt,
440
+ context: request.context,
441
+ deliverable: request.deliverable,
442
+ taskId: request.taskId,
443
+ peer: request.peer
444
+ };
445
+ return arg.replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
446
+ }
447
+ function cliRunner(config) {
448
+ const command = config.command;
449
+ const args = config.args ?? [];
450
+ const timeout = config.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
451
+ return {
452
+ name: `cli:${command}`,
453
+ run: (request) => new Promise((resolve) => {
454
+ execFile2(
455
+ command,
456
+ args.map((arg) => substitute(arg, request)),
457
+ // No shell, a bounded buffer, and a hard timeout: a hung runner must
458
+ // not hold the task open forever.
459
+ { timeout, maxBuffer: 4 * 1024 * 1024, windowsHide: true },
460
+ (error, stdout, stderr) => {
461
+ const out = stdout.trim();
462
+ if (error !== null) {
463
+ const detail = stderr.trim() !== "" ? stderr.trim() : error.message;
464
+ resolve({ status: "failed", note: truncate(`runner failed: ${detail}${out !== "" ? `
465
+ ${out}` : ""}`) });
466
+ return;
467
+ }
468
+ resolve({ status: "done", note: truncate(out === "" ? "(runner produced no output)" : out) });
469
+ }
470
+ );
471
+ })
472
+ };
473
+ }
474
+ function herdrRunner(config, ctl) {
475
+ const target = config.target ?? config.peer;
476
+ const timeout = config.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
477
+ return {
478
+ name: `herdr:${target}`,
479
+ run: async (request) => {
480
+ try {
481
+ const text = [request.prompt, request.context].filter((part) => part !== void 0 && part !== "").join("\n\n");
482
+ await ctl.promptSmart(target, text, { wait: true, timeoutMs: timeout });
483
+ const read = await ctl.readSmart(target, { lines: 200 });
484
+ return { status: "done", note: truncate(read.text.trim() === "" ? "(no output read back)" : read.text) };
485
+ } catch (error) {
486
+ return { status: "failed", note: truncate(`herdr runner failed: ${error.message}`) };
487
+ }
488
+ }
489
+ };
490
+ }
491
+ function createRunner(config, deps = {}) {
492
+ if (config.kind === "cli") return cliRunner(config);
493
+ if (deps.herdr === void 0) throw new Error(`runner for '${config.peer}': kind 'herdr' needs herdr to be enabled`);
494
+ return herdrRunner(config, deps.herdr);
495
+ }
496
+ var RunnerRegistry = class {
497
+ runners = /* @__PURE__ */ new Map();
498
+ constructor(configs = [], deps = {}) {
499
+ configs.forEach((config, index) => {
500
+ validateRunnerConfig(config, index);
501
+ this.runners.set(config.peer, createRunner(config, deps));
502
+ });
503
+ }
504
+ get(peer) {
505
+ return this.runners.get(peer);
506
+ }
507
+ /** Peer ids that have a runner (for startup logging). */
508
+ peers() {
509
+ return [...this.runners.keys()];
510
+ }
511
+ get size() {
512
+ return this.runners.size;
513
+ }
514
+ };
515
+
418
516
  // src/hub.ts
419
517
  import { randomUUID } from "node:crypto";
420
518
 
@@ -422,8 +520,34 @@ import { randomUUID } from "node:crypto";
422
520
  var KINDS = ["chat", "task", "notice", "ack"];
423
521
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
424
522
  var BROADCAST = "all";
523
+ var HUB_PEER = "hub";
524
+ var SSE_METHOD = "notifications/message";
525
+ var SSE_EVENT_PEERS_CHANGED = "peers_changed";
526
+ var SSE_EVENT_MESSAGE = "message";
425
527
  var MAX_HOP = 5;
426
- var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout"];
528
+ var MAX_REPLY_CHAIN = 50;
529
+ function a2aState(status) {
530
+ switch (status) {
531
+ case "pending":
532
+ case "accepted":
533
+ return "submitted";
534
+ case "working":
535
+ return "working";
536
+ case "input-required":
537
+ return "input-required";
538
+ case "done":
539
+ return "completed";
540
+ case "canceled":
541
+ return "canceled";
542
+ case "failed":
543
+ case "timeout":
544
+ return "failed";
545
+ case "rejected":
546
+ return "rejected";
547
+ }
548
+ }
549
+ var CLIENT_ACK_STATUSES = ["accepted", "working", "input-required", "rejected", "done", "failed"];
550
+ var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout", "canceled"];
427
551
  function ackToTaskStatus(ack) {
428
552
  return ack;
429
553
  }
@@ -438,6 +562,9 @@ function nextTaskStatus(current, ack) {
438
562
  case "working":
439
563
  if (next === "accepted") return "working";
440
564
  return next;
565
+ case "input-required":
566
+ if (next === "accepted") return "input-required";
567
+ return next;
441
568
  default:
442
569
  return null;
443
570
  }
@@ -445,6 +572,7 @@ function nextTaskStatus(current, ack) {
445
572
  function aggregateTaskStatus(assignees) {
446
573
  if (assignees.length === 0) return "pending";
447
574
  const statuses = assignees.map((a) => a.status);
575
+ if (statuses.some((s) => s === "input-required")) return "input-required";
448
576
  const active = statuses.filter((s) => s === "pending" || s === "accepted" || s === "working");
449
577
  if (active.length > 0) {
450
578
  if (active.some((s) => s === "working")) return "working";
@@ -454,13 +582,14 @@ function aggregateTaskStatus(assignees) {
454
582
  if (statuses.every((s) => s === "done")) return "done";
455
583
  if (statuses.some((s) => s === "failed")) return "failed";
456
584
  if (statuses.some((s) => s === "timeout")) return "timeout";
585
+ if (statuses.some((s) => s === "canceled")) return "canceled";
457
586
  return "rejected";
458
587
  }
459
588
  function encodeContent(payload) {
460
589
  return JSON.stringify(payload);
461
590
  }
462
591
  function decodeContent(kind, content) {
463
- if (kind !== "task" && kind !== "ack") return content;
592
+ if (kind !== "task" && kind !== "ack" && kind !== "notice") return content;
464
593
  try {
465
594
  const parsed = JSON.parse(content);
466
595
  if (parsed !== null && typeof parsed === "object") return parsed;
@@ -472,19 +601,103 @@ function decodeContent(kind, content) {
472
601
  // src/hub.ts
473
602
  var MAX_PROFILES = 512;
474
603
  var MAX_TASKS = 1024;
475
- var AgentHub = class {
604
+ var MAX_TIMER_DELAY_MS = 2147483647;
605
+ var DEFAULT_PARKED_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
606
+ var DEFAULT_LEASE_TTL_MS = 6e4;
607
+ var DEFAULT_MAX_ATTEMPTS = 5;
608
+ var MAX_EVENTS = 500;
609
+ var AgentHub = class _AgentHub {
476
610
  constructor(options) {
477
611
  this.options = options;
478
612
  const idle = options.peerIdleTimeoutMs ?? 6e5;
613
+ const parkedTtl = options.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS;
479
614
  if (idle > 0) {
480
615
  const interval = Math.min(6e4, Math.max(1e3, idle / 2));
481
616
  this.gcTimer = setInterval(() => this.gcTick(idle), interval);
482
617
  this.gcTimer.unref?.();
618
+ } else if (parkedTtl > 0) {
619
+ const interval = Math.min(6e4, Math.max(1e3, parkedTtl / 2));
620
+ this.gcTimer = setInterval(() => this.expireParkedMailboxes(Date.now()), interval);
621
+ this.gcTimer.unref?.();
483
622
  }
484
623
  }
485
624
  queues = /* @__PURE__ */ new Map();
625
+ /** When each parked (peer-gone-but-kept) mailbox was parked, for the TTL sweep.
626
+ * A queue in this map has no registered peer; see {@link unregister}. */
627
+ parkedAt = /* @__PURE__ */ new Map();
628
+ /** Highest {@link BridgeMessage.seq} handed out so far. Restored history can
629
+ * raise it (see importHistory) but nothing lowers it: a client's `since` cursor
630
+ * has to keep meaning the same thing across a restart. */
631
+ seqCounter = 0;
632
+ /**
633
+ * Record one operational event.
634
+ *
635
+ * Deliberately NOT the same thing as history: history is the messages that
636
+ * flowed (what peers said to each other), this is what the HUB did — a peer
637
+ * joined, a mailbox was dropped, a task moved. History can be read per peer and
638
+ * is persisted; events are global, per-run, and never persisted, because they
639
+ * describe the life of this process.
640
+ */
641
+ recordEvent(kind, input = {}) {
642
+ this.eventRing.push({
643
+ seq: ++this.eventSeq,
644
+ ts: Date.now(),
645
+ kind,
646
+ ...input.peer !== void 0 ? { peer: input.peer } : {},
647
+ detail: input.detail ?? {}
648
+ });
649
+ while (this.eventRing.length > MAX_EVENTS) this.eventRing.shift();
650
+ }
651
+ /**
652
+ * Read recorded events, oldest first.
653
+ *
654
+ * `since` is exclusive ("give me what happened after seq N"), which makes it the
655
+ * natural cursor: pass the `lastSeq` of the previous call. `truncated` says the
656
+ * requested window is no longer in the ring, so the caller knows it missed
657
+ * entries instead of assuming it saw everything.
658
+ */
659
+ events(options = {}) {
660
+ const limit = Math.max(1, Math.min(options.limit ?? 100, MAX_EVENTS));
661
+ const since = options.since ?? 0;
662
+ const available = this.eventRing.filter((event) => event.seq > since);
663
+ const truncated = since > 0 && available.length > 0 && available[0].seq > since + 1;
664
+ return {
665
+ events: available.slice(-limit),
666
+ lastSeq: this.eventSeq,
667
+ truncated
668
+ };
669
+ }
670
+ /** The task hook, plus the event it implies.
671
+ *
672
+ * Every status change in this class already reports through
673
+ * `options.onTaskChanged`, so recording the event HERE needs no new call sites:
674
+ * the hook IS the transition signal. That is also why the event can carry the
675
+ * aggregate status without the caller having to say what changed — it is read
676
+ * from the record at the moment the transition is announced. */
677
+ taskChanged(record) {
678
+ if (record !== void 0) {
679
+ this.recordEvent("task_transition", {
680
+ peer: record.from,
681
+ detail: { taskId: record.id, status: record.status, assignees: record.assignees.length }
682
+ });
683
+ }
684
+ this.options.onTaskChanged?.(record);
685
+ }
686
+ /** Record an event from outside the hub.
687
+ *
688
+ * The runner layer lives in `index.ts` and knows when a run starts and ends,
689
+ * which the hub cannot see. Deliberately narrow: a caller can ADD an event but
690
+ * cannot read or clear the ring through this.
691
+ */
692
+ noteEvent(kind, input = {}) {
693
+ this.recordEvent(kind, input);
694
+ }
486
695
  waiters = /* @__PURE__ */ new Map();
487
696
  historyRing = [];
697
+ /** Structured operational events, newest last. A ring, not a log: the hub is a
698
+ * long-running process and an unbounded event list is a leak. See {@link events}. */
699
+ eventRing = [];
700
+ eventSeq = 0;
488
701
  lastSeen = /* @__PURE__ */ new Map();
489
702
  /** Identity metadata keyed by peer id; outlives registration (see {@link PeerProfile}). */
490
703
  profiles = /* @__PURE__ */ new Map();
@@ -507,9 +720,69 @@ var AgentHub = class {
507
720
  if (now - (this.lastSeen.get(peerId) ?? 0) > idleTimeoutMs) {
508
721
  if (this.options.isPeerLive?.(peerId) === true) continue;
509
722
  this.options.onPeerGc?.(peerId);
510
- this.unregister(peerId);
723
+ this.unregister(peerId, { keepMailbox: true });
511
724
  }
512
725
  }
726
+ for (const peerId of [...this.leases.keys()]) this.reapLeases(peerId, now);
727
+ this.expireParkedMailboxes(now);
728
+ }
729
+ /** Drop parked mailboxes older than `parkedTtlMs` (0 disables expiry). */
730
+ expireParkedMailboxes(now) {
731
+ const ttl = this.options.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS;
732
+ if (ttl <= 0) return;
733
+ for (const [peerId, parkedMs] of [...this.parkedAt]) {
734
+ if (this.lastSeen.has(peerId)) {
735
+ this.parkedAt.delete(peerId);
736
+ continue;
737
+ }
738
+ if (now - parkedMs <= ttl) continue;
739
+ const queue = this.queues.get(peerId) ?? [];
740
+ this.queues.delete(peerId);
741
+ this.parkedAt.delete(peerId);
742
+ for (const message of queue) {
743
+ if (message.kind === "task") this.failTaskForGoneAssignee(message.id, peerId, now, ttl);
744
+ }
745
+ const dropped = queue.length;
746
+ if (dropped > 0) {
747
+ this.options.onMailboxesChanged?.();
748
+ this.recordEvent("mailbox_dropped", { peer: peerId, detail: { reason: "expired", dropped, ttlMs: ttl } });
749
+ this.options.onMailboxExpired?.(peerId, dropped);
750
+ }
751
+ }
752
+ }
753
+ /** Reconcile the task ledger after `peerId`'s parked mailbox was destroyed with
754
+ * the task still inside it.
755
+ *
756
+ * The task fails as a SYSTEM event, not as an ack: nobody acked anything, the
757
+ * peer simply never came back. Marking only this assignee's row keeps a
758
+ * broadcast task's other recipients intact, and the aggregate status is
759
+ * recomputed the same way every other transition does it. */
760
+ failTaskForGoneAssignee(taskId, peerId, now, ttl) {
761
+ const record = this.taskLedger.get(taskId);
762
+ if (record === void 0) return;
763
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) return;
764
+ const assignee = record.assignees.find((a) => a.peerId === peerId);
765
+ if (assignee === void 0 || TERMINAL_TASK_STATUSES.includes(assignee.status)) return;
766
+ const event = {
767
+ from: peerId,
768
+ status: "failed",
769
+ note: `recipient '${peerId}' never came back: its parked mailbox expired after ${ttl}ms and the task was dropped with it`,
770
+ ts: now,
771
+ messageId: `expired-${taskId}`,
772
+ applied: true,
773
+ system: true
774
+ };
775
+ record.acks.push(event);
776
+ assignee.status = "failed";
777
+ assignee.acks.push({ ...event });
778
+ record.status = aggregateTaskStatus(record.assignees);
779
+ record.updatedAt = now;
780
+ const timer = this.taskTimers.get(taskId);
781
+ if (timer !== void 0) {
782
+ clearTimeout(timer);
783
+ this.taskTimers.delete(taskId);
784
+ }
785
+ this.taskChanged(this.taskOf(taskId));
513
786
  }
514
787
  /** All registered peer ids, insertion order. */
515
788
  peers() {
@@ -521,6 +794,7 @@ var AgentHub = class {
521
794
  register(peerId, client) {
522
795
  if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
523
796
  this.lastSeen.set(peerId, Date.now());
797
+ this.parkedAt.delete(peerId);
524
798
  const existing = this.profiles.get(peerId);
525
799
  this.profiles.set(peerId, {
526
800
  ...existing,
@@ -530,13 +804,28 @@ var AgentHub = class {
530
804
  ...client?.version !== void 0 ? { clientVersion: client.version } : existing?.clientVersion !== void 0 ? { clientVersion: existing.clientVersion } : {}
531
805
  });
532
806
  this.evictOverflowProfiles();
807
+ this.recordEvent("peer_joined", { peer: peerId, detail: { clientName: client?.name } });
533
808
  this.options.onPeersChanged?.(this.peers());
534
809
  }
535
- /** Remove a peer and its queued messages; pending waiters resolve as
536
- * timeouts. The profile is kept so a reconnecting peer keeps its alias. */
537
- unregister(peerId) {
538
- if ((this.queues.get(peerId) ?? []).length > 0) this.options.onMailboxesChanged?.();
539
- this.queues.delete(peerId);
810
+ /** Remove a peer; pending waiters resolve as timeouts. The profile is kept so
811
+ * a reconnecting peer keeps its alias.
812
+ *
813
+ * `keepMailbox` is what the idle GC passes: the peer's queued messages stay in
814
+ * `queues` (marked parked) so a returning agent drains what was sent while it
815
+ * was away. An explicit `bridge_unregister` and a manager kick leave it off,
816
+ * i.e. they still drop the mail — leaving the hub is a deliberate act. */
817
+ unregister(peerId, options) {
818
+ const queue = this.queues.get(peerId) ?? [];
819
+ const keep = options?.keepMailbox === true;
820
+ if (queue.length > 0) this.options.onMailboxesChanged?.();
821
+ if (keep) {
822
+ this.parkedAt.set(peerId, Date.now());
823
+ this.recordEvent("peer_parked", { peer: peerId, detail: { queued: queue.length } });
824
+ } else {
825
+ this.recordEvent("peer_left", { peer: peerId, detail: { queued: queue.length } });
826
+ this.queues.delete(peerId);
827
+ this.parkedAt.delete(peerId);
828
+ }
540
829
  for (const waiter of this.waiters.get(peerId) ?? []) {
541
830
  clearTimeout(waiter.timer);
542
831
  waiter.onAbort();
@@ -553,6 +842,44 @@ var AgentHub = class {
553
842
  exportProfiles() {
554
843
  return [...this.profiles.values()].map((profile) => ({ ...profile }));
555
844
  }
845
+ /** Record what a peer says about itself. Creates the profile if the peer is
846
+ * new: a card is identity, and identity outlives a connection. */
847
+ announceCard(peerId, card) {
848
+ const existing = this.profiles.get(peerId);
849
+ const profile = { ...existing, id: peerId, registeredAt: existing?.registeredAt ?? Date.now(), card };
850
+ this.profiles.set(peerId, profile);
851
+ this.evictOverflowProfiles();
852
+ this.options.onPeersChanged?.(this.peers());
853
+ return profile;
854
+ }
855
+ /** Every peer whose announced card matches `filter`, with the load a caller
856
+ * needs to pick one.
857
+ *
858
+ * `acceptsTasks` defaults to TRUE — and that default is the point: discover is
859
+ * for finding somebody to HAND WORK TO, and "has a skill" is not "wants a
860
+ * task". Pass `false` to drop the requirement and list peers regardless. */
861
+ discover(filter = {}) {
862
+ const requireAccept = filter.acceptsTasks !== false;
863
+ const out = [];
864
+ for (const profile of this.profiles.values()) {
865
+ const card = profile.card;
866
+ if (card === void 0) continue;
867
+ if (requireAccept && card.acceptsTasks !== true) continue;
868
+ const skills = card.skills ?? [];
869
+ if (filter.skill !== void 0 && !skills.some((skill) => skill.id === filter.skill)) continue;
870
+ if (filter.tag !== void 0 && !skills.some((skill) => (skill.tags ?? []).includes(filter.tag))) continue;
871
+ const load = {
872
+ queued: this.queues.get(profile.id)?.length ?? 0,
873
+ waiting: this.waiters.get(profile.id)?.length ?? 0,
874
+ running: [...this.taskLedger.values()].filter(
875
+ (record) => !TERMINAL_TASK_STATUSES.includes(record.status) && record.assignees.some((a) => a.peerId === profile.id)
876
+ ).length
877
+ };
878
+ if (filter.idleOnly === true && (load.queued > 0 || load.waiting > 0 || load.running > 0)) continue;
879
+ out.push({ peerId: profile.id, ...profile.alias !== void 0 ? { alias: profile.alias } : {}, card, load });
880
+ }
881
+ return out;
882
+ }
556
883
  /** Load persisted profiles at boot. Invalid entries are dropped; `null`-ish
557
884
  * and non-finite fields are sanitized. Returns the imported count. Silent
558
885
  * for roster observers — this runs before the server accepts requests. */
@@ -572,6 +899,7 @@ var AgentHub = class {
572
899
  if (typeof candidate.alias === "string" && candidate.alias !== "") profile.alias = candidate.alias;
573
900
  if (typeof candidate.clientName === "string" && candidate.clientName !== "") profile.clientName = candidate.clientName;
574
901
  if (typeof candidate.clientVersion === "string" && candidate.clientVersion !== "") profile.clientVersion = candidate.clientVersion;
902
+ if (candidate.card !== null && typeof candidate.card === "object") profile.card = candidate.card;
575
903
  this.profiles.set(id, profile);
576
904
  count++;
577
905
  }
@@ -599,6 +927,7 @@ var AgentHub = class {
599
927
  if (oldId === newId) return;
600
928
  if (!this.lastSeen.has(oldId)) throw new Error(`unknown peer: ${oldId}`);
601
929
  if (newId === BROADCAST) throw new Error(`reserved peer id: ${newId}`);
930
+ if (newId === HUB_PEER) throw new Error(`reserved peer id: ${newId}`);
602
931
  if (!PEER_ID_PATTERN.test(newId)) throw new Error(`invalid peerId: ${newId} (expected [A-Za-z0-9._:-]{1,64})`);
603
932
  if (this.lastSeen.has(newId)) throw new Error(`peer already registered: ${newId}`);
604
933
  const queue = this.queues.get(oldId);
@@ -606,6 +935,8 @@ var AgentHub = class {
606
935
  const seen = this.lastSeen.get(oldId) ?? Date.now();
607
936
  const profile = this.profiles.get(oldId);
608
937
  const stale = this.profiles.get(newId);
938
+ this.parkedAt.delete(oldId);
939
+ this.parkedAt.delete(newId);
609
940
  this.queues.delete(oldId);
610
941
  this.waiters.delete(oldId);
611
942
  this.lastSeen.delete(oldId);
@@ -657,6 +988,13 @@ var AgentHub = class {
657
988
  has(peerId) {
658
989
  return this.lastSeen.has(peerId);
659
990
  }
991
+ /** Is the peer parked — gone from the live registry through idle GC, but still
992
+ * holding its mailbox and still addressable? `has()` is false for it, so a
993
+ * guard written only as `has()` will decide there is nothing to do and leave
994
+ * the peer reachable. A manager kick has to cover this state explicitly. */
995
+ isParked(peerId) {
996
+ return this.parkedAt.has(peerId);
997
+ }
660
998
  /** Is the peer "active" (tool/activity within the connected window)? */
661
999
  isActive(peerId) {
662
1000
  const last = this.lastSeen.get(peerId);
@@ -664,15 +1002,21 @@ var AgentHub = class {
664
1002
  }
665
1003
  /** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
666
1004
  send(from, to, kind, content, opts) {
667
- return this.route(from, to, kind, content, opts?.ref, opts?.threadId);
1005
+ return this.route(from, to, kind, content, opts?.ref, opts?.threadId, opts?.expiresAt, false, opts?.priority);
668
1006
  }
669
1007
  /** Send a structured task message to `to` (peer, {@link BROADCAST}, or a
670
1008
  * group id). Records a ledger entry so the sender can later query status /
671
- * wait for acks. */
672
- sendTask(from, to, task) {
1009
+ * wait for acks.
1010
+ *
1011
+ * `ref` marks the task this one answers. When the parent is itself a task the
1012
+ * new one is a **re-delegation**, and that chain is capped at {@link MAX_HOP}
1013
+ * — without a way to express the parent, the cap would be unreachable and a
1014
+ * hand-off loop unobservable. */
1015
+ sendTask(from, to, task, ref, expiresAt) {
673
1016
  const threadId = task.threadId;
674
- const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
1017
+ const message = this.compose(from, to, "task", JSON.stringify(task), ref, threadId, expiresAt, false, task.priority);
675
1018
  this.recordTask(message, task);
1019
+ this.dispatch(message);
676
1020
  return message;
677
1021
  }
678
1022
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
@@ -681,20 +1025,126 @@ var AgentHub = class {
681
1025
  sendAck(from, ref, ack) {
682
1026
  const task = this.taskLedger.get(ref);
683
1027
  let target;
1028
+ let threadId;
684
1029
  if (task !== void 0) {
685
1030
  target = task.from;
1031
+ threadId = task.threadId;
686
1032
  } else {
687
1033
  const original = this.historyRing.findLast((message2) => message2.id === ref);
688
1034
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
689
1035
  target = original.from;
1036
+ threadId = original.threadId;
690
1037
  }
691
- const message = this.route(from, target, "ack", JSON.stringify(ack), ref);
1038
+ const message = this.route(from, target, "ack", JSON.stringify(ack), ref, threadId);
692
1039
  this.applyAckToLedger(message, from, ref, ack);
693
1040
  return message;
694
1041
  }
1042
+ /** Record an ack the hub produced ON BEHALF of a peer whose runner finished
1043
+ * its task (see `src/runner.ts`).
1044
+ *
1045
+ * Unlike {@link sendAck} the sender is not a live session — it may be parked,
1046
+ * or not connected at all — so the "sender must be registered" check does not
1047
+ * apply. This is the hub acting for a peer it is executing for, never a
1048
+ * caller-supplied identity; nothing from a request reaches `from` here.
1049
+ * Everything else (ledger update, routing to the task's sender) is identical. */
1050
+ ackForRunner(peer, ref, ack) {
1051
+ const task = this.taskLedger.get(ref);
1052
+ if (task === void 0) throw new Error(`runner cannot ack unknown task: ${ref}`);
1053
+ const message = this.route(peer, task.from, "ack", JSON.stringify(ack), ref, void 0, void 0, true);
1054
+ this.applyAckToLedger(message, peer, ref, ack);
1055
+ return message;
1056
+ }
1057
+ /** Cancel a task. EITHER side may do it — the sender stops work it no longer
1058
+ * wants, an assignee reports it will not finish — and the cancel is both
1059
+ * recorded on the ledger and routed to the OTHER side, so whoever did not
1060
+ * press the button finds out. Cancelling an already-terminal task is an error
1061
+ * rather than a silent no-op: the caller asked for something that did not
1062
+ * happen. */
1063
+ cancelTask(from, ref, reason) {
1064
+ const record = this.taskLedger.get(ref);
1065
+ if (record === void 0) throw new Error(`cannot cancel unknown task: ${ref}`);
1066
+ const isSender = from === record.from;
1067
+ if (!isSender && !record.assignees.some((a) => a.peerId === from)) {
1068
+ throw new Error(`only the sender or an assignee can cancel task ${ref}`);
1069
+ }
1070
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) {
1071
+ throw new Error(`task ${ref} is already ${record.status}`);
1072
+ }
1073
+ const ack = { status: "canceled", ...reason !== void 0 ? { note: reason } : {} };
1074
+ if (!isSender) {
1075
+ return this.sendAck(from, ref, ack);
1076
+ }
1077
+ let last;
1078
+ for (const assignee of record.assignees) {
1079
+ if (TERMINAL_TASK_STATUSES.includes(assignee.status)) continue;
1080
+ const message = this.route(from, assignee.peerId, "ack", JSON.stringify(ack), ref, record.threadId);
1081
+ const event = {
1082
+ from,
1083
+ status: "canceled",
1084
+ ...reason !== void 0 ? { note: reason } : {},
1085
+ ts: message.ts,
1086
+ messageId: message.id,
1087
+ applied: true
1088
+ };
1089
+ record.acks.push(event);
1090
+ assignee.status = "canceled";
1091
+ assignee.acks.push({ ...event });
1092
+ last = message;
1093
+ }
1094
+ if (last === void 0) throw new Error(`task ${ref} has no assignee left to cancel`);
1095
+ record.status = aggregateTaskStatus(record.assignees);
1096
+ record.updatedAt = last.ts;
1097
+ this.taskChanged(this.taskOf(ref));
1098
+ return last;
1099
+ }
1100
+ /** Answer an `input-required` interrupt. Only the task's SENDER can answer — it
1101
+ * is the side the assignee asked. The answer is delivered to the waiting
1102
+ * assignee as an `ack` carrying the task id, so a `bridge_wait { ref }` picks
1103
+ * it up, and the task resumes (`working`). */
1104
+ taskInput(from, ref, input) {
1105
+ const record = this.taskLedger.get(ref);
1106
+ if (record === void 0) throw new Error(`cannot answer unknown task: ${ref}`);
1107
+ if (from !== record.from) {
1108
+ throw new Error(`only the sender of task ${ref} can answer it (an assignee resumes by acking 'working')`);
1109
+ }
1110
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) {
1111
+ throw new Error(`task ${ref} is already ${record.status}`);
1112
+ }
1113
+ const waiting = record.assignees.filter((a) => a.status === "input-required");
1114
+ const targets = waiting.length > 0 ? waiting : record.assignees;
1115
+ const ack = { status: "working", note: input };
1116
+ let last;
1117
+ for (const assignee of targets) {
1118
+ const message = this.route(from, assignee.peerId, "ack", JSON.stringify(ack), ref, record.threadId);
1119
+ const event = { from, status: "working", note: input, ts: message.ts, messageId: message.id, applied: true };
1120
+ record.acks.push(event);
1121
+ if (assignee.status === "input-required") {
1122
+ assignee.status = "working";
1123
+ assignee.acks.push({ ...event });
1124
+ }
1125
+ last = message;
1126
+ }
1127
+ if (last === void 0) throw new Error(`task ${ref} has no assignee to answer`);
1128
+ record.status = aggregateTaskStatus(record.assignees);
1129
+ record.updatedAt = last.ts;
1130
+ this.taskChanged(this.taskOf(ref));
1131
+ return last;
1132
+ }
1133
+ /** Every peer a fan-out can reach: the live registry **plus** parked peers.
1134
+ *
1135
+ * Parked peers keep their mailbox and stay addressable (that is the point of
1136
+ * parking, see {@link isParked}), and a group send already delivered to its
1137
+ * parked members because it reads the member list rather than the live roster.
1138
+ * Broadcast read the live roster, so `peer-x` showed up in `bridge_peers` as
1139
+ * addressable yet silently missed every broadcast. Both resolve here now. */
1140
+ audience() {
1141
+ const ids = new Set(this.lastSeen.keys());
1142
+ for (const id of this.parkedAt.keys()) ids.add(id);
1143
+ return [...ids];
1144
+ }
695
1145
  /** Resolve the peer ids a task is assigned to (broadcast / group fan-out). */
696
1146
  resolveAssignees(from, to) {
697
- if (to === BROADCAST) return this.peers().filter((peer) => peer !== from);
1147
+ if (to === BROADCAST) return this.audience().filter((peer) => peer !== from);
698
1148
  const group = this.groups.get(to);
699
1149
  if (group !== void 0) return group.members.filter((member) => member !== from);
700
1150
  return [to];
@@ -723,13 +1173,19 @@ var AgentHub = class {
723
1173
  this.taskLedger.set(message.id, record);
724
1174
  this.scheduleTaskTimeout(record);
725
1175
  this.evictOverflowTasks();
726
- this.options.onTaskChanged?.(this.taskOf(record.id));
1176
+ this.taskChanged(this.taskOf(record.id));
727
1177
  }
728
- /** Arm the SLA timer for a task that supplied `timeoutMs`. */
1178
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. A deadline
1179
+ * beyond {@link MAX_TIMER_DELAY_MS} cannot be expressed as one delay — the
1180
+ * timer would overflow and fire after ~1 ms — so it is re-armed in segments
1181
+ * of that size. `deadlineAt` stays the authoritative deadline and each
1182
+ * segment recomputes the remaining time, so a long SLA is honored exactly
1183
+ * (no drift); settling or evicting the task clears the pending segment, and
1184
+ * clearing the timer is what ends the chain. */
729
1185
  scheduleTaskTimeout(record) {
730
1186
  if (record.deadlineAt === void 0) return;
731
- const delay = Math.max(0, record.deadlineAt - Date.now());
732
- const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
1187
+ const remaining = record.deadlineAt - Date.now();
1188
+ const timer = remaining > MAX_TIMER_DELAY_MS ? setTimeout(() => this.scheduleTaskTimeout(record), MAX_TIMER_DELAY_MS) : setTimeout(() => this.fireTaskTimeout(record.id), Math.max(0, remaining));
733
1189
  timer.unref?.();
734
1190
  this.taskTimers.set(record.id, timer);
735
1191
  }
@@ -759,7 +1215,7 @@ var AgentHub = class {
759
1215
  record.status = "timeout";
760
1216
  record.updatedAt = now;
761
1217
  this.clearTaskTimer(taskId);
762
- this.options.onTaskChanged?.(this.taskOf(taskId));
1218
+ this.taskChanged(this.taskOf(taskId));
763
1219
  }
764
1220
  clearTaskTimer(taskId) {
765
1221
  const timer = this.taskTimers.get(taskId);
@@ -776,7 +1232,7 @@ var AgentHub = class {
776
1232
  if (assignee === void 0 || assignee.deliveredMs !== void 0) return;
777
1233
  assignee.deliveredMs = ts;
778
1234
  record.updatedAt = ts;
779
- this.options.onTaskChanged?.(this.taskOf(taskId));
1235
+ this.taskChanged(this.taskOf(taskId));
780
1236
  }
781
1237
  /**
782
1238
  * Fold an ack into the task's timeline and (when legal) its status.
@@ -796,11 +1252,15 @@ var AgentHub = class {
796
1252
  status: ack.status,
797
1253
  ...ack.note !== void 0 ? { note: ack.note } : {},
798
1254
  ...typeof ack.progress === "number" && Number.isFinite(ack.progress) ? { progress: Math.max(0, Math.min(100, ack.progress)) } : {},
1255
+ ...ack.artifacts !== void 0 && ack.artifacts.length > 0 ? { artifacts: ack.artifacts } : {},
799
1256
  ts: message.ts,
800
1257
  messageId: message.id,
801
1258
  applied
802
1259
  };
803
1260
  record.acks.push(event);
1261
+ if (applied && event.artifacts !== void 0) {
1262
+ record.artifacts = [...record.artifacts ?? [], ...event.artifacts];
1263
+ }
804
1264
  if (assignee !== void 0 && assigneeNext !== null) {
805
1265
  assignee.status = assigneeNext;
806
1266
  assignee.acks.push({ ...event });
@@ -812,7 +1272,7 @@ var AgentHub = class {
812
1272
  }
813
1273
  record.updatedAt = message.ts;
814
1274
  if (TERMINAL_TASK_STATUSES.includes(record.status)) this.clearTaskTimer(record.id);
815
- this.options.onTaskChanged?.(this.taskOf(record.id));
1275
+ this.taskChanged(this.taskOf(record.id));
816
1276
  }
817
1277
  /** Keep the ledger bounded: drop oldest terminal tasks first. */
818
1278
  evictOverflowTasks() {
@@ -936,27 +1396,38 @@ var AgentHub = class {
936
1396
  this.evictOverflowTasks();
937
1397
  return count;
938
1398
  }
1399
+ /** Highest sequence number handed out so far — a client's "start from now"
1400
+ * cursor (`bridge_status.lastSeq`). */
1401
+ lastSeq() {
1402
+ return this.seqCounter;
1403
+ }
939
1404
  /** Recent messages involving `peer` (inbound and outbound, plus group
940
- * channels the peer belongs to), newest first. */
941
- history(peerId, limit) {
1405
+ * channels the peer belongs to), newest first. `since` restricts the result to
1406
+ * messages newer than that sequence number (a resume cursor); omitting it keeps
1407
+ * the original behaviour, which includes history recorded before cursors
1408
+ * existed. */
1409
+ history(peerId, limit, since) {
942
1410
  const memberOf = new Set(
943
1411
  [...this.groups.values()].filter((group) => group.members.includes(peerId)).map((group) => group.id)
944
1412
  );
945
1413
  const filtered = this.historyRing.filter(
946
- (message) => message.from === peerId || message.to === peerId || message.to === BROADCAST || message.channel !== void 0 && memberOf.has(message.channel)
1414
+ (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))
947
1415
  );
948
1416
  return filtered.slice(-Math.max(0, limit)).reverse();
949
1417
  }
950
1418
  /** Recent messages of one group channel, newest first. */
951
- historyChannel(groupId, limit) {
952
- const filtered = this.historyRing.filter((message) => message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId));
1419
+ historyChannel(groupId, limit, since) {
1420
+ const filtered = this.historyRing.filter(
1421
+ (message) => (since === void 0 || (message.seq ?? 0) > since) && (message.channel === groupId || message.to === groupId && message.channel === void 0 && this.groups.has(groupId))
1422
+ );
953
1423
  return filtered.slice(-Math.max(0, limit)).reverse();
954
1424
  }
955
1425
  /** Most recent messages across every peer, unfiltered (newest first).
956
1426
  * Backs `bridge_history { peer: "all" }` — lets an archiver (the desktop
957
1427
  * app) capture peer-to-peer traffic it is not a party of. */
958
- historyAll(limit) {
959
- return this.historyRing.slice(-Math.max(0, limit)).reverse();
1428
+ historyAll(limit, since) {
1429
+ const filtered = since === void 0 ? this.historyRing : this.historyRing.filter((message) => (message.seq ?? 0) > since);
1430
+ return filtered.slice(-Math.max(0, limit)).reverse();
960
1431
  }
961
1432
  // ---- groups (group channel) ------------------------------------------------
962
1433
  /** Create a channel; throws when the id is taken or a member is unknown. */
@@ -1059,6 +1530,7 @@ var AgentHub = class {
1059
1530
  const m = raw;
1060
1531
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1061
1532
  if (KINDS.includes(m.kind) === false) continue;
1533
+ const hop = importHop(m.hop);
1062
1534
  valid.push({
1063
1535
  id: m.id,
1064
1536
  from: m.from,
@@ -1068,9 +1540,18 @@ var AgentHub = class {
1068
1540
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1069
1541
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1070
1542
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1543
+ ...hop !== void 0 ? { hop } : {},
1544
+ // Absent in databases written before the column existed; such a message
1545
+ // is simply unreachable through a `since` cursor, which is the honest
1546
+ // state for history that predates the cursor.
1547
+ ...typeof m.seq === "number" && Number.isFinite(m.seq) && m.seq > 0 ? { seq: m.seq } : {},
1548
+ ...typeof m.expiresAt === "number" && Number.isFinite(m.expiresAt) && m.expiresAt > 0 ? { expiresAt: m.expiresAt } : {},
1071
1549
  ts: m.ts
1072
1550
  });
1073
1551
  }
1552
+ for (const message of valid) {
1553
+ if (message.seq !== void 0 && message.seq > this.seqCounter) this.seqCounter = message.seq;
1554
+ }
1074
1555
  for (const message of valid.slice(-this.options.historyLimit)) this.historyRing.push(message);
1075
1556
  return Math.min(valid.length, this.options.historyLimit);
1076
1557
  }
@@ -1093,6 +1574,7 @@ var AgentHub = class {
1093
1574
  if (typeof raw !== "object" || raw === null) continue;
1094
1575
  const m = raw;
1095
1576
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1577
+ const hop = importHop(m.hop);
1096
1578
  restored.push({
1097
1579
  id: m.id,
1098
1580
  from: m.from,
@@ -1102,11 +1584,18 @@ var AgentHub = class {
1102
1584
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1103
1585
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1104
1586
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1587
+ ...hop !== void 0 ? { hop } : {},
1588
+ // Restored mail must keep its deadline and its sequence number: the
1589
+ // deadline decides whether it is still deliverable at all, and the
1590
+ // cursor has to stay continuous across a restart.
1591
+ ...typeof m.seq === "number" && Number.isFinite(m.seq) && m.seq > 0 ? { seq: m.seq } : {},
1592
+ ...typeof m.expiresAt === "number" && Number.isFinite(m.expiresAt) && m.expiresAt > 0 ? { expiresAt: m.expiresAt } : {},
1105
1593
  ts: m.ts
1106
1594
  });
1107
1595
  }
1108
1596
  if (restored.length > 0) {
1109
1597
  this.queues.set(peer, restored.slice(-this.options.maxQueue));
1598
+ if (!this.lastSeen.has(peer)) this.parkedAt.set(peer, Date.now());
1110
1599
  count += restored.length;
1111
1600
  }
1112
1601
  }
@@ -1120,27 +1609,321 @@ var AgentHub = class {
1120
1609
  const now = Date.now();
1121
1610
  return {
1122
1611
  server: "agent-comm-hub",
1123
- peers: this.peers().map((peer) => {
1124
- const alias = this.profiles.get(peer)?.alias;
1125
- return {
1126
- id: peer,
1127
- ...alias !== void 0 ? { alias } : {},
1128
- connected: this.isActive(peer) || livePeers?.has(peer) === true,
1129
- lastSeenMs: this.lastSeen.get(peer) ?? 0,
1130
- queued: (this.queues.get(peer) ?? []).length,
1131
- waiting: (this.waiters.get(peer) ?? []).length
1132
- };
1133
- }),
1612
+ peers: this.rosterRows(livePeers),
1134
1613
  historyLimit: this.options.historyLimit,
1135
- maxQueue: this.options.maxQueue
1614
+ maxQueue: this.options.maxQueue,
1615
+ // A client that wants only what happened since it last looked reads this
1616
+ // and passes it back as `bridge_history { since }`.
1617
+ lastSeq: this.seqCounter
1618
+ };
1619
+ }
1620
+ /** Registered peers plus parked ones (gone but still holding mail).
1621
+ * Parked rows are what lets a sender tell "queued, will be delivered when it
1622
+ * comes back" apart from "vanished" — without them the message just looks lost.
1623
+ */
1624
+ rosterRows(livePeers) {
1625
+ const rows = this.peers().map((peer) => {
1626
+ const alias = this.profiles.get(peer)?.alias;
1627
+ const connected = this.isActive(peer) || livePeers?.has(peer) === true;
1628
+ return {
1629
+ id: peer,
1630
+ ...alias !== void 0 ? { alias } : {},
1631
+ connected,
1632
+ reachable: connected ? "online" : "idle",
1633
+ lastSeenMs: this.lastSeen.get(peer) ?? 0,
1634
+ queued: (this.queues.get(peer) ?? []).length,
1635
+ waiting: (this.waiters.get(peer) ?? []).length,
1636
+ ...this.leases.get(peer) !== void 0 ? { inFlight: this.leases.get(peer).length } : {},
1637
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).overflow > 0 ? { droppedOverflow: this.drops.get(peer).overflow } : {},
1638
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).expired > 0 ? { droppedExpired: this.drops.get(peer).expired } : {},
1639
+ ...this.drops.get(peer) !== void 0 && this.drops.get(peer).rate > 0 ? { droppedRate: this.drops.get(peer).rate } : {},
1640
+ ...this.deadLetters.get(peer) !== void 0 ? { deadLettered: this.deadLetters.get(peer).length } : {}
1641
+ };
1642
+ });
1643
+ for (const [peerId, parkedMs] of this.parkedAt) {
1644
+ if (this.lastSeen.has(peerId)) continue;
1645
+ const alias = this.profiles.get(peerId)?.alias;
1646
+ rows.push({
1647
+ id: peerId,
1648
+ ...alias !== void 0 ? { alias } : {},
1649
+ connected: false,
1650
+ reachable: "parked",
1651
+ lastSeenMs: parkedMs,
1652
+ queued: (this.queues.get(peerId) ?? []).length,
1653
+ waiting: 0,
1654
+ ...this.leases.get(peerId) !== void 0 ? { inFlight: this.leases.get(peerId).length } : {},
1655
+ ...this.deadLetters.get(peerId) !== void 0 ? { deadLettered: this.deadLetters.get(peerId).length } : {}
1656
+ });
1657
+ }
1658
+ return rows;
1659
+ }
1660
+ // ---- manual-ack leases -----------------------------------------------------
1661
+ /** A message handed out but not yet committed, and when its lease ends. */
1662
+ leases = /* @__PURE__ */ new Map();
1663
+ /** Hand-out count per (peer, message). Kept OUTSIDE the message so requeueing
1664
+ * preserves it and so it is never persisted as part of the message. */
1665
+ leaseAttempts = /* @__PURE__ */ new Map();
1666
+ /** Messages that ran out of attempts; `nack` revives them. */
1667
+ deadLetters = /* @__PURE__ */ new Map();
1668
+ // ---- drop accounting --------------------------------------------------------
1669
+ /** Per-peer drop counters, surfaced in the roster. Memory-only on purpose:
1670
+ * these are operational counters for the current run, not durable state. */
1671
+ drops = /* @__PURE__ */ new Map();
1672
+ /** Per-peer token bucket for the MAILBOX. See {@link HubOptions.ratePerMin}. */
1673
+ buckets = /* @__PURE__ */ new Map();
1674
+ /** Traffic counters, per peer: how much this peer sent, was sent, and acked.
1675
+ * Deliberately separate from the mailbox — "how much has this peer sent" is not
1676
+ * "how much is queued", and a drained queue must not erase the history of what
1677
+ * went through it. Memory-only, like {@link drops}: operational, not durable. */
1678
+ traffic = /* @__PURE__ */ new Map();
1679
+ bumpTraffic(peerId, field) {
1680
+ const row = this.traffic.get(peerId) ?? { sent: 0, received: 0, acked: 0 };
1681
+ row[field]++;
1682
+ this.traffic.set(peerId, row);
1683
+ }
1684
+ /**
1685
+ * Observability snapshot: per-peer and global counters.
1686
+ *
1687
+ * Everything here is either derived from live state or from a counter the hub
1688
+ * already keeps — nothing is estimated, and nothing is sampled. `messagesTotal`
1689
+ * is the history `seq` (monotonic, never resets in a run), so it is a true count
1690
+ * of composed messages rather than a rolling window.
1691
+ */
1692
+ metrics() {
1693
+ const tasksByStatus = {};
1694
+ for (const record of this.taskLedger.values()) {
1695
+ tasksByStatus[record.status] = (tasksByStatus[record.status] ?? 0) + 1;
1696
+ }
1697
+ const ids = /* @__PURE__ */ new Set([
1698
+ ...this.lastSeen.keys(),
1699
+ ...this.parkedAt.keys(),
1700
+ ...this.queues.keys(),
1701
+ ...this.traffic.keys()
1702
+ ]);
1703
+ const peers = [...ids].map((peerId) => {
1704
+ const row = this.traffic.get(peerId) ?? { sent: 0, received: 0, acked: 0 };
1705
+ const drop = this.drops.get(peerId) ?? { overflow: 0, expired: 0, rate: 0 };
1706
+ return {
1707
+ peerId,
1708
+ sent: row.sent,
1709
+ received: row.received,
1710
+ acked: row.acked,
1711
+ queued: this.queues.get(peerId)?.length ?? 0,
1712
+ inFlight: this.leases.get(peerId)?.length ?? 0,
1713
+ deadLettered: this.deadLetters.get(peerId)?.length ?? 0,
1714
+ droppedOverflow: drop.overflow,
1715
+ droppedExpired: drop.expired,
1716
+ droppedRate: drop.rate,
1717
+ // Same definition the roster uses: recent activity OR a live SSE channel.
1718
+ connected: this.isActive(peerId) || this.options.isPeerLive?.(peerId) === true
1719
+ };
1720
+ });
1721
+ return {
1722
+ global: {
1723
+ messagesTotal: this.seqCounter,
1724
+ peers: peers.length,
1725
+ connectedPeers: peers.filter((p) => p.connected).length,
1726
+ parkedPeers: peers.filter((p) => !p.connected && this.parkedAt.has(p.peerId)).length,
1727
+ waitersNow: [...this.waiters.values()].reduce((sum, list) => sum + list.length, 0),
1728
+ queuedTotal: peers.reduce((sum, p) => sum + p.queued, 0),
1729
+ tasksByStatus,
1730
+ droppedOverflow: peers.reduce((sum, p) => sum + p.droppedOverflow, 0),
1731
+ droppedExpired: peers.reduce((sum, p) => sum + p.droppedExpired, 0),
1732
+ droppedRate: peers.reduce((sum, p) => sum + p.droppedRate, 0),
1733
+ deadLettered: peers.reduce((sum, p) => sum + p.deadLettered, 0),
1734
+ inFlight: peers.reduce((sum, p) => sum + p.inFlight, 0)
1735
+ },
1736
+ peers: peers.sort((a, b) => a.peerId.localeCompare(b.peerId))
1737
+ };
1738
+ }
1739
+ /** Count a dropped message for `peerId` and, if that peer is waiting right
1740
+ * now, tell it. The notice is NOT queued: the mailbox is either full
1741
+ * (overflow) or the message already expired, and queueing the notice would
1742
+ * push yet another message out. An offline peer just sees the counters. */
1743
+ /** May this peer's mailbox accept one more message right now?
1744
+ *
1745
+ * Applied on the QUEUE path only, never on a waiter handoff: a waiting peer
1746
+ * already controls its own rate (it gets one message per `bridge_wait`), while a
1747
+ * mailbox is filled by traffic the recipient never asked for. That is the whole
1748
+ * point of the bucket.
1749
+ */
1750
+ takeToken(peerId, now) {
1751
+ const rate = this.options.ratePerMin ?? 0;
1752
+ if (rate <= 0) return true;
1753
+ const bucket = this.buckets.get(peerId) ?? { tokens: rate, last: now };
1754
+ const elapsed = Math.max(0, now - bucket.last);
1755
+ bucket.tokens = Math.min(rate, bucket.tokens + elapsed * rate / 6e4);
1756
+ bucket.last = now;
1757
+ if (bucket.tokens < 1) {
1758
+ this.buckets.set(peerId, bucket);
1759
+ return false;
1760
+ }
1761
+ bucket.tokens -= 1;
1762
+ this.buckets.set(peerId, bucket);
1763
+ return true;
1764
+ }
1765
+ countDrop(peerId, kind, now) {
1766
+ const stats = this.drops.get(peerId) ?? { overflow: 0, expired: 0, rate: 0 };
1767
+ stats[kind] += 1;
1768
+ this.drops.set(peerId, stats);
1769
+ const total = stats.overflow + stats.expired + stats.rate;
1770
+ this.recordEvent("mailbox_dropped", { peer: peerId, detail: { reason: kind, total } });
1771
+ const notice = {
1772
+ id: randomUUID(),
1773
+ seq: ++this.seqCounter,
1774
+ from: HUB_PEER,
1775
+ to: peerId,
1776
+ kind: "notice",
1777
+ content: encodeContent({
1778
+ 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)`,
1779
+ level: "warn"
1780
+ }),
1781
+ ts: now
1136
1782
  };
1783
+ if (kind === "expired") {
1784
+ const queue = this.queues.get(peerId) ?? [];
1785
+ if (queue.length < this.options.maxQueue) {
1786
+ queue.push(notice);
1787
+ this.queues.set(peerId, queue);
1788
+ this.options.onQueued?.(notice, peerId);
1789
+ this.options.onMailboxesChanged?.();
1790
+ return;
1791
+ }
1792
+ }
1793
+ this.deliverToWaiter(peerId, notice);
1794
+ }
1795
+ /** Drop counters for `peerId` (undefined when nothing was ever dropped). */
1796
+ dropsFor(peerId) {
1797
+ return this.drops.get(peerId);
1798
+ }
1799
+ /** Hand `message` to a matching live waiter, if there is one. Never queues —
1800
+ * used for notices about a mailbox that must not grow. Returns whether it
1801
+ * was handed over. */
1802
+ deliverToWaiter(target, message) {
1803
+ const list = this.waiters.get(target) ?? [];
1804
+ const index = list.findIndex((waiter2) => {
1805
+ if (waiter2.from !== void 0 && waiter2.from !== message.from) return false;
1806
+ if (waiter2.ref !== void 0 && !(message.kind === "ack" && message.ref === waiter2.ref)) return false;
1807
+ if (waiter2.threadId !== void 0 && message.threadId !== waiter2.threadId) return false;
1808
+ return true;
1809
+ });
1810
+ if (index < 0) return false;
1811
+ const [waiter] = list.splice(index, 1);
1812
+ this.waiters.set(target, list);
1813
+ clearTimeout(waiter.timer);
1814
+ waiter.resolve({ type: "message", message: this.handOut(target, message, waiter.manual === true, message.ts) });
1815
+ return true;
1816
+ }
1817
+ static leaseKey(peerId, messageId) {
1818
+ return `${peerId}\0${messageId}`;
1819
+ }
1820
+ /** Return expired leases to the FRONT of the queue (order preserved), or
1821
+ * dead-letter them once they have been handed out too many times. Called
1822
+ * before every take, so expiry needs no timer of its own. */
1823
+ reapLeases(peerId, now) {
1824
+ const list = this.leases.get(peerId);
1825
+ if (list === void 0 || list.length === 0) return;
1826
+ const max = this.options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1827
+ const still = [];
1828
+ const back = [];
1829
+ for (const lease of list) {
1830
+ if (lease.expiresAt > now) {
1831
+ still.push(lease);
1832
+ continue;
1833
+ }
1834
+ const attempts = this.leaseAttempts.get(_AgentHub.leaseKey(peerId, lease.message.id)) ?? 0;
1835
+ if (max > 0 && attempts >= max) {
1836
+ const dead = this.deadLetters.get(peerId) ?? [];
1837
+ dead.push(lease.message);
1838
+ this.deadLetters.set(peerId, dead);
1839
+ continue;
1840
+ }
1841
+ back.push(lease.message);
1842
+ }
1843
+ if (still.length > 0) this.leases.set(peerId, still);
1844
+ else this.leases.delete(peerId);
1845
+ if (back.length > 0) {
1846
+ const queue = this.queues.get(peerId) ?? [];
1847
+ insertByPriority(queue, back);
1848
+ this.queues.set(peerId, queue);
1849
+ this.options.onMailboxesChanged?.();
1850
+ }
1851
+ }
1852
+ /** Hand a message to `peerId`. Without `manual` it is gone; with it the hub
1853
+ * keeps a lease and the peer must `commit` or `nack` before it expires. */
1854
+ handOut(peerId, message, manual, now) {
1855
+ if (!manual) return message;
1856
+ const key = _AgentHub.leaseKey(peerId, message.id);
1857
+ const attempts = (this.leaseAttempts.get(key) ?? 0) + 1;
1858
+ this.leaseAttempts.set(key, attempts);
1859
+ const expiresAt = now + (this.options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS);
1860
+ const list = this.leases.get(peerId) ?? [];
1861
+ list.push({ message, expiresAt });
1862
+ this.leases.set(peerId, list);
1863
+ return { ...message, attempts, leaseExpiresAt: expiresAt };
1864
+ }
1865
+ /** Mark leased messages as consumed; returns how many leases were released. */
1866
+ commit(peerId, ids) {
1867
+ const wanted = new Set(ids);
1868
+ const list = this.leases.get(peerId);
1869
+ if (list === void 0) return 0;
1870
+ const still = list.filter((lease) => !wanted.has(lease.message.id));
1871
+ const released = list.length - still.length;
1872
+ for (const id of wanted) this.leaseAttempts.delete(_AgentHub.leaseKey(peerId, id));
1873
+ if (still.length > 0) this.leases.set(peerId, still);
1874
+ else this.leases.delete(peerId);
1875
+ return released;
1876
+ }
1877
+ /** Give messages back NOW — from live leases or from the dead letters. Attempts
1878
+ * are kept, so a message that keeps failing still eventually dead-letters;
1879
+ * reviving a dead letter resets its count, which is the point of reviving it. */
1880
+ nack(peerId, ids) {
1881
+ const wanted = new Set(ids);
1882
+ const back = [];
1883
+ const list = this.leases.get(peerId);
1884
+ if (list !== void 0) {
1885
+ const still = [];
1886
+ for (const lease of list) {
1887
+ if (wanted.has(lease.message.id)) back.push(lease.message);
1888
+ else still.push(lease);
1889
+ }
1890
+ if (still.length > 0) this.leases.set(peerId, still);
1891
+ else this.leases.delete(peerId);
1892
+ }
1893
+ const dead = this.deadLetters.get(peerId);
1894
+ if (dead !== void 0) {
1895
+ const still = [];
1896
+ for (const message of dead) {
1897
+ if (wanted.has(message.id)) {
1898
+ back.push(message);
1899
+ this.leaseAttempts.delete(_AgentHub.leaseKey(peerId, message.id));
1900
+ } else still.push(message);
1901
+ }
1902
+ if (still.length > 0) this.deadLetters.set(peerId, still);
1903
+ else this.deadLetters.delete(peerId);
1904
+ }
1905
+ if (back.length === 0) return 0;
1906
+ const queue = this.queues.get(peerId) ?? [];
1907
+ insertByPriority(queue, back);
1908
+ this.queues.set(peerId, queue);
1909
+ this.options.onMailboxesChanged?.();
1910
+ return back.length;
1137
1911
  }
1138
1912
  /** Non-blocking drain of everything queued for `peer` (optionally from one sender). */
1139
- poll(peerId, from) {
1913
+ poll(peerId, from, options) {
1914
+ const now = Date.now();
1915
+ this.reapLeases(peerId, now);
1140
1916
  const queue = this.queues.get(peerId) ?? [];
1917
+ for (let i = queue.length - 1; i >= 0; i--) {
1918
+ const queued = queue[i];
1919
+ if (queued.expiresAt !== void 0 && queued.expiresAt <= now) {
1920
+ queue.splice(i, 1);
1921
+ this.countDrop(peerId, "expired", now);
1922
+ }
1923
+ }
1141
1924
  const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
1142
1925
  if (drained.length > 0) this.options.onMailboxesChanged?.();
1143
- return drained;
1926
+ return drained.map((message) => this.handOut(peerId, message, options?.manual === true, now));
1144
1927
  }
1145
1928
  /**
1146
1929
  * Long-poll for the next message addressed to `peer`: resolves immediately
@@ -1153,7 +1936,7 @@ var AgentHub = class {
1153
1936
  * a multi-message burst (or a reconnect with a full queue) would silently
1154
1937
  * lose messages that never reach wait/poll again.
1155
1938
  */
1156
- wait(peerId, timeoutMs, from, signal, ref, threadId) {
1939
+ wait(peerId, timeoutMs, from, signal, ref, threadId, manual, maxMessages = 1) {
1157
1940
  const startedAt = Date.now();
1158
1941
  const matches = (message) => {
1159
1942
  if (from !== void 0 && message.from !== from) return false;
@@ -1161,13 +1944,28 @@ var AgentHub = class {
1161
1944
  if (threadId !== void 0 && message.threadId !== threadId) return false;
1162
1945
  return true;
1163
1946
  };
1947
+ this.reapLeases(peerId, startedAt);
1164
1948
  const queue = this.queues.get(peerId);
1949
+ if (queue !== void 0) {
1950
+ for (let i = queue.length - 1; i >= 0; i--) {
1951
+ const queued = queue[i];
1952
+ if (queued.expiresAt !== void 0 && queued.expiresAt <= startedAt) {
1953
+ queue.splice(i, 1);
1954
+ this.countDrop(peerId, "expired", startedAt);
1955
+ }
1956
+ }
1957
+ }
1165
1958
  if (queue !== void 0 && queue.length > 0) {
1166
- const index = queue.findIndex(matches);
1167
- if (index >= 0) {
1168
- const [queued] = queue.splice(index, 1);
1959
+ const take = Math.max(1, Math.floor(maxMessages));
1960
+ const taken = [];
1961
+ for (let i = 0; i < queue.length && taken.length < take; ) {
1962
+ if (matches(queue[i])) taken.push(...queue.splice(i, 1));
1963
+ else i++;
1964
+ }
1965
+ if (taken.length > 0) {
1169
1966
  this.options.onMailboxesChanged?.();
1170
- return Promise.resolve({ type: "message", message: queued });
1967
+ const delivered = taken.map((queued) => this.handOut(peerId, queued, manual === true, startedAt));
1968
+ return Promise.resolve(take > 1 ? { type: "messages", messages: delivered } : { type: "message", message: delivered[0] });
1171
1969
  }
1172
1970
  }
1173
1971
  const budget = Math.max(1, Math.min(Math.floor(timeoutMs), this.options.waitTimeoutMs));
@@ -1204,29 +2002,42 @@ var AgentHub = class {
1204
2002
  onAbort,
1205
2003
  ...from !== void 0 ? { from } : {},
1206
2004
  ...ref !== void 0 ? { ref } : {},
1207
- ...threadId !== void 0 ? { threadId } : {}
2005
+ ...threadId !== void 0 ? { threadId } : {},
2006
+ ...manual === true ? { manual } : {},
2007
+ // A batch request keeps the batch SHAPE even when the wait ends with a
2008
+ // single message, so the caller's field access does not depend on timing.
2009
+ ...maxMessages > 1 ? { batch: true } : {}
1208
2010
  });
1209
2011
  this.waiters.set(peerId, list);
1210
2012
  });
1211
2013
  }
1212
2014
  /** Create a message from `from` addressed to `to` and deliver it. `to` may
1213
2015
  * be a peer id, {@link BROADCAST}, or a group id. */
1214
- route(from, to, kind, content, ref, threadId) {
1215
- if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
2016
+ route(from, to, kind, content, ref, threadId, expiresAt, system = false, priority) {
2017
+ const message = this.compose(from, to, kind, content, ref, threadId, expiresAt, system, priority);
2018
+ this.dispatch(message);
2019
+ return message;
2020
+ }
2021
+ /** Validate, build, and record a message from `from` to `to` (`to` may be a
2022
+ * peer id, {@link BROADCAST}, or a group id). Composing and dispatching are
2023
+ * separate steps because a caller may need ledger state to exist before
2024
+ * anything reaches a recipient — {@link sendTask} books delivery times. */
2025
+ compose(from, to, kind, content, ref, threadId, expiresAt, system = false, priority) {
2026
+ if (!system && !this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1216
2027
  const group = this.groups.get(to);
1217
- if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
2028
+ const known = this.lastSeen.has(to) || this.parkedAt.has(to);
2029
+ if (to !== BROADCAST && group === void 0 && !known) {
1218
2030
  throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1219
2031
  }
1220
2032
  let hop = 0;
1221
2033
  if (ref !== void 0) {
1222
2034
  const parent = this.historyRing.findLast((m) => m.id === ref);
1223
- hop = (parent?.hop ?? 0) + 1;
1224
- if (hop > MAX_HOP) {
1225
- throw new Error(`hop limit exceeded (max ${MAX_HOP}): start a new task/thread instead of extending this chain`);
1226
- }
2035
+ const ceiling = kind === "task" && parent?.kind === "task" ? MAX_HOP : MAX_REPLY_CHAIN;
2036
+ hop = Math.min((parent?.hop ?? 0) + 1, ceiling);
1227
2037
  }
1228
2038
  const message = {
1229
2039
  id: randomUUID(),
2040
+ seq: ++this.seqCounter,
1230
2041
  from,
1231
2042
  to,
1232
2043
  kind,
@@ -1235,27 +2046,45 @@ var AgentHub = class {
1235
2046
  ...threadId !== void 0 ? { threadId } : {},
1236
2047
  ...group !== void 0 ? { channel: group.id } : {},
1237
2048
  ...hop > 0 ? { hop } : {},
2049
+ ...expiresAt !== void 0 ? { expiresAt } : {},
2050
+ // Only a real priority is stored: an unset one must not appear on the wire,
2051
+ // or every reader has to distinguish 0 from "absent" for no reason.
2052
+ ...priority !== void 0 ? { priority } : {},
1238
2053
  ts: Date.now()
1239
2054
  };
1240
- this.lastSeen.set(from, message.ts);
2055
+ if (!system) this.lastSeen.set(from, message.ts);
2056
+ this.bumpTraffic(from, "sent");
2057
+ if (kind === "ack") this.bumpTraffic(from, "acked");
1241
2058
  this.remember(message);
1242
- if (to === BROADCAST) {
1243
- for (const peer of this.peers()) {
1244
- if (peer !== from) this.deliver(peer, message);
2059
+ return message;
2060
+ }
2061
+ /** Fan a composed message out to its recipients: broadcast, group members,
2062
+ * or the single addressed peer. */
2063
+ dispatch(message) {
2064
+ if (message.to === BROADCAST) {
2065
+ for (const peer of this.audience()) {
2066
+ if (peer !== message.from) this.deliver(peer, message);
1245
2067
  }
1246
- } else if (group !== void 0) {
2068
+ return;
2069
+ }
2070
+ const group = this.groups.get(message.to);
2071
+ if (group !== void 0) {
1247
2072
  for (const member of group.members) {
1248
- if (member !== from) this.deliver(member, message);
2073
+ if (member !== message.from) this.deliver(member, message);
1249
2074
  }
1250
- } else {
1251
- this.deliver(to, message);
2075
+ return;
1252
2076
  }
1253
- return message;
2077
+ this.deliver(message.to, message);
1254
2078
  }
1255
2079
  /** Queue or hand off a message; wake the first matching waiter for its target.
1256
2080
  * History / onMessage are recorded by the caller (route / sendToGroup) —
1257
2081
  * exactly once per message, never per fan-out target. */
1258
2082
  deliver(target, message) {
2083
+ this.bumpTraffic(target, "received");
2084
+ if (message.expiresAt !== void 0 && message.expiresAt <= Date.now()) {
2085
+ this.countDrop(target, "expired", Date.now());
2086
+ return;
2087
+ }
1259
2088
  const list = this.waiters.get(target) ?? [];
1260
2089
  const index = list.findIndex((waiter) => {
1261
2090
  if (waiter.from !== void 0 && waiter.from !== message.from) return false;
@@ -1268,14 +2097,26 @@ var AgentHub = class {
1268
2097
  const [waiter] = list.splice(index, 1);
1269
2098
  this.waiters.set(target, list);
1270
2099
  clearTimeout(waiter.timer);
1271
- waiter.resolve({ type: "message", message });
2100
+ const delivered = this.handOut(target, message, waiter.manual === true, message.ts);
2101
+ waiter.resolve(waiter.batch === true ? { type: "messages", messages: [delivered] } : { type: "message", message: delivered });
2102
+ return;
2103
+ }
2104
+ if (!this.takeToken(target, Date.now())) {
2105
+ this.countDrop(target, "rate", Date.now());
1272
2106
  return;
1273
2107
  }
1274
2108
  const queue = this.queues.get(target) ?? [];
1275
- if (queue.length >= this.options.maxQueue) queue.shift();
1276
- queue.push(message);
2109
+ if (queue.length >= this.options.maxQueue) {
2110
+ const lowest = priorityOf(queue[queue.length - 1]);
2111
+ let victim = queue.length - 1;
2112
+ while (victim > 0 && priorityOf(queue[victim - 1]) === lowest) victim--;
2113
+ queue.splice(victim, 1);
2114
+ this.countDrop(target, "overflow", Date.now());
2115
+ }
2116
+ insertByPriority(queue, [message]);
1277
2117
  this.queues.set(target, queue);
1278
2118
  this.options.onQueued?.(message, target);
2119
+ if (message.kind === "task") this.options.onTaskQueued?.(message, target);
1279
2120
  }
1280
2121
  /** Append a sent message to the history ring (and fire the persistence /
1281
2122
  * relay hooks). Exactly one call per message — see route() / sendToGroup(). */
@@ -1285,6 +2126,20 @@ var AgentHub = class {
1285
2126
  this.options.onMessage?.(message);
1286
2127
  }
1287
2128
  };
2129
+ function importHop(value) {
2130
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2131
+ }
2132
+ function insertByPriority(queue, messages) {
2133
+ for (const message of messages) {
2134
+ let at = queue.length;
2135
+ while (at > 0 && priorityOf(queue[at - 1]) < priorityOf(message)) at--;
2136
+ queue.splice(at, 0, message);
2137
+ }
2138
+ }
2139
+ function priorityOf(message) {
2140
+ const value = message.priority;
2141
+ return value === 1 || value === 2 ? value : 0;
2142
+ }
1288
2143
  function drainFrom(queue, from) {
1289
2144
  const kept = [];
1290
2145
  const drained = [];
@@ -1306,6 +2161,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
1306
2161
  import { dirname } from "node:path";
1307
2162
  var ANON_JOIN_PEER = "__join__";
1308
2163
  var TOKEN_PATTERN = /^[A-Za-z0-9._:-]{16,128}$/;
2164
+ var PLACEHOLDER_TOKEN = /CHANGE[-_]?ME/i;
1309
2165
  function loadTokenFile(file) {
1310
2166
  let parsed;
1311
2167
  try {
@@ -1324,6 +2180,9 @@ function loadTokenFile(file) {
1324
2180
  if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
1325
2181
  throw new Error(msg("has an invalid token (expect 16-128 chars of [A-Za-z0-9._:-])"));
1326
2182
  }
2183
+ if (PLACEHOLDER_TOKEN.test(token)) {
2184
+ throw new Error(msg("uses the example placeholder token \u2014 generate one with `agent-comm-hub auth gen`"));
2185
+ }
1327
2186
  if (typeof peer !== "string" || !PEER_ID_PATTERN.test(peer) || peer === "all") {
1328
2187
  throw new Error(msg(`has an invalid peer id '${String(peer)}'`));
1329
2188
  }
@@ -1339,8 +2198,19 @@ function loadTokenFile(file) {
1339
2198
  peers.add(peer);
1340
2199
  });
1341
2200
  if (table.size === 0) throw new Error(`auth token file ${file}: no tokens defined`);
2201
+ warnIfTokenFileIsReadableByOthers(file);
1342
2202
  return table;
1343
2203
  }
2204
+ function warnIfTokenFileIsReadableByOthers(file) {
2205
+ if (process.platform === "win32") return;
2206
+ try {
2207
+ const mode = statSync(file).mode & 511;
2208
+ if ((mode & 63) !== 0) {
2209
+ console.warn(`warning: ${file} is readable by group/other (mode ${mode.toString(8)}) \u2014 chmod 600 it`);
2210
+ }
2211
+ } catch {
2212
+ }
2213
+ }
1344
2214
  function tokenTable(map) {
1345
2215
  return {
1346
2216
  authenticate: (token) => token === void 0 ? void 0 : map.get(token)
@@ -1436,6 +2306,9 @@ function loadTokenFileVoid(rows) {
1436
2306
  const peers = /* @__PURE__ */ new Set();
1437
2307
  for (const { token, peer, role, owner } of rows) {
1438
2308
  if (!TOKEN_PATTERN.test(token)) throw new Error(`invalid token for peer '${peer}'`);
2309
+ if (PLACEHOLDER_TOKEN.test(token)) {
2310
+ throw new Error(`example placeholder token for peer '${peer}' \u2014 generate one with \`agent-comm-hub auth gen\``);
2311
+ }
1439
2312
  if (!PEER_ID_PATTERN.test(peer) || peer === "all") throw new Error(`invalid peer id '${peer}'`);
1440
2313
  if (role !== "agent" && role !== "manager") throw new Error(`invalid role '${role}'`);
1441
2314
  if (table.has(token)) throw new Error("duplicate token");
@@ -1455,6 +2328,9 @@ function present(message) {
1455
2328
  ...channel !== void 0 ? { channel } : {},
1456
2329
  ...threadId !== void 0 ? { threadId } : {},
1457
2330
  ...hop !== void 0 ? { hop } : {},
2331
+ // A2A speaks `taskId` / `contextId`; our names stay, these ride along.
2332
+ ...message.kind === "ack" && ref !== void 0 ? { taskId: ref } : {},
2333
+ ...threadId !== void 0 ? { contextId: threadId } : {},
1458
2334
  content: decodeContent(message.kind, message.content)
1459
2335
  };
1460
2336
  }
@@ -1519,13 +2395,28 @@ function rosterFor(hub, registry) {
1519
2395
  ...alias !== void 0 ? { alias } : {},
1520
2396
  ...clientName !== void 0 ? { clientName } : {},
1521
2397
  ...clientVersion !== void 0 ? { clientVersion } : {},
2398
+ // The announced card rides with the roster: `bridge_peers` is how an agent
2399
+ // sees who else is here, and a card nobody can read is not announced.
2400
+ ...profile?.card !== void 0 ? { card: profile.card } : {},
1522
2401
  ...peerAnon.get(peer.id) === true ? { anonymous: true } : {},
1523
2402
  ...ips !== void 0 && ips.length > 0 ? { clientIps: ips } : {},
1524
2403
  connected: peer.connected,
2404
+ // W7: keep the reachability verdict and the queue depth in the tool output —
2405
+ // `connected: false` alone cannot say "idle, will read it later" apart from
2406
+ // "gone, and your message is nowhere".
2407
+ reachable: peer.reachable,
2408
+ queued: peer.queued,
1525
2409
  lastSeenMs: peer.lastSeenMs
1526
2410
  };
1527
2411
  });
1528
2412
  }
2413
+ function isManagerCaller(registry, sessionId, peer, options) {
2414
+ if (registry.tokenFor(sessionId)?.role === "manager") return true;
2415
+ if (options.managerTokenRequired === true) return registry.isManagerSession(sessionId);
2416
+ const managers = options.managerPeers ?? "all";
2417
+ if (managers === "all") return true;
2418
+ return peer !== void 0 && managers.has(peer);
2419
+ }
1529
2420
  function hubTools(hub, registry, options) {
1530
2421
  const schema = (properties, required = []) => ({
1531
2422
  type: "object",
@@ -1536,6 +2427,86 @@ function hubTools(hub, registry, options) {
1536
2427
  const str = (description) => ({ type: "string", description });
1537
2428
  const int = (description) => ({ type: "integer", description });
1538
2429
  const optStr = str;
2430
+ const priorityField = () => ({
2431
+ type: "integer",
2432
+ enum: [0, 1, 2],
2433
+ 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."
2434
+ });
2435
+ const optPriority = (value) => value === 0 || value === 1 || value === 2 ? { priority: value } : {};
2436
+ const idList = (value, tool) => {
2437
+ if (!Array.isArray(value)) throw new Error(`${tool} expects ids: an array of message ids`);
2438
+ const ids = value.filter((id) => typeof id === "string" && id !== "");
2439
+ if (ids.length === 0) throw new Error(`${tool} expects at least one message id`);
2440
+ return ids;
2441
+ };
2442
+ const deadline = (value) => {
2443
+ const at = Number(value);
2444
+ if (!Number.isFinite(at) || at <= 0) throw new Error(`invalid expiresAt: ${String(value)} (expected epoch milliseconds)`);
2445
+ if (at <= Date.now()) throw new Error(`expiresAt is already in the past: ${new Date(at).toISOString()}`);
2446
+ return at;
2447
+ };
2448
+ const parseArtifacts = (value) => {
2449
+ if (value === void 0) return void 0;
2450
+ if (!Array.isArray(value)) throw new Error("artifacts must be an array of {name, text?/url?} objects");
2451
+ const out = [];
2452
+ for (const [index, entry] of value.entries()) {
2453
+ if (typeof entry !== "object" || entry === null) throw new Error(`artifacts[${index}] must be an object`);
2454
+ const raw = entry;
2455
+ if (typeof raw.name !== "string" || raw.name === "") throw new Error(`artifacts[${index}].name is required`);
2456
+ const artifact = { name: raw.name };
2457
+ if (typeof raw.mimeType === "string" && raw.mimeType !== "") artifact.mimeType = raw.mimeType;
2458
+ if (typeof raw.text === "string") artifact.text = raw.text;
2459
+ if (typeof raw.url === "string" && raw.url !== "") artifact.url = raw.url;
2460
+ if (artifact.text === void 0 && artifact.url === void 0) {
2461
+ throw new Error(`artifacts[${index}] needs text or url \u2014 an artifact with neither says nothing`);
2462
+ }
2463
+ out.push(artifact);
2464
+ }
2465
+ return out.length > 0 ? out : void 0;
2466
+ };
2467
+ const parseCard = (value) => {
2468
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("card must be an object");
2469
+ const raw = value;
2470
+ const card = {};
2471
+ if (raw.skills !== void 0) {
2472
+ if (!Array.isArray(raw.skills)) throw new Error("card.skills must be an array");
2473
+ card.skills = raw.skills.map((entry, index) => {
2474
+ if (typeof entry !== "object" || entry === null) throw new Error(`card.skills[${index}] must be an object`);
2475
+ const skill = entry;
2476
+ if (typeof skill.id !== "string" || skill.id === "") throw new Error(`card.skills[${index}].id is required`);
2477
+ if (typeof skill.name !== "string" || skill.name === "") throw new Error(`card.skills[${index}].name is required`);
2478
+ return {
2479
+ id: skill.id,
2480
+ name: skill.name,
2481
+ ...typeof skill.description === "string" ? { description: skill.description } : {},
2482
+ ...Array.isArray(skill.tags) ? { tags: skill.tags.filter((tag) => typeof tag === "string") } : {}
2483
+ };
2484
+ });
2485
+ }
2486
+ if (raw.acceptsTasks !== void 0) {
2487
+ if (typeof raw.acceptsTasks !== "boolean") throw new Error("card.acceptsTasks must be a boolean");
2488
+ card.acceptsTasks = raw.acceptsTasks;
2489
+ }
2490
+ if (raw.concurrency !== void 0) {
2491
+ if (!Number.isInteger(raw.concurrency) || raw.concurrency < 0) throw new Error("card.concurrency must be a non-negative integer");
2492
+ card.concurrency = raw.concurrency;
2493
+ }
2494
+ for (const key of ["inputModes", "outputModes"]) {
2495
+ const list = raw[key];
2496
+ if (list === void 0) continue;
2497
+ if (!Array.isArray(list)) throw new Error(`card.${key} must be an array of strings`);
2498
+ card[key] = list.filter((entry) => typeof entry === "string");
2499
+ }
2500
+ if (raw.runner !== void 0) {
2501
+ if (!["herdr", "cli", "http", "none"].includes(String(raw.runner))) throw new Error("card.runner must be one of herdr | cli | http | none");
2502
+ card.runner = raw.runner;
2503
+ }
2504
+ if (raw.endpoint !== void 0) {
2505
+ if (typeof raw.endpoint !== "string" || raw.endpoint === "") throw new Error("card.endpoint must be a non-empty string");
2506
+ card.endpoint = raw.endpoint;
2507
+ }
2508
+ return card;
2509
+ };
1539
2510
  const requirePeer = (sessionId) => {
1540
2511
  const bound = registry.peerFor(sessionId);
1541
2512
  if (bound !== void 0) {
@@ -1552,16 +2523,26 @@ function hubTools(hub, registry, options) {
1552
2523
  const receipt = (message) => ({
1553
2524
  ok: true,
1554
2525
  id: message.id,
2526
+ // The sender needs this back: it is the cursor to record.
2527
+ ...message.seq !== void 0 ? { seq: message.seq } : {},
1555
2528
  from: message.from,
1556
2529
  to: message.to,
1557
2530
  kind: message.kind,
1558
2531
  ...message.ref !== void 0 ? { ref: message.ref } : {},
1559
2532
  ...message.channel !== void 0 ? { channel: message.channel } : {},
2533
+ // A2A aliases (additive): a task receipt answers with `taskId`, and a
2534
+ // threaded message carries its `contextId`.
2535
+ ...message.kind === "task" ? { taskId: message.id } : {},
2536
+ ...message.threadId !== void 0 ? { contextId: message.threadId } : {},
1560
2537
  ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
1561
2538
  ...message.hop !== void 0 ? { hop: message.hop } : {},
1562
2539
  ts: message.ts
1563
2540
  });
1564
- const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
2541
+ const presentWait = (result) => {
2542
+ if (result.type === "timeout") return result;
2543
+ if (result.type === "messages") return { type: "messages", messages: result.messages.map(present) };
2544
+ return { type: "message", message: present(result.message) };
2545
+ };
1565
2546
  const checkControl = (peer) => {
1566
2547
  const herdr = options.herdr;
1567
2548
  if (herdr === void 0) {
@@ -1574,17 +2555,27 @@ function hubTools(hub, registry, options) {
1574
2555
  return herdr;
1575
2556
  };
1576
2557
  const requireManager = (peer, action, sessionId) => {
1577
- if (registry.tokenFor(sessionId)?.role === "manager") return;
1578
- const managers = options.managerPeers ?? "all";
1579
- if (managers !== "all" && !managers.has(peer)) {
1580
- throw new Error(`peer '${peer}' is not a hub manager \u2014 ${action} requires manager rights`);
1581
- }
2558
+ if (isManagerCaller(registry, sessionId, peer, options)) return;
2559
+ throw new Error(
2560
+ 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`
2561
+ );
1582
2562
  };
1583
2563
  const asStatuses = (value) => {
1584
2564
  if (!Array.isArray(value)) return void 0;
1585
2565
  const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
1586
2566
  return statuses.length > 0 ? statuses : void 0;
1587
2567
  };
2568
+ const requireArgs = (tool, handler) => {
2569
+ const required = tool.inputSchema.required ?? [];
2570
+ if (required.length === 0) return handler;
2571
+ return async (args, sessionId) => {
2572
+ const missing = required.filter((name) => args?.[name] === void 0);
2573
+ if (missing.length > 0) {
2574
+ throw new Error(`missing required argument(s) for ${tool.name}: ${missing.join(", ")}`);
2575
+ }
2576
+ return handler(args, sessionId);
2577
+ };
2578
+ };
1588
2579
  const wrap = (peerAware, handler) => async (args, sessionId) => {
1589
2580
  const peer = peerAware ? requirePeer(sessionId) : "";
1590
2581
  return handler(args, peer, sessionId);
@@ -1598,13 +2589,20 @@ function hubTools(hub, registry, options) {
1598
2589
  alias: optStr("Optional display name (1-64 chars, spaces/CJK allowed). Routing still uses peerId.")
1599
2590
  }, ["peerId"]),
1600
2591
  handler: async (args, sessionId) => {
1601
- const peerId = String(args.peerId);
2592
+ const boundId = registry.peerFor(sessionId);
2593
+ const peerId = args.peerId === void 0 ? boundId : String(args.peerId);
2594
+ if (peerId === void 0) {
2595
+ throw new Error('no peer to register \u2014 pass peerId (e.g. {"peerId":"mavis"}); this session is not bound to a peer yet');
2596
+ }
1602
2597
  if (!PEER_ID_PATTERN.test(peerId)) {
1603
2598
  throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
1604
2599
  }
1605
2600
  if (peerId === BROADCAST) {
1606
2601
  throw new Error(`reserved peer id: ${peerId} (it is the broadcast address)`);
1607
2602
  }
2603
+ if (peerId === HUB_PEER) {
2604
+ throw new Error(`reserved peer id: ${peerId} (the hub uses it for its own notices)`);
2605
+ }
1608
2606
  const alias = args.alias === void 0 ? void 0 : sanitizeAlias(String(args.alias));
1609
2607
  const tokenPeer = registry.tokenFor(sessionId)?.peer;
1610
2608
  if (tokenPeer !== void 0 && peerId !== tokenPeer) {
@@ -1623,8 +2621,8 @@ function hubTools(hub, registry, options) {
1623
2621
  if (registry.attachedCount(current) === 0) hub.unregister(current);
1624
2622
  }
1625
2623
  }
1626
- if (!hub.has(peerId)) hub.register(peerId);
1627
2624
  registry.bindPeer(sessionId, peerId);
2625
+ if (!hub.has(peerId)) hub.register(peerId);
1628
2626
  registry.clearSuppress(sessionId);
1629
2627
  hub.touch(peerId);
1630
2628
  if (alias !== void 0) hub.setAlias(peerId, alias);
@@ -1652,7 +2650,7 @@ function hubTools(hub, registry, options) {
1652
2650
  if (caller === void 0) throw new Error("not registered \u2014 call bridge_register(peerId) first");
1653
2651
  const target = String(args.peer);
1654
2652
  requireManager(caller, `kicking '${target}'`, sessionId);
1655
- if (!hub.has(target)) {
2653
+ if (!hub.has(target) && !hub.isParked(target)) {
1656
2654
  return { ok: true, peerId: null, kicked: false };
1657
2655
  }
1658
2656
  hub.unregister(target);
@@ -1700,32 +2698,67 @@ function hubTools(hub, registry, options) {
1700
2698
  },
1701
2699
  {
1702
2700
  name: "bridge_chat",
1703
- 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.',
2701
+ 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.',
1704
2702
  inputSchema: schema(
1705
2703
  {
1706
2704
  to: str('Target peerId, group id, or "all" to broadcast.'),
1707
2705
  message: str("The message text."),
1708
2706
  threadId: optStr("Optional conversation / workflow thread id."),
1709
- ref: optStr("Optional reply-to message id.")
2707
+ priority: priorityField(),
2708
+ ref: optStr("Optional reply-to message id."),
2709
+ 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.")
1710
2710
  },
1711
2711
  ["to", "message"]
1712
2712
  ),
1713
2713
  handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message), {
1714
2714
  ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
1715
- ...args.ref !== void 0 ? { ref: String(args.ref) } : {}
2715
+ ...optPriority(args.priority),
2716
+ ...args.ref !== void 0 ? { ref: String(args.ref) } : {},
2717
+ ...args.expiresAt !== void 0 ? { expiresAt: deadline(args.expiresAt) } : {}
1716
2718
  })))
1717
2719
  },
2720
+ {
2721
+ name: "bridge_notice",
2722
+ 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.',
2723
+ inputSchema: schema(
2724
+ {
2725
+ to: str('Target peerId, group id, or "all" to broadcast.'),
2726
+ message: str("The notice text."),
2727
+ level: { type: "string", enum: ["info", "warn", "error"], description: "Severity (default info)." },
2728
+ threadId: optStr("Optional conversation / workflow thread id."),
2729
+ priority: priorityField()
2730
+ },
2731
+ ["to", "message"]
2732
+ ),
2733
+ handler: wrap(true, async (args, peer) => {
2734
+ const level = args.level === void 0 ? void 0 : String(args.level);
2735
+ if (level !== void 0 && level !== "info" && level !== "warn" && level !== "error") {
2736
+ throw new Error(`invalid level: ${level} (expected info | warn | error)`);
2737
+ }
2738
+ const payload = {
2739
+ message: String(args.message),
2740
+ ...level !== void 0 ? { level } : {}
2741
+ };
2742
+ return receipt(hub.send(peer, String(args.to), "notice", encodeContent(payload), {
2743
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
2744
+ ...optPriority(args.priority)
2745
+ }));
2746
+ })
2747
+ },
1718
2748
  {
1719
2749
  name: "bridge_task",
1720
- 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.',
2750
+ 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).',
1721
2751
  inputSchema: schema(
1722
2752
  {
1723
2753
  to: str('Target peerId, group id, or "all" to broadcast.'),
1724
2754
  prompt: str("What the receiving agent should do."),
1725
2755
  context: optStr("Optional background information for the task."),
1726
2756
  deliverable: optStr("Optional expected deliverable description."),
1727
- timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1728
- threadId: optStr("Optional conversation / workflow thread id.")
2757
+ 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."),
2758
+ threadId: optStr("Optional conversation / workflow thread id."),
2759
+ priority: priorityField(),
2760
+ 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)."),
2761
+ 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.")
1729
2762
  },
1730
2763
  ["to", "prompt"]
1731
2764
  ),
@@ -1734,8 +2767,9 @@ function hubTools(hub, registry, options) {
1734
2767
  ...args.context !== void 0 ? { context: String(args.context) } : {},
1735
2768
  ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {},
1736
2769
  ...args.timeoutMs !== void 0 ? { timeoutMs: Number(args.timeoutMs) } : {},
1737
- ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {}
1738
- })))
2770
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
2771
+ ...optPriority(args.priority)
2772
+ }, args.ref === void 0 ? void 0 : String(args.ref), args.expiresAt === void 0 ? void 0 : deadline(args.expiresAt))))
1739
2773
  },
1740
2774
  {
1741
2775
  name: "bridge_ack",
@@ -1743,21 +2777,24 @@ function hubTools(hub, registry, options) {
1743
2777
  inputSchema: schema(
1744
2778
  {
1745
2779
  ref: str("The id of the message being acknowledged."),
1746
- status: { type: "string", enum: ["accepted", "working", "rejected", "done", "failed"], description: "acknowledgement status." },
2780
+ 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." },
1747
2781
  note: optStr("Optional explanation for the acknowledgement."),
2782
+ 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" } },
1748
2783
  progress: int("Optional 0-100 progress (meaningful with status=working).")
1749
2784
  },
1750
2785
  ["ref", "status"]
1751
2786
  ),
1752
2787
  handler: wrap(true, async (args, peer) => {
1753
2788
  const status = String(args.status);
1754
- const valid = ["accepted", "working", "rejected", "done", "failed"];
2789
+ const valid = CLIENT_ACK_STATUSES;
1755
2790
  if (!valid.includes(status)) {
1756
2791
  throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
1757
2792
  }
2793
+ const artifacts = parseArtifacts(args.artifacts);
1758
2794
  return receipt(hub.sendAck(peer, String(args.ref), {
1759
2795
  status,
1760
2796
  ...args.note !== void 0 ? { note: String(args.note) } : {},
2797
+ ...artifacts !== void 0 ? { artifacts } : {},
1761
2798
  ...args.progress !== void 0 ? { progress: Number(args.progress) } : {}
1762
2799
  }));
1763
2800
  })
@@ -1796,26 +2833,99 @@ function hubTools(hub, registry, options) {
1796
2833
  },
1797
2834
  {
1798
2835
  name: "bridge_task_status",
1799
- 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).",
2836
+ 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.",
1800
2837
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1801
2838
  handler: wrap(true, async (args, peer, sessionId) => {
1802
2839
  const ref = String(args.ref);
1803
2840
  const task = hub.taskOf(ref);
1804
2841
  if (task === void 0) throw new Error(`unknown task: ${ref}`);
1805
- if (task.from !== peer && task.to !== peer && task.to !== BROADCAST) {
2842
+ const isAssignee = task.assignees.some((assignee) => assignee.peerId === peer);
2843
+ const isGroupMember = hub.groupOf(task.to)?.members.includes(peer) ?? false;
2844
+ if (task.from !== peer && task.to !== peer && task.to !== BROADCAST && !isAssignee && !isGroupMember) {
1806
2845
  requireManager(peer, `reading task '${ref}'`, sessionId);
1807
2846
  }
1808
- return { task };
2847
+ return {
2848
+ task,
2849
+ // A2A naming aliases: the same task under the names an A2A client
2850
+ // already uses. `task.id` stays authoritative.
2851
+ taskId: task.id,
2852
+ ...task.threadId !== void 0 ? { contextId: task.threadId } : {},
2853
+ a2aState: a2aState(task.status)
2854
+ };
1809
2855
  })
1810
2856
  },
2857
+ {
2858
+ name: "bridge_task_cancel",
2859
+ 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.",
2860
+ inputSchema: schema(
2861
+ {
2862
+ ref: str("Task message id (the id returned by bridge_task)."),
2863
+ reason: optStr("Optional reason, delivered with the cancel and kept on the timeline.")
2864
+ },
2865
+ ["ref"]
2866
+ ),
2867
+ handler: wrap(true, async (args, peer) => receipt(hub.cancelTask(peer, String(args.ref), args.reason === void 0 ? void 0 : String(args.reason))))
2868
+ },
2869
+ {
2870
+ name: "bridge_task_input",
2871
+ 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.",
2872
+ inputSchema: schema(
2873
+ {
2874
+ ref: str("Task message id."),
2875
+ input: str("The information the assignee asked for.")
2876
+ },
2877
+ ["ref", "input"]
2878
+ ),
2879
+ handler: wrap(true, async (args, peer) => receipt(hub.taskInput(peer, String(args.ref), String(args.input))))
2880
+ },
2881
+ {
2882
+ name: "bridge_announce",
2883
+ 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.",
2884
+ inputSchema: schema(
2885
+ {
2886
+ card: {
2887
+ type: "object",
2888
+ description: 'Your card: {skills?: [{id, name, description?, tags?}], acceptsTasks?: boolean, concurrency?: number, inputModes?: string[], outputModes?: string[], runner?: "herdr"|"cli"|"http"|"none", endpoint?: string}.'
2889
+ }
2890
+ },
2891
+ ["card"]
2892
+ ),
2893
+ handler: wrap(true, async (args, peer) => {
2894
+ const profile = hub.announceCard(peer, parseCard(args.card));
2895
+ return { ok: true, peer, card: profile.card };
2896
+ })
2897
+ },
2898
+ {
2899
+ name: "bridge_discover",
2900
+ 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.",
2901
+ inputSchema: schema(
2902
+ {
2903
+ skill: optStr("Only peers declaring this skill id."),
2904
+ tag: optStr("Only peers whose skills carry this tag."),
2905
+ acceptsTasks: { type: "boolean", description: "Require the peer to accept tasks (default true). Pass false to drop that requirement." },
2906
+ idleOnly: { type: "boolean", description: "Only peers with nothing queued, no waiter and no running task." }
2907
+ },
2908
+ []
2909
+ ),
2910
+ handler: wrap(true, async (args) => ({
2911
+ peers: hub.discover({
2912
+ ...args.skill !== void 0 ? { skill: String(args.skill) } : {},
2913
+ ...args.tag !== void 0 ? { tag: String(args.tag) } : {},
2914
+ ...args.acceptsTasks !== void 0 ? { acceptsTasks: args.acceptsTasks === true } : {},
2915
+ ...args.idleOnly !== void 0 ? { idleOnly: args.idleOnly === true } : {}
2916
+ })
2917
+ }))
2918
+ },
1811
2919
  {
1812
2920
  name: "bridge_wait",
1813
- 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.",
2921
+ 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.',
1814
2922
  inputSchema: schema({
1815
2923
  from: optStr("Only wait for messages from this peerId."),
1816
2924
  ref: optStr("Only wait for the ack of this task message id (bridge_task id)."),
1817
2925
  threadId: optStr("Only wait for messages carrying this threadId."),
1818
- timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
2926
+ timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`),
2927
+ 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.'),
2928
+ ack: { type: "string", enum: ["auto", "manual"], description: '"auto" (default) consumes the message; "manual" leases it until bridge_commit (or bridge_nack).' }
1819
2929
  }),
1820
2930
  handler: wrap(true, async (args, peer) => presentWait(await hub.wait(
1821
2931
  peer,
@@ -1823,14 +2933,39 @@ function hubTools(hub, registry, options) {
1823
2933
  args.from === void 0 ? void 0 : String(args.from),
1824
2934
  void 0,
1825
2935
  args.ref === void 0 ? void 0 : String(args.ref),
1826
- args.threadId === void 0 ? void 0 : String(args.threadId)
2936
+ args.threadId === void 0 ? void 0 : String(args.threadId),
2937
+ args.ack === "manual",
2938
+ args.maxMessages === void 0 ? 1 : Math.max(1, Number(args.maxMessages))
1827
2939
  )))
1828
2940
  },
1829
2941
  {
1830
2942
  name: "bridge_poll",
1831
- description: "Non-blocking: drain every message currently queued for you. Empty list when nothing is waiting. `from` narrows to one sender.",
1832
- inputSchema: schema({ from: optStr("Only drain messages from this peerId.") }),
1833
- handler: wrap(true, async (args, peer) => ({ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from)).map(present) }))
2943
+ 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.',
2944
+ inputSchema: schema({
2945
+ from: optStr("Only drain messages from this peerId."),
2946
+ ack: { type: "string", enum: ["auto", "manual"], description: '"auto" (default) consumes; "manual" leases until bridge_commit.' }
2947
+ }),
2948
+ handler: wrap(true, async (args, peer) => ({
2949
+ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from), { manual: args.ack === "manual" }).map(present)
2950
+ }))
2951
+ },
2952
+ {
2953
+ name: "bridge_commit",
2954
+ 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.',
2955
+ inputSchema: schema(
2956
+ { ids: { type: "array", items: { type: "string" }, description: "Message ids you received and have finished handling." } },
2957
+ ["ids"]
2958
+ ),
2959
+ handler: wrap(true, async (args, peer) => ({ committed: hub.commit(peer, idList(args.ids, "bridge_commit")) }))
2960
+ },
2961
+ {
2962
+ name: "bridge_nack",
2963
+ 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.",
2964
+ inputSchema: schema(
2965
+ { ids: { type: "array", items: { type: "string" }, description: "Message ids to give back." } },
2966
+ ["ids"]
2967
+ ),
2968
+ handler: wrap(true, async (args, peer) => ({ requeued: hub.nack(peer, idList(args.ids, "bridge_nack")) }))
1834
2969
  },
1835
2970
  {
1836
2971
  name: "bridge_status",
@@ -1838,6 +2973,21 @@ function hubTools(hub, registry, options) {
1838
2973
  inputSchema: schema({}),
1839
2974
  handler: wrap(true, async () => hub.status(livePeersFor(registry)))
1840
2975
  },
2976
+ {
2977
+ name: "bridge_metrics",
2978
+ 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.",
2979
+ inputSchema: schema({}),
2980
+ handler: wrap(true, async () => hub.metrics())
2981
+ },
2982
+ {
2983
+ name: "bridge_events",
2984
+ 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.",
2985
+ inputSchema: schema({
2986
+ since: { type: "number", description: "Return only events with a seq greater than this (exclusive)." },
2987
+ limit: { type: "number", description: "Maximum events to return, newest kept (default 100, max 500)." }
2988
+ }),
2989
+ handler: wrap(true, async (args) => hub.events(args))
2990
+ },
1841
2991
  {
1842
2992
  name: "bridge_peers",
1843
2993
  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.",
@@ -1850,10 +3000,16 @@ function hubTools(hub, registry, options) {
1850
3000
  inputSchema: schema({
1851
3001
  peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'),
1852
3002
  channel: optStr("Read a group channel history instead of a peer conversation (you must be a member, or a manager)."),
1853
- limit: int("How many messages to return (default 20).")
3003
+ limit: int("How many messages to return (default 20)."),
3004
+ 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.')
1854
3005
  }),
1855
3006
  handler: wrap(true, async (args, peer, sessionId) => {
1856
3007
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
3008
+ const sinceRaw = args.since === void 0 ? void 0 : Number(args.since);
3009
+ if (sinceRaw !== void 0 && (!Number.isFinite(sinceRaw) || sinceRaw < 0)) {
3010
+ throw new Error(`invalid since: ${String(args.since)} (expected a non-negative sequence number)`);
3011
+ }
3012
+ const since = sinceRaw;
1857
3013
  const target = args.peer === void 0 ? peer : String(args.peer);
1858
3014
  if (target !== peer) {
1859
3015
  requireManager(peer, `reading ${target === BROADCAST ? "the full history" : `peer '${target}' history`}`, sessionId);
@@ -1863,9 +3019,9 @@ function hubTools(hub, registry, options) {
1863
3019
  const group = hub.groupOf(groupId);
1864
3020
  if (group === void 0) throw new Error(`unknown group: ${groupId}`);
1865
3021
  if (!group.members.includes(peer)) requireManager(peer, `reading group '${groupId}' history`, sessionId);
1866
- return { messages: hub.historyChannel(groupId, limit).map(present) };
3022
+ return { messages: hub.historyChannel(groupId, limit, since).map(present) };
1867
3023
  }
1868
- const messages = target === BROADCAST ? hub.historyAll(limit) : hub.history(target, limit);
3024
+ const messages = target === BROADCAST ? hub.historyAll(limit, since) : hub.history(target, limit, since);
1869
3025
  return { messages: messages.map(present) };
1870
3026
  })
1871
3027
  },
@@ -2141,11 +3297,11 @@ function hubTools(hub, registry, options) {
2141
3297
  return read === null ? { matched: null } : { matched: read };
2142
3298
  })
2143
3299
  }
2144
- ];
3300
+ ].map((tool) => ({ ...tool, handler: requireArgs(tool, tool.handler) }));
2145
3301
  }
2146
3302
 
2147
3303
  // src/mcp-server.ts
2148
- import { randomUUID as randomUUID2 } from "node:crypto";
3304
+ import { randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
2149
3305
  import { readFileSync as readFileSync2 } from "node:fs";
2150
3306
  var ADMIN_PATH = "/admin";
2151
3307
  var ADMIN_API_PATH = "/admin/api";
@@ -2160,6 +3316,7 @@ var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
2160
3316
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
2161
3317
  var MAX_BODY_BYTES = 1048576;
2162
3318
  var HEARTBEAT_MS = 2e4;
3319
+ var DEFAULT_SESSION_TTL_MS = 30 * 6e4;
2163
3320
  var SessionRegistry = class {
2164
3321
  sessions = /* @__PURE__ */ new Set();
2165
3322
  /** sessionId → peerId claimed via `bridge_register`. */
@@ -2172,8 +3329,17 @@ var SessionRegistry = class {
2172
3329
  ensureSession(sessionId) {
2173
3330
  const id = sessionId ?? randomUUID2();
2174
3331
  this.sessions.add(id);
3332
+ this.lastUsedAt.set(id, Date.now());
2175
3333
  return id;
2176
3334
  }
3335
+ /** Has this session id ever been minted here and not terminated since?
3336
+ * A request presenting an id the registry does not know must be answered 404
3337
+ * (spec MUST) so the client starts a new session, instead of being adopted
3338
+ * silently — which is how a client kept talking to a hub that had restarted
3339
+ * and already forgotten it. */
3340
+ hasSession(sessionId) {
3341
+ return this.sessions.has(sessionId);
3342
+ }
2177
3343
  /** Peer bound to a session, if any. */
2178
3344
  peerFor(sessionId) {
2179
3345
  if (sessionId === void 0) return void 0;
@@ -2229,6 +3395,47 @@ var SessionRegistry = class {
2229
3395
  isSuppressed(sessionId) {
2230
3396
  return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
2231
3397
  }
3398
+ /** Session → the last time a request presented it.
3399
+ *
3400
+ * The registry is the only thing that knows a session exists, so it is also the
3401
+ * only thing that can decide one is finished. Without this the maps below only
3402
+ * ever grew, `hasSession` was true forever, and "this session is over" was not
3403
+ * expressible — so the 404 path could never fire for a session the hub had
3404
+ * genuinely finished with. */
3405
+ lastUsedAt = /* @__PURE__ */ new Map();
3406
+ /** Record that `sessionId` was just used (a request carried its id). */
3407
+ touch(sessionId) {
3408
+ if (sessionId !== void 0 && this.sessions.has(sessionId)) this.lastUsedAt.set(sessionId, Date.now());
3409
+ }
3410
+ /**
3411
+ * Forget sessions that are finished: no live SSE stream, and unused for `ttlMs`.
3412
+ * Returns the ids removed. `ttlMs <= 0` disables reaping.
3413
+ *
3414
+ * Safe because a client that gets 404 is REQUIRED by the spec to send a fresh
3415
+ * `initialize`, and the hub auto-registers it under the same peer id — it comes
3416
+ * back with its mailbox intact. A live SSE stream pins a session regardless of
3417
+ * age: that is the one signal that means "still connected", and it wins.
3418
+ */
3419
+ sweep(now, ttlMs) {
3420
+ if (ttlMs <= 0) return [];
3421
+ const removed = [];
3422
+ for (const sessionId of [...this.sessions]) {
3423
+ if (this.liveStreams.has(sessionId)) continue;
3424
+ const last = this.lastUsedAt.get(sessionId) ?? now;
3425
+ if (now - last <= ttlMs) continue;
3426
+ this.sessions.delete(sessionId);
3427
+ this.lastUsedAt.delete(sessionId);
3428
+ this.peerBindings.delete(sessionId);
3429
+ this.clientNames.delete(sessionId);
3430
+ this.clientVersions.delete(sessionId);
3431
+ this.sessionIps.delete(sessionId);
3432
+ this.tokenBindings.delete(sessionId);
3433
+ this.managerSessions.delete(sessionId);
3434
+ this.suppressedAuto.delete(sessionId);
3435
+ removed.push(sessionId);
3436
+ }
3437
+ return removed;
3438
+ }
2232
3439
  /** Session → client IP (remote mode: the admin roster shows where a peer
2233
3440
  * connects from, so the operator can tell Zhang San's kimi-code from Li Si's). */
2234
3441
  sessionIps = /* @__PURE__ */ new Map();
@@ -2247,6 +3454,19 @@ var SessionRegistry = class {
2247
3454
  if (sessionId === void 0) return void 0;
2248
3455
  return this.tokenBindings.get(sessionId);
2249
3456
  }
3457
+ /** Sessions that presented the loopback manager token (`--manager-token`).
3458
+ * Distinct from a token-table identity: loopback mode has no token table, so
3459
+ * the manager secret is the only thing separating "the operator's GUI" from
3460
+ * "any other local MCP client". */
3461
+ managerSessions = /* @__PURE__ */ new Set();
3462
+ /** Remember that this session proved it holds the manager token. */
3463
+ markManagerSession(sessionId) {
3464
+ this.managerSessions.add(sessionId);
3465
+ }
3466
+ /** Did this session present the manager token? */
3467
+ isManagerSession(sessionId) {
3468
+ return sessionId !== void 0 && this.managerSessions.has(sessionId);
3469
+ }
2250
3470
  /** Mark a session's SSE stream open (server→client channel alive). */
2251
3471
  markSseOpen(sessionId) {
2252
3472
  this.liveStreams.add(sessionId);
@@ -2305,7 +3525,7 @@ var SessionRegistry = class {
2305
3525
  var McpStreamableHttpServer = class {
2306
3526
  constructor(tools, info, registry, log = () => {
2307
3527
  }, onInitialize = () => {
2308
- }, authenticate, adminApi, heartbeatMs) {
3528
+ }, authenticate, adminApi, heartbeatMs, originPolicy, managerToken, sessionTtlMs = DEFAULT_SESSION_TTL_MS) {
2309
3529
  this.tools = tools;
2310
3530
  this.info = info;
2311
3531
  this.registry = registry;
@@ -2314,29 +3534,54 @@ var McpStreamableHttpServer = class {
2314
3534
  this.authenticate = authenticate;
2315
3535
  this.adminApi = adminApi;
2316
3536
  this.heartbeatMs = heartbeatMs;
3537
+ this.originPolicy = originPolicy;
3538
+ this.managerToken = managerToken;
3539
+ this.sessionTtlMs = sessionTtlMs;
2317
3540
  }
2318
3541
  sseStreams = /* @__PURE__ */ new Map();
2319
3542
  heartbeatTimer;
3543
+ sessionSweepTimer;
3544
+ /** Does this request carry the loopback manager token? */
3545
+ presentedManagerToken(req) {
3546
+ if (this.managerToken === void 0) return false;
3547
+ const header = headerValue(req.headers.authorization);
3548
+ if (header === void 0) return false;
3549
+ const match = /^Bearer\s+(.+)$/i.exec(header);
3550
+ return match !== null && secretEquals(match[1].trim(), this.managerToken);
3551
+ }
2320
3552
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
2321
3553
  attach(server, path2) {
2322
3554
  this.heartbeatTimer = setInterval(() => {
2323
3555
  if (this.sseStreams.size === 0) return;
2324
- this.notifyAll("notifications/message", {
3556
+ this.notifyAll(SSE_METHOD, {
2325
3557
  level: "debug",
2326
3558
  logger: "bridge",
2327
3559
  data: { event: "heartbeat", ts: Date.now() }
2328
3560
  });
2329
3561
  }, this.heartbeatMs ?? HEARTBEAT_MS);
2330
3562
  this.heartbeatTimer.unref?.();
3563
+ if (this.sessionTtlMs > 0) {
3564
+ const interval = Math.min(6e4, Math.max(1e3, this.sessionTtlMs / 4));
3565
+ this.sessionSweepTimer = setInterval(() => {
3566
+ const removed = this.registry.sweep(Date.now(), this.sessionTtlMs);
3567
+ if (removed.length > 0) this.log(`sessions reaped: ${removed.length}`);
3568
+ }, interval);
3569
+ this.sessionSweepTimer.unref?.();
3570
+ }
2331
3571
  server.on("request", (req, res) => {
2332
3572
  const url = new URL(req.url ?? "/", "http://localhost");
3573
+ if (!this.originAllowed(req)) {
3574
+ res.writeHead(403, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8", "Mcp-Protocol-Version": messageProtocolVersion(req) });
3575
+ res.end(JSON.stringify({ error: `origin not allowed: ${headerValue(req.headers.origin) ?? ""}` }));
3576
+ return;
3577
+ }
2333
3578
  if (url.pathname === ADMIN_PATH && req.method === "GET") {
2334
3579
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
2335
3580
  res.end(adminPage());
2336
3581
  return;
2337
3582
  }
2338
3583
  if (url.pathname === "/healthz") {
2339
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...corsHeaders() });
3584
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...this.corsFor(req) });
2340
3585
  res.end(JSON.stringify({ ok: true, ts: Date.now() }));
2341
3586
  return;
2342
3587
  }
@@ -2357,15 +3602,16 @@ var McpStreamableHttpServer = class {
2357
3602
  return;
2358
3603
  }
2359
3604
  if (req.method === "OPTIONS") {
2360
- res.writeHead(204, corsHeaders()).end();
3605
+ res.writeHead(204, this.corsFor(req)).end();
2361
3606
  return;
2362
3607
  }
2363
- res.writeHead(405, corsHeaders()).end();
3608
+ res.writeHead(405, { ...this.corsFor(req), "Mcp-Protocol-Version": messageProtocolVersion(req) }).end();
2364
3609
  });
2365
3610
  }
2366
3611
  /** Close all open SSE streams (called on server shutdown). */
2367
3612
  close() {
2368
3613
  if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
3614
+ if (this.sessionSweepTimer !== void 0) clearInterval(this.sessionSweepTimer);
2369
3615
  for (const stream of this.sseStreams.values()) stream.end();
2370
3616
  this.sseStreams.clear();
2371
3617
  }
@@ -2393,33 +3639,107 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2393
3639
  }
2394
3640
  }
2395
3641
  }
3642
+ /**
3643
+ * Cross-origin gate. A request is allowed when:
3644
+ * - it carries no `Origin` (curl, MCP clients, the desktop app — never browsers), or
3645
+ * - the origin is explicitly listed (`--allow-origins`, or 'all'), or
3646
+ * - the origin is a loopback hostname (the bundled admin page, local tooling), or
3647
+ * - it presents a valid bearer token AND its origin host matches the `Host`
3648
+ * header (the admin page behind a TLS reverse proxy, where the hub cannot
3649
+ * know its own public origin).
3650
+ *
3651
+ * The last clause is deliberately credential-gated, not identity-gated: with
3652
+ * `--allow-join` the transport mints an anonymous identity for a request that
3653
+ * carries nothing at all, so testing only `authenticate(req) !== undefined`
3654
+ * would re-open this branch to exactly the rebinding page it exists to stop.
3655
+ * A real `Authorization` header is required as well.
3656
+ */
3657
+ originAllowed(req) {
3658
+ const origin = headerValue(req.headers.origin);
3659
+ if (origin === void 0) return true;
3660
+ const policy = this.originPolicy;
3661
+ if (policy?.allow === "all") return true;
3662
+ if (policy?.allow?.has(origin) === true) return true;
3663
+ if (origin.includes(",") || /\s/.test(origin)) return false;
3664
+ const originHost = originHostname(origin);
3665
+ if (originHost === void 0) return false;
3666
+ if (isLoopbackHost(originHost)) return true;
3667
+ const identity = this.authenticate?.(req);
3668
+ if (identity === void 0 || identity.anonymous === true) return false;
3669
+ const host = headerValue(req.headers.host);
3670
+ return host !== void 0 && hostHeaderName(host) === originHost;
3671
+ }
3672
+ /** CORS headers for one request: echoes the origin only when it is allowed. */
3673
+ corsFor(req) {
3674
+ if (req === void 0) return corsHeaders();
3675
+ const origin = headerValue(req.headers.origin);
3676
+ return corsHeaders(origin !== void 0 && this.originAllowed(req) ? origin : void 0);
3677
+ }
2396
3678
  handleGet(req, res) {
2397
- if (this.authenticate !== void 0 && this.authenticate(req) === void 0) {
2398
- res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
2399
- res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
3679
+ let streamIdentity;
3680
+ if (this.authenticate !== void 0) {
3681
+ streamIdentity = this.authenticate(req);
3682
+ if (streamIdentity === void 0) {
3683
+ res.writeHead(401, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8", "Mcp-Protocol-Version": messageProtocolVersion(req) });
3684
+ res.end(JSON.stringify({ error: "unauthorized: missing or invalid bearer token" }));
3685
+ return;
3686
+ }
3687
+ const existing = this.registry.sessionIdFor(req);
3688
+ const bound = existing === void 0 ? void 0 : this.registry.tokenFor(existing);
3689
+ const bothAnonymous = bound?.anonymous === true && streamIdentity.anonymous === true;
3690
+ if (bound !== void 0 && !bothAnonymous && (bound.peer !== streamIdentity.peer || bound.role !== streamIdentity.role)) {
3691
+ this.unauthorized(req, res, null, "token does not match this session");
3692
+ return;
3693
+ }
3694
+ }
3695
+ if (this.protocolVersionRejected(req, res)) return;
3696
+ const streamSession = this.registry.sessionIdFor(req);
3697
+ if (streamSession !== void 0 && !this.registry.hasSession(streamSession)) {
3698
+ this.jsonRpcError(req, res, null, -32001, "session not found \u2014 send a new initialize request", void 0, 404);
2400
3699
  return;
2401
3700
  }
2402
- const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
2403
- const identity = this.authenticate?.(req);
2404
- if (identity !== void 0) this.registry.bindToken(sessionId, identity);
3701
+ this.registry.touch(streamSession);
3702
+ const sessionId = this.registry.ensureSession(streamSession);
3703
+ if (this.presentedManagerToken(req)) this.registry.markManagerSession(sessionId);
3704
+ if (streamIdentity !== void 0) this.registry.bindToken(sessionId, streamIdentity);
2405
3705
  res.writeHead(200, {
2406
- ...corsHeaders(),
3706
+ ...this.corsFor(req),
2407
3707
  "Content-Type": "text/event-stream; charset=utf-8",
2408
3708
  "Cache-Control": "no-cache",
2409
3709
  Connection: "keep-alive",
3710
+ // nginx (and anything else speaking X-Accel) buffers proxied responses by
3711
+ // default, which breaks SSE completely: events sit in the proxy until its
3712
+ // buffer fills or the stream ends, so a client never sees a push. This header
3713
+ // is the in-band way to turn that off — it needs no proxy configuration, and
3714
+ // a proxy that ignores it is unaffected.
3715
+ "X-Accel-Buffering": "no",
3716
+ // Same header the POST path sets: the client should see the version the
3717
+ // server is speaking on every 200, not only on JSON responses.
3718
+ "Mcp-Protocol-Version": messageProtocolVersion(req),
2410
3719
  ...req.headers["mcp-session-id"] ? {} : { "Mcp-Session-Id": sessionId }
2411
3720
  });
2412
3721
  res.write(": connected\n\n");
2413
3722
  this.sseStreams.set(sessionId, res);
2414
3723
  this.registry.markSseOpen(sessionId);
2415
3724
  req.on("close", () => {
2416
- this.sseStreams.delete(sessionId);
2417
- this.registry.markSseClosed(sessionId);
3725
+ if (this.sseStreams.get(sessionId) === res) this.sseStreams.delete(sessionId);
3726
+ if (!this.sseStreams.has(sessionId)) this.registry.markSseClosed(sessionId);
2418
3727
  });
2419
3728
  }
2420
3729
  /** Reject a request without a valid token (remote auth mode). */
2421
- unauthorized(res, id, why) {
2422
- res.writeHead(401, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
3730
+ unauthorized(req, res, id, why) {
3731
+ res.writeHead(401, {
3732
+ ...this.corsFor(req),
3733
+ "Content-Type": "application/json; charset=utf-8",
3734
+ // MCP authorization spec: a 401 MUST carry WWW-Authenticate. The hub does not
3735
+ // implement OAuth (authorization is OPTIONAL in MCP), so there is no
3736
+ // resource_metadata to advertise — the bare scheme is still required.
3737
+ "WWW-Authenticate": "Bearer",
3738
+ // Every response names the protocol version the server is speaking, errors
3739
+ // included: a client that only sees 401s still needs to know what it is
3740
+ // talking to before it can decide whether to retry.
3741
+ "Mcp-Protocol-Version": messageProtocolVersion(req)
3742
+ });
2423
3743
  res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32001, message: `unauthorized: ${why}` } }));
2424
3744
  }
2425
3745
  /** Management API for the bundled admin page: issue/revoke tokens and list
@@ -2427,7 +3747,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2427
3747
  * and must resolve to a MANAGER role — an agent token gets 403. */
2428
3748
  async handleAdminApi(req, res, url) {
2429
3749
  const respond = (status, body) => {
2430
- res.writeHead(status, { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" });
3750
+ res.writeHead(status, { ...this.corsFor(req), "Content-Type": "application/json; charset=utf-8" });
2431
3751
  res.end(JSON.stringify(body));
2432
3752
  };
2433
3753
  const identity = this.authenticate?.(req);
@@ -2476,49 +3796,57 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2476
3796
  if (this.authenticate !== void 0) {
2477
3797
  authIdentity = this.authenticate(req);
2478
3798
  if (authIdentity === void 0) {
2479
- this.unauthorized(res, null, "missing or invalid bearer token");
3799
+ this.unauthorized(req, res, null, "missing or invalid bearer token");
2480
3800
  return;
2481
3801
  }
2482
3802
  const existingSession = this.registry.sessionIdFor(req);
2483
3803
  const bound = existingSession === void 0 ? void 0 : this.registry.tokenFor(existingSession);
2484
3804
  const bothAnonymous = bound?.anonymous === true && authIdentity.anonymous === true;
2485
3805
  if (bound !== void 0 && !bothAnonymous && (bound.peer !== authIdentity.peer || bound.role !== authIdentity.role)) {
2486
- this.unauthorized(res, null, "token does not match this session");
3806
+ this.unauthorized(req, res, null, "token does not match this session");
2487
3807
  return;
2488
3808
  }
2489
3809
  if (existingSession !== void 0 && bound === void 0 && !authIdentity.anonymous) {
2490
3810
  this.registry.bindToken(existingSession, authIdentity);
2491
3811
  }
2492
3812
  }
3813
+ if (this.protocolVersionRejected(req, res)) return;
3814
+ const presentedSession = this.registry.sessionIdFor(req);
3815
+ if (presentedSession !== void 0 && !this.registry.hasSession(presentedSession)) {
3816
+ this.jsonRpcError(req, res, null, -32001, "session not found \u2014 send a new initialize request", void 0, 404);
3817
+ return;
3818
+ }
3819
+ this.registry.touch(presentedSession);
2493
3820
  let body;
2494
3821
  try {
2495
3822
  body = await readBody(req);
2496
3823
  } catch (error) {
2497
- this.jsonRpcError(res, null, -32700, `parse error: ${error.message}`);
3824
+ this.jsonRpcError(req, res, null, -32700, `parse error: ${error.message}`);
2498
3825
  return;
2499
3826
  }
2500
3827
  let message;
2501
3828
  try {
2502
3829
  message = JSON.parse(body.toString("utf8"));
2503
3830
  } catch {
2504
- this.jsonRpcError(res, null, -32700, "parse error: invalid JSON");
3831
+ this.jsonRpcError(req, res, null, -32700, "parse error: invalid JSON");
2505
3832
  return;
2506
3833
  }
2507
3834
  if (typeof message !== "object" || message === null || message.method === void 0) {
2508
- this.jsonRpcError(res, message?.id ?? null, -32600, "invalid request");
3835
+ this.jsonRpcError(req, res, message?.id ?? null, -32600, "invalid request");
2509
3836
  return;
2510
3837
  }
2511
3838
  const id = message.id ?? null;
2512
3839
  const sessionId = this.registry.sessionIdFor(req);
2513
3840
  if (sessionId !== void 0) this.registry.sessionIps.set(sessionId, clientIp(req));
3841
+ if (sessionId !== void 0 && this.presentedManagerToken(req)) this.registry.markManagerSession(sessionId);
2514
3842
  if (id === null) {
2515
- res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
3843
+ res.writeHead(202, { ...this.corsFor(req), "Content-Length": "0" }).end();
2516
3844
  return;
2517
3845
  }
2518
3846
  try {
2519
3847
  const { result, extraHeaders } = await this.dispatch(message, sessionId, authIdentity);
2520
3848
  res.writeHead(200, {
2521
- ...corsHeaders(),
3849
+ ...this.corsFor(req),
2522
3850
  "Content-Type": "application/json; charset=utf-8",
2523
3851
  "Mcp-Protocol-Version": messageProtocolVersion(req),
2524
3852
  ...extraHeaders
@@ -2526,7 +3854,7 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2526
3854
  res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
2527
3855
  } catch (error) {
2528
3856
  const code = error.code ?? -32603;
2529
- this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
3857
+ this.jsonRpcError(req, res, id, code, error.message);
2530
3858
  }
2531
3859
  }
2532
3860
  async dispatch(message, sessionId, authIdentity) {
@@ -2589,11 +3917,27 @@ data: ${JSON.stringify({ jsonrpc: "2.0", method, params })}
2589
3917
  throw rpcError(-32601, `method not found: ${method}`);
2590
3918
  }
2591
3919
  }
2592
- jsonRpcError(res, id, code, message, data, protocolVersion) {
2593
- res.writeHead(code === -32700 || code === -32600 ? 400 : 200, {
2594
- ...corsHeaders(),
3920
+ /** Spec MUST: a request carrying an `MCP-Protocol-Version` this server does
3921
+ * not implement gets 400. Absent is fine — an absent header means the client is
3922
+ * on the newest revision this server knows, which is what responses advertise
3923
+ * (`messageProtocolVersion` returns LATEST_VERSION) —
3924
+ * but *present and unknown* is a client speaking a revision whose rules this
3925
+ * server would otherwise be guessing at. Writes the response itself and
3926
+ * reports whether it did. */
3927
+ protocolVersionRejected(req, res) {
3928
+ const presented = req.headers["mcp-protocol-version"];
3929
+ if (presented === void 0) return false;
3930
+ if (typeof presented === "string" && SUPPORTED_VERSIONS.includes(presented)) return false;
3931
+ this.jsonRpcError(req, res, null, -32600, `unsupported MCP-Protocol-Version: ${String(presented).slice(0, 64)} (supported: ${SUPPORTED_VERSIONS.join(", ")})`, void 0, 400);
3932
+ return true;
3933
+ }
3934
+ jsonRpcError(req, res, id, code, message, data, status) {
3935
+ const code400 = code === -32700 || code === -32600;
3936
+ const httpStatus = status ?? (code400 ? 400 : 200);
3937
+ res.writeHead(httpStatus, {
3938
+ ...this.corsFor(req),
2595
3939
  "Content-Type": "application/json; charset=utf-8",
2596
- ...protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}
3940
+ "Mcp-Protocol-Version": messageProtocolVersion(req)
2597
3941
  });
2598
3942
  res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, ...data !== void 0 ? { data } : {} } }));
2599
3943
  }
@@ -2604,7 +3948,8 @@ function rpcError(code, message) {
2604
3948
  return error;
2605
3949
  }
2606
3950
  function messageProtocolVersion(req) {
2607
- return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
3951
+ const presented = req.headers["mcp-protocol-version"];
3952
+ return typeof presented === "string" && SUPPORTED_VERSIONS.includes(presented) ? presented : LATEST_VERSION;
2608
3953
  }
2609
3954
  function clientIp(req) {
2610
3955
  const forwarded = req.headers["x-forwarded-for"];
@@ -2614,10 +3959,44 @@ function clientIp(req) {
2614
3959
  }
2615
3960
  return (req.socket.remoteAddress ?? "?").replace(/^::ffff:/, "");
2616
3961
  }
2617
- function corsHeaders() {
3962
+ function headerValue(value) {
3963
+ const raw = Array.isArray(value) ? value[0] : value;
3964
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
3965
+ }
3966
+ function secretEquals(a, b) {
3967
+ const left = Buffer.from(a, "utf8");
3968
+ const right = Buffer.from(b, "utf8");
3969
+ return left.length === right.length && timingSafeEqual(left, right);
3970
+ }
3971
+ function originHostname(origin) {
3972
+ try {
3973
+ return new URL(origin).hostname.toLowerCase();
3974
+ } catch {
3975
+ return void 0;
3976
+ }
3977
+ }
3978
+ function hostHeaderName(host) {
3979
+ try {
3980
+ return new URL(`http://${host}`).hostname.toLowerCase();
3981
+ } catch {
3982
+ return void 0;
3983
+ }
3984
+ }
3985
+ function isLoopbackHost(host) {
3986
+ const name = host.trim().toLowerCase().replace(/\.$/, "");
3987
+ if (name === "localhost" || name === "::1" || name === "[::1]") return true;
3988
+ const quad = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
3989
+ return quad !== null && quad.slice(1).every((octet) => Number(octet) <= 255);
3990
+ }
3991
+ var ALLOWED_REQUEST_HEADERS = "content-type, mcp-session-id, mcp-protocol-version, authorization";
3992
+ function corsHeaders(allowOrigin) {
2618
3993
  return {
2619
- "Access-Control-Allow-Origin": "*",
2620
- "Access-Control-Allow-Headers": "*",
3994
+ Vary: "Origin",
3995
+ ...allowOrigin !== void 0 ? { "Access-Control-Allow-Origin": allowOrigin } : {},
3996
+ "Access-Control-Allow-Headers": ALLOWED_REQUEST_HEADERS,
3997
+ // Exposed so a cross-origin browser client can actually READ the version the
3998
+ // server is speaking: without this the header is set but invisible to script.
3999
+ "Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version",
2621
4000
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
2622
4001
  };
2623
4002
  }
@@ -2643,7 +4022,7 @@ function readBody(req) {
2643
4022
  import { DatabaseSync } from "node:sqlite";
2644
4023
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2645
4024
  function messageToRow(message) {
2646
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
4025
+ 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];
2647
4026
  }
2648
4027
  function rowToMessage(row) {
2649
4028
  const id = row.id;
@@ -2663,6 +4042,15 @@ function rowToMessage(row) {
2663
4042
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2664
4043
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2665
4044
  ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
4045
+ // Chain depth: rows written before the hop column restore without it
4046
+ // (treated as a chain origin by importHistory — never as a bogus depth).
4047
+ ...typeof row.hop === "number" && Number.isFinite(row.hop) && row.hop > 0 ? { hop: row.hop } : {},
4048
+ // Cursors: rows written before the mseq column restore without it, and are
4049
+ // then reachable only without a `since` cursor (never mistaken for seq 0).
4050
+ ...typeof row.mseq === "number" && Number.isFinite(row.mseq) && row.mseq > 0 ? { seq: row.mseq } : {},
4051
+ // Deadline: restored so a message that expired while the hub was down is
4052
+ // dropped rather than delivered late.
4053
+ ...typeof row.expires_at === "number" && Number.isFinite(row.expires_at) && row.expires_at > 0 ? { expiresAt: row.expires_at } : {},
2666
4054
  ts
2667
4055
  };
2668
4056
  }
@@ -2673,15 +4061,15 @@ var SQLiteStateStore = class {
2673
4061
  this.db.exec("PRAGMA journal_mode = WAL");
2674
4062
  this.db.exec(`
2675
4063
  CREATE TABLE IF NOT EXISTS profiles (
2676
- id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER
4064
+ id TEXT PRIMARY KEY, alias TEXT, client_name TEXT, client_version TEXT, registered_at INTEGER, card TEXT
2677
4065
  );
2678
4066
  CREATE TABLE IF NOT EXISTS messages (
2679
4067
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2680
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
4068
+ 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
2681
4069
  );
2682
4070
  CREATE TABLE IF NOT EXISTS mailboxes (
2683
4071
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2684
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
4072
+ 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
2685
4073
  );
2686
4074
  CREATE TABLE IF NOT EXISTS groups (
2687
4075
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
@@ -2691,11 +4079,17 @@ var SQLiteStateStore = class {
2691
4079
  );
2692
4080
  `);
2693
4081
  for (const table of ["messages", "mailboxes"]) {
2694
- try {
2695
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2696
- } catch {
4082
+ for (const column of ["thread_id TEXT", "hop INTEGER", "mseq INTEGER", "expires_at INTEGER"]) {
4083
+ try {
4084
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column}`);
4085
+ } catch {
4086
+ }
2697
4087
  }
2698
4088
  }
4089
+ try {
4090
+ this.db.exec("ALTER TABLE profiles ADD COLUMN card TEXT");
4091
+ } catch {
4092
+ }
2699
4093
  log.info(`sqlite state open: ${file}`);
2700
4094
  }
2701
4095
  db;
@@ -2707,24 +4101,24 @@ var SQLiteStateStore = class {
2707
4101
  /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2708
4102
  flush(snapshot) {
2709
4103
  const insert = this.db.prepare(
2710
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
4104
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop, mseq, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2711
4105
  );
2712
4106
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2713
4107
  insert.run(...messageToRow(message));
2714
4108
  }
2715
4109
  this.db.exec("DELETE FROM mailboxes");
2716
4110
  const insertMail = this.db.prepare(
2717
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
4111
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop, mseq, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2718
4112
  );
2719
4113
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2720
4114
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
2721
4115
  }
2722
4116
  this.db.exec("DELETE FROM profiles");
2723
4117
  const insertProfile = this.db.prepare(
2724
- "INSERT INTO profiles (id, alias, client_name, client_version, registered_at) VALUES (?, ?, ?, ?, ?)"
4118
+ "INSERT INTO profiles (id, alias, client_name, client_version, registered_at, card) VALUES (?, ?, ?, ?, ?, ?)"
2725
4119
  );
2726
4120
  for (const profile of snapshot.profiles) {
2727
- insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt);
4121
+ insertProfile.run(profile.id, profile.alias ?? null, profile.clientName ?? null, profile.clientVersion ?? null, profile.registeredAt, profile.card === void 0 ? null : JSON.stringify(profile.card));
2728
4122
  }
2729
4123
  this.db.exec("DELETE FROM groups");
2730
4124
  const insertGroup = this.db.prepare(
@@ -2756,7 +4150,9 @@ var SQLiteStateStore = class {
2756
4150
  ...typeof row.alias === "string" && row.alias !== "" ? { alias: row.alias } : {},
2757
4151
  ...typeof row.client_name === "string" && row.client_name !== "" ? { clientName: row.client_name } : {},
2758
4152
  ...typeof row.client_version === "string" && row.client_version !== "" ? { clientVersion: row.client_version } : {},
2759
- registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now()
4153
+ registeredAt: typeof row.registered_at === "number" ? row.registered_at : Date.now(),
4154
+ // A corrupt card row must not take the whole roster down.
4155
+ ...cardField(row.card)
2760
4156
  });
2761
4157
  }
2762
4158
  const messages = [];
@@ -2804,10 +4200,19 @@ var SQLiteStateStore = class {
2804
4200
  this.db.close();
2805
4201
  }
2806
4202
  };
4203
+ function cardField(raw) {
4204
+ if (typeof raw !== "string" || raw === "") return {};
4205
+ try {
4206
+ const parsed = JSON.parse(raw);
4207
+ return typeof parsed === "object" && parsed !== null ? { card: parsed } : {};
4208
+ } catch {
4209
+ return {};
4210
+ }
4211
+ }
2807
4212
 
2808
4213
  // src/index.ts
2809
4214
  var SERVER_NAME = "agent-comm-hub";
2810
- var SERVER_VERSION = "0.9.0";
4215
+ var SERVER_VERSION = "0.10.0";
2811
4216
  var DEFAULT_HOST = "127.0.0.1";
2812
4217
  var DEFAULT_PORT = 18764;
2813
4218
  var DEFAULT_PATH = "/mcp";
@@ -2858,23 +4263,60 @@ function writeRosterFile(file, profiles) {
2858
4263
  writeFileSync2(tmp, JSON.stringify({ version: 1, savedAt: Date.now(), profiles }), "utf8");
2859
4264
  renameSync2(tmp, file);
2860
4265
  }
4266
+ function readManagerTokenFile(stateFile) {
4267
+ if (stateFile === void 0) return void 0;
4268
+ const file = join(dirname2(stateFile), "manager.token");
4269
+ try {
4270
+ const raw = readFileSync3(file, "utf8").trim();
4271
+ return raw === "" ? void 0 : raw;
4272
+ } catch (error) {
4273
+ if (error.code === "ENOENT") return void 0;
4274
+ throw new Error(`cannot read the manager token ${file}: ${error.message}`);
4275
+ }
4276
+ }
2861
4277
  function startHub(config = {}, log = console) {
2862
4278
  const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
2863
4279
  const resolved = { ...DEFAULT_CONFIG, ...overrides };
4280
+ const managerToken = resolved.managerToken ?? readManagerTokenFile(resolved.stateFile);
4281
+ if (!isLoopbackHost(resolved.host) && resolved.authTokens === void 0 && resolved.insecure !== true) {
4282
+ throw new Error(
4283
+ `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.`
4284
+ );
4285
+ }
4286
+ if (managerToken === void 0 && resolved.managerPeers !== "all" && !isLoopbackHost(resolved.host)) {
4287
+ log.warn(
4288
+ `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`
4289
+ );
4290
+ }
4291
+ if (managerToken !== void 0 && resolved.authTokens !== void 0) {
4292
+ log.warn(
4293
+ "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."
4294
+ );
4295
+ }
2864
4296
  let mcp;
2865
4297
  let dbStore;
2866
4298
  const pushRoster = () => {
2867
- mcp.notifyAll("notifications/message", {
4299
+ mcp.notifyAll(SSE_METHOD, {
2868
4300
  level: "info",
2869
4301
  logger: "bridge",
2870
- data: { event: "peers_changed", peers: rosterFor(hub, registry) }
4302
+ data: { event: SSE_EVENT_PEERS_CHANGED, peers: rosterFor(hub, registry) }
2871
4303
  });
2872
4304
  };
4305
+ const streamCursor = /* @__PURE__ */ new Map();
4306
+ const oneStreamPer = (key, candidates) => {
4307
+ if (candidates.length === 0) return [];
4308
+ const streamed = registry.liveSessions();
4309
+ const open = candidates.filter((id) => streamed.has(id));
4310
+ const pool = open.length > 0 ? open : candidates;
4311
+ const at = (streamCursor.get(key) ?? 0) % pool.length;
4312
+ streamCursor.set(key, (at + 1) % pool.length);
4313
+ return [pool[at]];
4314
+ };
2873
4315
  const pushQueued = (message, target) => {
2874
- mcp.notify(registry.sessionsForPeer(target), "notifications/message", {
4316
+ mcp.notify(oneStreamPer(`peer:${target}`, registry.sessionsForPeer(target)), SSE_METHOD, {
2875
4317
  level: "info",
2876
4318
  logger: "bridge",
2877
- data: { event: "message", message: present(message) }
4319
+ data: { event: SSE_EVENT_MESSAGE, message: present(message) }
2878
4320
  });
2879
4321
  };
2880
4322
  const managerSet = resolved.managerPeers === "all" ? void 0 : new Set(resolved.managerPeers);
@@ -2883,15 +4325,15 @@ function startHub(config = {}, log = console) {
2883
4325
  for (const sessionId of registry.liveSessions()) {
2884
4326
  const peer = registry.peerFor(sessionId);
2885
4327
  if (peer === void 0) continue;
2886
- if (registry.tokenFor(sessionId)?.role === "manager" || managerSet === void 0 || managerSet.has(peer)) {
4328
+ if (isManagerCaller(registry, sessionId, peer, { managerPeers: managerSet, managerTokenRequired: managerToken !== void 0 })) {
2887
4329
  managers.push(sessionId);
2888
4330
  }
2889
4331
  }
2890
4332
  if (managers.length > 0) {
2891
- mcp.notify(managers, "notifications/message", {
4333
+ mcp.notify(oneStreamPer("relay", managers), SSE_METHOD, {
2892
4334
  level: "info",
2893
4335
  logger: "bridge",
2894
- data: { event: "message", message: present(message) }
4336
+ data: { event: SSE_EVENT_MESSAGE, message: present(message) }
2895
4337
  });
2896
4338
  }
2897
4339
  };
@@ -2925,12 +4367,64 @@ function startHub(config = {}, log = console) {
2925
4367
  }, 500);
2926
4368
  saveTimer.unref?.();
2927
4369
  };
4370
+ let runnerRegistry;
4371
+ const runnerQueue = /* @__PURE__ */ new Map();
4372
+ const taskPayload = (content) => {
4373
+ try {
4374
+ const parsed = JSON.parse(content);
4375
+ return {
4376
+ prompt: typeof parsed.prompt === "string" ? parsed.prompt : content,
4377
+ ...typeof parsed.context === "string" ? { context: parsed.context } : {},
4378
+ ...typeof parsed.deliverable === "string" ? { deliverable: parsed.deliverable } : {},
4379
+ ...typeof parsed.threadId === "string" ? { threadId: parsed.threadId } : {}
4380
+ };
4381
+ } catch {
4382
+ return { prompt: content };
4383
+ }
4384
+ };
4385
+ function runTask(message, target) {
4386
+ const runner = runnerRegistry?.get(target);
4387
+ if (runner === void 0) return;
4388
+ const previous = runnerQueue.get(target) ?? Promise.resolve();
4389
+ const next = previous.then(async () => {
4390
+ const payload = taskPayload(message.content);
4391
+ hub.noteEvent("runner_started", { peer: target, detail: { taskId: message.id, runner: runner.name } });
4392
+ try {
4393
+ const result = await runner.run({ taskId: message.id, peer: target, ...payload });
4394
+ hub.noteEvent("runner_finished", { peer: target, detail: { taskId: message.id, runner: runner.name, status: result.status } });
4395
+ hub.ackForRunner(target, message.id, { status: result.status, note: result.note });
4396
+ log.info(`runner ${runner.name}: task ${message.id} for '${target}' -> ${result.status}`);
4397
+ } catch (error) {
4398
+ hub.noteEvent("runner_finished", { peer: target, detail: { taskId: message.id, runner: runner.name, status: "failed", error: error.message } });
4399
+ try {
4400
+ hub.ackForRunner(target, message.id, { status: "failed", note: `runner error: ${error.message}` });
4401
+ } catch {
4402
+ }
4403
+ log.warn(`runner ${runner.name} errored on task ${message.id}: ${error.message}`);
4404
+ }
4405
+ });
4406
+ runnerQueue.set(target, next.catch(() => {
4407
+ }));
4408
+ }
2928
4409
  const hub = new AgentHub({
2929
4410
  maxQueue: resolved.maxQueue,
2930
4411
  historyLimit: resolved.historyLimit,
2931
4412
  waitTimeoutMs: resolved.waitTimeoutMs,
2932
4413
  connectedWindowMs: resolved.connectedWindowMs,
2933
4414
  peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
4415
+ parkedTtlMs: resolved.parkedTtlMs,
4416
+ leaseTtlMs: resolved.leaseTtlMs,
4417
+ maxAttempts: resolved.maxAttempts,
4418
+ ratePerMin: resolved.ratePerMin,
4419
+ // A parked mailbox that expires is data loss: say so, and make the store
4420
+ // re-snapshot so the dropped rows cannot resurrect on the next restart.
4421
+ onMailboxExpired: (peerId, dropped) => {
4422
+ log.warn(`parked mailbox for '${peerId}' expired after ${resolved.parkedTtlMs ?? DEFAULT_PARKED_TTL_MS}ms: ${dropped} message(s) dropped`);
4423
+ if (dbStore !== void 0) {
4424
+ dbDirty = true;
4425
+ scheduleSave();
4426
+ }
4427
+ },
2934
4428
  onPeerGc: (peerId) => registry.unbindPeerId(peerId),
2935
4429
  // Remote persistence: buffer every history append; re-snapshot when a
2936
4430
  // mailbox is consumed so drained messages cannot resurrect on restart.
@@ -2986,6 +4480,17 @@ function startHub(config = {}, log = console) {
2986
4480
  } catch (error) {
2987
4481
  log.warn(`message notification failed: ${error.message}`);
2988
4482
  }
4483
+ },
4484
+ // A task that reached a mailbox has nobody polling for it. If this peer has
4485
+ // a runner configured, wake it — that is the whole point of a runner.
4486
+ // `runnerRegistry` is filled in below, once the herdr adapter exists; this
4487
+ // hook only ever fires after the hub is serving.
4488
+ onTaskQueued: (message, target) => {
4489
+ try {
4490
+ runTask(message, target);
4491
+ } catch (error) {
4492
+ log.warn(`runner dispatch failed for '${target}': ${error.message}`);
4493
+ }
2989
4494
  }
2990
4495
  });
2991
4496
  const registry = new SessionRegistry();
@@ -3038,6 +4543,8 @@ function startHub(config = {}, log = console) {
3038
4543
  socketPath: resolved.herdrSocketPath,
3039
4544
  sendRequest: resolved.herdrSendRequest
3040
4545
  });
4546
+ runnerRegistry = new RunnerRegistry(resolved.runners ?? [], { herdr });
4547
+ if (runnerRegistry.size > 0) log.info(`runners configured for: ${runnerRegistry.peers().join(", ")}`);
3041
4548
  let auth;
3042
4549
  let authReload;
3043
4550
  if (resolved.authTokens !== void 0) {
@@ -3063,7 +4570,8 @@ function startHub(config = {}, log = console) {
3063
4570
  waitTimeoutMs: resolved.waitTimeoutMs,
3064
4571
  herdr,
3065
4572
  herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers),
3066
- managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers)
4573
+ managerPeers: resolved.managerPeers === "all" ? "all" : new Set(resolved.managerPeers ?? DEFAULT_CONFIG.managerPeers),
4574
+ managerTokenRequired: managerToken !== void 0
3067
4575
  }),
3068
4576
  { name: SERVER_NAME, version: SERVER_VERSION },
3069
4577
  registry,
@@ -3090,12 +4598,15 @@ function startHub(config = {}, log = console) {
3090
4598
  },
3091
4599
  auth,
3092
4600
  authTokensAdmin(resolved.authTokens, authReload),
3093
- resolved.heartbeatMs
4601
+ resolved.heartbeatMs,
4602
+ resolved.allowOrigins === void 0 || resolved.allowOrigins.length === 0 ? void 0 : { allow: new Set(resolved.allowOrigins) },
4603
+ managerToken,
4604
+ resolved.sessionTtlMs
3094
4605
  );
3095
4606
  const server = createServer();
3096
4607
  mcp.attach(server, resolved.path);
3097
4608
  server.on("error", (error) => log.warn(`hub http server error: ${error.message}`));
3098
- server.listen(resolved.port, resolved.host, () => {
4609
+ server.listen(resolved.port, resolved.host.trim(), () => {
3099
4610
  log.info(`agent-comm-hub listening on http://${resolved.host}:${resolved.port}${resolved.path}`);
3100
4611
  });
3101
4612
  return {
@@ -3128,24 +4639,36 @@ export {
3128
4639
  AGENT_STATUSES,
3129
4640
  AgentHub,
3130
4641
  BROADCAST,
4642
+ CLIENT_ACK_STATUSES,
3131
4643
  DEFAULT_CONFIG,
3132
4644
  DEFAULT_HOST,
3133
4645
  DEFAULT_PATH,
3134
4646
  DEFAULT_PORT,
4647
+ DEFAULT_RUN_TIMEOUT_MS,
4648
+ HUB_PEER,
3135
4649
  HerdrCtl,
3136
4650
  KINDS,
4651
+ MAX_EVENTS,
3137
4652
  MAX_HOP,
4653
+ MAX_REPLY_CHAIN,
4654
+ MAX_RUN_NOTE_CHARS,
3138
4655
  McpStreamableHttpServer,
3139
4656
  PEER_ID_PATTERN,
4657
+ RunnerRegistry,
3140
4658
  SERVER_NAME,
3141
4659
  SERVER_VERSION,
3142
4660
  SQLiteStateStore,
4661
+ SSE_EVENT_MESSAGE,
4662
+ SSE_EVENT_PEERS_CHANGED,
4663
+ SSE_METHOD,
3143
4664
  SessionRegistry,
3144
4665
  TERMINAL_TASK_STATUSES,
4666
+ a2aState,
3145
4667
  ackToTaskStatus,
3146
4668
  addToken,
3147
4669
  aggregateTaskStatus,
3148
4670
  autoRegisterPeer,
4671
+ createRunner,
3149
4672
  decodeContent,
3150
4673
  encodeContent,
3151
4674
  generateToken,
@@ -3159,5 +4682,6 @@ export {
3159
4682
  sanitizePeerId,
3160
4683
  startHub,
3161
4684
  tokenTable,
4685
+ validateRunnerConfig,
3162
4686
  watchTokenFile
3163
4687
  };