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/README.md +71 -15
- package/README.zh.md +2 -2
- package/agents/SKILL.md +74 -12
- package/agents/install-all.ps1 +25 -160
- package/agents/registry.json +175 -7
- package/lib/cli.js +1954 -222
- package/lib/index.js +1682 -158
- package/lib/setup.js +184 -26
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
536
|
-
*
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
732
|
-
const timer = setTimeout(() => this.fireTaskTimeout(record.id),
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
this.deliver(to, message);
|
|
2075
|
+
return;
|
|
1252
2076
|
}
|
|
1253
|
-
|
|
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
|
-
|
|
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)
|
|
1276
|
-
|
|
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) =>
|
|
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
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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: [
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
1832
|
-
inputSchema: schema({
|
|
1833
|
-
|
|
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(
|
|
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", ...
|
|
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,
|
|
3605
|
+
res.writeHead(204, this.corsFor(req)).end();
|
|
2361
3606
|
return;
|
|
2362
3607
|
}
|
|
2363
|
-
res.writeHead(405,
|
|
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
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
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
|
-
|
|
2403
|
-
const
|
|
2404
|
-
if (
|
|
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
|
-
...
|
|
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, {
|
|
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, { ...
|
|
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, { ...
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2620
|
-
"Access-Control-Allow-
|
|
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
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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.
|
|
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(
|
|
4299
|
+
mcp.notifyAll(SSE_METHOD, {
|
|
2868
4300
|
level: "info",
|
|
2869
4301
|
logger: "bridge",
|
|
2870
|
-
data: { event:
|
|
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),
|
|
4316
|
+
mcp.notify(oneStreamPer(`peer:${target}`, registry.sessionsForPeer(target)), SSE_METHOD, {
|
|
2875
4317
|
level: "info",
|
|
2876
4318
|
logger: "bridge",
|
|
2877
|
-
data: { event:
|
|
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
|
|
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,
|
|
4333
|
+
mcp.notify(oneStreamPer("relay", managers), SSE_METHOD, {
|
|
2892
4334
|
level: "info",
|
|
2893
4335
|
logger: "bridge",
|
|
2894
|
-
data: { event:
|
|
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
|
};
|