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