agent-comm-hub 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -422,7 +422,40 @@ import { randomUUID } from "node:crypto";
422
422
  var KINDS = ["chat", "task", "notice", "ack"];
423
423
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
424
424
  var BROADCAST = "all";
425
- var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed"];
425
+ var MAX_HOP = 5;
426
+ var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout"];
427
+ function ackToTaskStatus(ack) {
428
+ return ack;
429
+ }
430
+ function nextTaskStatus(current, ack) {
431
+ if (TERMINAL_TASK_STATUSES.includes(current)) return null;
432
+ const next = ackToTaskStatus(ack);
433
+ switch (current) {
434
+ case "pending":
435
+ return next;
436
+ case "accepted":
437
+ return next;
438
+ case "working":
439
+ if (next === "accepted") return "working";
440
+ return next;
441
+ default:
442
+ return null;
443
+ }
444
+ }
445
+ function aggregateTaskStatus(assignees) {
446
+ if (assignees.length === 0) return "pending";
447
+ const statuses = assignees.map((a) => a.status);
448
+ const active = statuses.filter((s) => s === "pending" || s === "accepted" || s === "working");
449
+ if (active.length > 0) {
450
+ if (active.some((s) => s === "working")) return "working";
451
+ if (active.some((s) => s === "accepted")) return "accepted";
452
+ return "pending";
453
+ }
454
+ if (statuses.every((s) => s === "done")) return "done";
455
+ if (statuses.some((s) => s === "failed")) return "failed";
456
+ if (statuses.some((s) => s === "timeout")) return "timeout";
457
+ return "rejected";
458
+ }
426
459
  function encodeContent(payload) {
427
460
  return JSON.stringify(payload);
428
461
  }
@@ -459,10 +492,14 @@ var AgentHub = class {
459
492
  groups = /* @__PURE__ */ new Map();
460
493
  /** Task ledger: task message id → record (status + ack timeline). */
461
494
  taskLedger = /* @__PURE__ */ new Map();
495
+ /** SLA timers keyed by task id (cleared when the task turns terminal). */
496
+ taskTimers = /* @__PURE__ */ new Map();
462
497
  gcTimer;
463
498
  /** Stop the idle GC (call when the hub shuts down). */
464
499
  dispose() {
465
500
  if (this.gcTimer !== void 0) clearInterval(this.gcTimer);
501
+ for (const timer of this.taskTimers.values()) clearTimeout(timer);
502
+ this.taskTimers.clear();
466
503
  }
467
504
  gcTick(idleTimeoutMs) {
468
505
  const now = Date.now();
@@ -626,29 +663,49 @@ var AgentHub = class {
626
663
  return last !== void 0 && Date.now() - last < (this.options.connectedWindowMs ?? 3e4);
627
664
  }
628
665
  /** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
629
- send(from, to, kind, content) {
630
- return this.route(from, to, kind, content);
666
+ send(from, to, kind, content, opts) {
667
+ return this.route(from, to, kind, content, opts?.ref, opts?.threadId);
631
668
  }
632
- /** Send a structured task message to `to` (or {@link BROADCAST}). Records
633
- * a ledger entry so the sender can later query status / wait for acks. */
669
+ /** Send a structured task message to `to` (peer, {@link BROADCAST}, or a
670
+ * group id). Records a ledger entry so the sender can later query status /
671
+ * wait for acks. */
634
672
  sendTask(from, to, task) {
635
- const message = this.route(from, to, "task", JSON.stringify(task));
673
+ const threadId = task.threadId;
674
+ const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
636
675
  this.recordTask(message, task);
637
676
  return message;
638
677
  }
639
678
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
640
- * ledger when `ref` is a known task; chat/notice refs still route. */
679
+ * ledger when `ref` is a known task (looked up in the ledger first, so acks
680
+ * survive history-ring eviction); chat/notice refs still route via history. */
641
681
  sendAck(from, ref, ack) {
642
- const original = this.historyRing.findLast((message2) => message2.id === ref);
643
- if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
644
- const message = this.route(from, original.from, "ack", JSON.stringify(ack), ref);
682
+ const task = this.taskLedger.get(ref);
683
+ let target;
684
+ if (task !== void 0) {
685
+ target = task.from;
686
+ } else {
687
+ const original = this.historyRing.findLast((message2) => message2.id === ref);
688
+ if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
689
+ target = original.from;
690
+ }
691
+ const message = this.route(from, target, "ack", JSON.stringify(ack), ref);
645
692
  this.applyAckToLedger(message, from, ref, ack);
646
693
  return message;
647
694
  }
695
+ /** Resolve the peer ids a task is assigned to (broadcast / group fan-out). */
696
+ resolveAssignees(from, to) {
697
+ if (to === BROADCAST) return this.peers().filter((peer) => peer !== from);
698
+ const group = this.groups.get(to);
699
+ if (group !== void 0) return group.members.filter((member) => member !== from);
700
+ return [to];
701
+ }
648
702
  /** Create or refresh the ledger row for a newly sent task. */
649
703
  recordTask(message, task) {
650
704
  const now = message.ts;
651
- this.taskLedger.set(message.id, {
705
+ const assigneeIds = this.resolveAssignees(message.from, message.to);
706
+ const assignees = assigneeIds.map((peerId) => ({ peerId, status: "pending", acks: [] }));
707
+ const timeoutMs = typeof task.timeoutMs === "number" && Number.isFinite(task.timeoutMs) && task.timeoutMs > 0 ? Math.floor(task.timeoutMs) : void 0;
708
+ const record = {
652
709
  id: message.id,
653
710
  from: message.from,
654
711
  to: message.to,
@@ -657,25 +714,105 @@ var AgentHub = class {
657
714
  ...task.deliverable !== void 0 ? { deliverable: task.deliverable } : {},
658
715
  status: "pending",
659
716
  acks: [],
717
+ assignees,
718
+ ...timeoutMs !== void 0 ? { timeoutMs, deadlineAt: now + timeoutMs } : {},
719
+ ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
660
720
  createdAt: now,
661
721
  updatedAt: now
662
- });
722
+ };
723
+ this.taskLedger.set(message.id, record);
724
+ this.scheduleTaskTimeout(record);
663
725
  this.evictOverflowTasks();
726
+ this.options.onTaskChanged?.(this.taskOf(record.id));
727
+ }
728
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. */
729
+ scheduleTaskTimeout(record) {
730
+ if (record.deadlineAt === void 0) return;
731
+ const delay = Math.max(0, record.deadlineAt - Date.now());
732
+ const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
733
+ timer.unref?.();
734
+ this.taskTimers.set(record.id, timer);
735
+ }
736
+ /** Mark a non-terminal task `timeout` and leave a system timeline event. */
737
+ fireTaskTimeout(taskId) {
738
+ this.taskTimers.delete(taskId);
739
+ const record = this.taskLedger.get(taskId);
740
+ if (record === void 0) return;
741
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) return;
742
+ const now = Date.now();
743
+ const event = {
744
+ from: record.to,
745
+ status: "failed",
746
+ note: `task timed out after ${record.timeoutMs ?? 0}ms without a terminal ack`,
747
+ ts: now,
748
+ messageId: `timeout-${record.id}`,
749
+ applied: true,
750
+ system: true
751
+ };
752
+ record.acks.push(event);
753
+ for (const assignee of record.assignees) {
754
+ if (!TERMINAL_TASK_STATUSES.includes(assignee.status)) {
755
+ assignee.status = "timeout";
756
+ assignee.acks.push({ ...event });
757
+ }
758
+ }
759
+ record.status = "timeout";
760
+ record.updatedAt = now;
761
+ this.clearTaskTimer(taskId);
762
+ this.options.onTaskChanged?.(this.taskOf(taskId));
664
763
  }
665
- /** Fold an ack into the task's timeline and status. Unknown refs are a
666
- * no-op (chat/notice can be acked for routing without a ledger row). */
764
+ clearTaskTimer(taskId) {
765
+ const timer = this.taskTimers.get(taskId);
766
+ if (timer !== void 0) {
767
+ clearTimeout(timer);
768
+ this.taskTimers.delete(taskId);
769
+ }
770
+ }
771
+ /** Note that `target` has taken delivery of a task (waiter handoff / queue). */
772
+ markTaskDelivered(taskId, target, ts) {
773
+ const record = this.taskLedger.get(taskId);
774
+ if (record === void 0) return;
775
+ const assignee = record.assignees.find((a) => a.peerId === target);
776
+ if (assignee === void 0 || assignee.deliveredMs !== void 0) return;
777
+ assignee.deliveredMs = ts;
778
+ record.updatedAt = ts;
779
+ this.options.onTaskChanged?.(this.taskOf(taskId));
780
+ }
781
+ /**
782
+ * Fold an ack into the task's timeline and (when legal) its status.
783
+ * - Unknown refs are a no-op (chat/notice can be acked for routing without a ledger row).
784
+ * - Terminal tasks keep the event on the timeline with `applied: false`.
785
+ * - Broadcast / group tasks update the acking assignee first, then re-aggregate.
786
+ */
667
787
  applyAckToLedger(message, from, ref, ack) {
668
788
  const record = this.taskLedger.get(ref);
669
789
  if (record === void 0) return;
670
- record.acks.push({
790
+ const next = nextTaskStatus(record.status, ack.status);
791
+ const assignee = record.assignees.find((a) => a.peerId === from);
792
+ const assigneeNext = assignee === void 0 ? null : nextTaskStatus(assignee.status, ack.status);
793
+ const applied = record.assignees.length <= 1 ? next !== null : assigneeNext !== null;
794
+ const event = {
671
795
  from,
672
796
  status: ack.status,
673
797
  ...ack.note !== void 0 ? { note: ack.note } : {},
798
+ ...typeof ack.progress === "number" && Number.isFinite(ack.progress) ? { progress: Math.max(0, Math.min(100, ack.progress)) } : {},
674
799
  ts: message.ts,
675
- messageId: message.id
676
- });
677
- record.status = ack.status;
800
+ messageId: message.id,
801
+ applied
802
+ };
803
+ record.acks.push(event);
804
+ if (assignee !== void 0 && assigneeNext !== null) {
805
+ assignee.status = assigneeNext;
806
+ assignee.acks.push({ ...event });
807
+ }
808
+ if (record.assignees.length <= 1) {
809
+ if (next !== null) record.status = next;
810
+ } else {
811
+ record.status = aggregateTaskStatus(record.assignees);
812
+ }
678
813
  record.updatedAt = message.ts;
814
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) this.clearTaskTimer(record.id);
815
+ this.options.onTaskChanged?.(this.taskOf(record.id));
679
816
  }
680
817
  /** Keep the ledger bounded: drop oldest terminal tasks first. */
681
818
  evictOverflowTasks() {
@@ -684,19 +821,25 @@ var AgentHub = class {
684
821
  let overflow = this.taskLedger.size - MAX_TASKS;
685
822
  for (const record of evictable) {
686
823
  if (overflow <= 0) break;
824
+ this.clearTaskTimer(record.id);
687
825
  this.taskLedger.delete(record.id);
688
826
  overflow--;
689
827
  }
690
828
  }
691
- /** One task by message id (the ack `ref`). */
829
+ /** Deep-ish copy of one task by message id (the ack `ref`). */
692
830
  taskOf(ref) {
693
831
  const record = this.taskLedger.get(ref);
694
- return record === void 0 ? void 0 : { ...record, acks: record.acks.map((a) => ({ ...a })) };
832
+ if (record === void 0) return void 0;
833
+ return {
834
+ ...record,
835
+ acks: record.acks.map((a) => ({ ...a })),
836
+ assignees: record.assignees.map((a) => ({ ...a, acks: a.acks.map((e) => ({ ...e })) }))
837
+ };
695
838
  }
696
839
  /**
697
840
  * Tasks involving `peer`. `role`:
698
841
  * - `sent` — tasks this peer delegated (`from`)
699
- * - `received` — tasks addressed to this peer (or broadcast)
842
+ * - `received` — tasks addressed to this peer, a group they are in, or broadcast
700
843
  * - `all` (default) — either side
701
844
  * Optional `status` filter matches the ledger's current status.
702
845
  */
@@ -705,17 +848,94 @@ var AgentHub = class {
705
848
  const limit = Math.max(1, options?.limit ?? 50);
706
849
  const out = [];
707
850
  for (const record of this.taskLedger.values()) {
708
- const isSent = record.from === peerId;
709
- const isReceived = record.to === peerId || record.to === BROADCAST;
710
- if (role === "sent" && !isSent) continue;
711
- if (role === "received" && !isReceived) continue;
712
- if (role === "all" && !isSent && !isReceived) continue;
851
+ if (peerId !== "all") {
852
+ const isSent = record.from === peerId;
853
+ const isReceived = record.to === peerId || record.to === BROADCAST || record.assignees.some((a) => a.peerId === peerId) || (this.groups.get(record.to)?.members.includes(peerId) ?? false);
854
+ if (role === "sent" && !isSent) continue;
855
+ if (role === "received" && !isReceived) continue;
856
+ if (role === "all" && !isSent && !isReceived) continue;
857
+ }
713
858
  if (options?.status !== void 0 && record.status !== options.status) continue;
714
- out.push({ ...record, acks: record.acks.map((a) => ({ ...a })) });
859
+ const copy = this.taskOf(record.id);
860
+ if (copy !== void 0) out.push(copy);
715
861
  }
716
862
  out.sort((a, b) => b.updatedAt - a.updatedAt);
717
863
  return out.slice(0, limit);
718
864
  }
865
+ /** Snapshot of every task (persistence). */
866
+ exportTasks() {
867
+ return [...this.taskLedger.values()].map((record) => this.taskOf(record.id)).filter(Boolean);
868
+ }
869
+ /** Restore the task ledger at boot (and re-arm SLA timers). */
870
+ importTasks(tasks) {
871
+ if (!Array.isArray(tasks)) return 0;
872
+ let count = 0;
873
+ for (const raw of tasks) {
874
+ if (typeof raw !== "object" || raw === null) continue;
875
+ const t = raw;
876
+ if (typeof t.id !== "string" || typeof t.from !== "string" || typeof t.to !== "string" || typeof t.prompt !== "string") continue;
877
+ if (typeof t.status !== "string" || !TERMINAL_TASK_STATUSES.includes(t.status) && t.status !== "pending" && t.status !== "accepted" && t.status !== "working") continue;
878
+ const acks = Array.isArray(t.acks) ? t.acks.flatMap((rawAck) => {
879
+ if (typeof rawAck !== "object" || rawAck === null) return [];
880
+ const a = rawAck;
881
+ if (typeof a.from !== "string" || typeof a.status !== "string" || typeof a.ts !== "number" || typeof a.messageId !== "string") return [];
882
+ return [{
883
+ from: a.from,
884
+ status: a.status,
885
+ ...typeof a.note === "string" ? { note: a.note } : {},
886
+ ...typeof a.progress === "number" ? { progress: a.progress } : {},
887
+ ts: a.ts,
888
+ messageId: a.messageId,
889
+ applied: a.applied !== false,
890
+ ...a.system === true ? { system: true } : {}
891
+ }];
892
+ }) : [];
893
+ const assignees = Array.isArray(t.assignees) && t.assignees.length > 0 ? t.assignees.flatMap((rawA) => {
894
+ if (typeof rawA !== "object" || rawA === null) return [];
895
+ const a = rawA;
896
+ if (typeof a.peerId !== "string" || typeof a.status !== "string") return [];
897
+ return [{
898
+ peerId: a.peerId,
899
+ status: a.status,
900
+ ...typeof a.deliveredMs === "number" ? { deliveredMs: a.deliveredMs } : {},
901
+ acks: Array.isArray(a.acks) ? a.acks.flatMap((rawE) => {
902
+ if (typeof rawE !== "object" || rawE === null) return [];
903
+ const e = rawE;
904
+ if (typeof e.from !== "string" || typeof e.status !== "string" || typeof e.ts !== "number" || typeof e.messageId !== "string") return [];
905
+ return [{
906
+ from: e.from,
907
+ status: e.status,
908
+ ...typeof e.note === "string" ? { note: e.note } : {},
909
+ ts: e.ts,
910
+ messageId: e.messageId,
911
+ applied: e.applied !== false
912
+ }];
913
+ }) : []
914
+ }];
915
+ }) : [{ peerId: t.to, status: t.status, acks: [] }];
916
+ const record = {
917
+ id: t.id,
918
+ from: t.from,
919
+ to: t.to,
920
+ prompt: t.prompt,
921
+ ...typeof t.context === "string" ? { context: t.context } : {},
922
+ ...typeof t.deliverable === "string" ? { deliverable: t.deliverable } : {},
923
+ status: t.status,
924
+ acks,
925
+ assignees,
926
+ ...typeof t.timeoutMs === "number" ? { timeoutMs: t.timeoutMs } : {},
927
+ ...typeof t.deadlineAt === "number" ? { deadlineAt: t.deadlineAt } : {},
928
+ ...typeof t.threadId === "string" ? { threadId: t.threadId } : {},
929
+ createdAt: typeof t.createdAt === "number" ? t.createdAt : Date.now(),
930
+ updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : Date.now()
931
+ };
932
+ this.taskLedger.set(record.id, record);
933
+ this.scheduleTaskTimeout(record);
934
+ count++;
935
+ }
936
+ this.evictOverflowTasks();
937
+ return count;
938
+ }
719
939
  /** Recent messages involving `peer` (inbound and outbound, plus group
720
940
  * channels the peer belongs to), newest first. */
721
941
  history(peerId, limit) {
@@ -799,20 +1019,7 @@ var AgentHub = class {
799
1019
  const group = this.groups.get(groupId);
800
1020
  if (group === void 0) throw new Error(`unknown group: ${groupId}`);
801
1021
  if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
802
- const message = {
803
- id: randomUUID(),
804
- from,
805
- to: groupId,
806
- kind: "chat",
807
- content,
808
- channel: groupId,
809
- ts: Date.now()
810
- };
811
- this.lastSeen.set(from, message.ts);
812
- for (const member of group.members) {
813
- if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
814
- }
815
- return message;
1022
+ return this.route(from, groupId, "chat", content);
816
1023
  }
817
1024
  /** Snapshot of every group for persistence. */
818
1025
  exportGroups() {
@@ -860,6 +1067,7 @@ var AgentHub = class {
860
1067
  content: m.content,
861
1068
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
862
1069
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1070
+ ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
863
1071
  ts: m.ts
864
1072
  });
865
1073
  }
@@ -893,6 +1101,7 @@ var AgentHub = class {
893
1101
  content: m.content,
894
1102
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
895
1103
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1104
+ ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
896
1105
  ts: m.ts
897
1106
  });
898
1107
  }
@@ -944,11 +1153,12 @@ var AgentHub = class {
944
1153
  * a multi-message burst (or a reconnect with a full queue) would silently
945
1154
  * lose messages that never reach wait/poll again.
946
1155
  */
947
- wait(peerId, timeoutMs, from, signal, ref) {
1156
+ wait(peerId, timeoutMs, from, signal, ref, threadId) {
948
1157
  const startedAt = Date.now();
949
1158
  const matches = (message) => {
950
1159
  if (from !== void 0 && message.from !== from) return false;
951
1160
  if (ref !== void 0 && !(message.kind === "ack" && message.ref === ref)) return false;
1161
+ if (threadId !== void 0 && message.threadId !== threadId) return false;
952
1162
  return true;
953
1163
  };
954
1164
  const queue = this.queues.get(peerId);
@@ -993,15 +1203,28 @@ var AgentHub = class {
993
1203
  timer,
994
1204
  onAbort,
995
1205
  ...from !== void 0 ? { from } : {},
996
- ...ref !== void 0 ? { ref } : {}
1206
+ ...ref !== void 0 ? { ref } : {},
1207
+ ...threadId !== void 0 ? { threadId } : {}
997
1208
  });
998
1209
  this.waiters.set(peerId, list);
999
1210
  });
1000
1211
  }
1001
- /** Create a message from `from` addressed to `to` and deliver it. */
1002
- route(from, to, kind, content, ref) {
1212
+ /** Create a message from `from` addressed to `to` and deliver it. `to` may
1213
+ * be a peer id, {@link BROADCAST}, or a group id. */
1214
+ route(from, to, kind, content, ref, threadId) {
1003
1215
  if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1004
- if (to !== BROADCAST && !this.lastSeen.has(to)) throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1216
+ const group = this.groups.get(to);
1217
+ if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
1218
+ throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1219
+ }
1220
+ let hop = 0;
1221
+ if (ref !== void 0) {
1222
+ const parent = this.historyRing.findLast((m) => m.id === ref);
1223
+ hop = (parent?.hop ?? 0) + 1;
1224
+ if (hop > MAX_HOP) {
1225
+ throw new Error(`hop limit exceeded (max ${MAX_HOP}): start a new task/thread instead of extending this chain`);
1226
+ }
1227
+ }
1005
1228
  const message = {
1006
1229
  id: randomUUID(),
1007
1230
  from,
@@ -1009,31 +1232,42 @@ var AgentHub = class {
1009
1232
  kind,
1010
1233
  content,
1011
1234
  ...ref !== void 0 ? { ref } : {},
1235
+ ...threadId !== void 0 ? { threadId } : {},
1236
+ ...group !== void 0 ? { channel: group.id } : {},
1237
+ ...hop > 0 ? { hop } : {},
1012
1238
  ts: Date.now()
1013
1239
  };
1014
1240
  this.lastSeen.set(from, message.ts);
1241
+ this.remember(message);
1015
1242
  if (to === BROADCAST) {
1016
1243
  for (const peer of this.peers()) {
1017
1244
  if (peer !== from) this.deliver(peer, message);
1018
1245
  }
1246
+ } else if (group !== void 0) {
1247
+ for (const member of group.members) {
1248
+ if (member !== from) this.deliver(member, message);
1249
+ }
1019
1250
  } else {
1020
1251
  this.deliver(to, message);
1021
1252
  }
1022
1253
  return message;
1023
1254
  }
1024
- /** Queue or hand off a message; wake the first matching waiter for its target. */
1255
+ /** Queue or hand off a message; wake the first matching waiter for its target.
1256
+ * History / onMessage are recorded by the caller (route / sendToGroup) —
1257
+ * exactly once per message, never per fan-out target. */
1025
1258
  deliver(target, message) {
1026
1259
  const list = this.waiters.get(target) ?? [];
1027
1260
  const index = list.findIndex((waiter) => {
1028
1261
  if (waiter.from !== void 0 && waiter.from !== message.from) return false;
1029
1262
  if (waiter.ref !== void 0 && !(message.kind === "ack" && message.ref === waiter.ref)) return false;
1263
+ if (waiter.threadId !== void 0 && message.threadId !== waiter.threadId) return false;
1030
1264
  return true;
1031
1265
  });
1266
+ if (message.kind === "task") this.markTaskDelivered(message.id, target, message.ts);
1032
1267
  if (index >= 0) {
1033
1268
  const [waiter] = list.splice(index, 1);
1034
1269
  this.waiters.set(target, list);
1035
1270
  clearTimeout(waiter.timer);
1036
- this.remember(message);
1037
1271
  waiter.resolve({ type: "message", message });
1038
1272
  return;
1039
1273
  }
@@ -1041,11 +1275,10 @@ var AgentHub = class {
1041
1275
  if (queue.length >= this.options.maxQueue) queue.shift();
1042
1276
  queue.push(message);
1043
1277
  this.queues.set(target, queue);
1044
- this.remember(message);
1045
1278
  this.options.onQueued?.(message, target);
1046
1279
  }
1047
- /** Append a delivered message to the history ring (and fire the
1048
- * persistence hook — one call per message, delivered or queued). */
1280
+ /** Append a sent message to the history ring (and fire the persistence /
1281
+ * relay hooks). Exactly one call per message — see route() / sendToGroup(). */
1049
1282
  remember(message) {
1050
1283
  this.historyRing.push(message);
1051
1284
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
@@ -1215,11 +1448,13 @@ function loadTokenFileVoid(rows) {
1215
1448
  // src/hub-tools.ts
1216
1449
  var DEFAULT_WAIT_MS = 3e4;
1217
1450
  function present(message) {
1218
- const { ref, channel, ...rest } = message;
1451
+ const { ref, channel, threadId, hop, ...rest } = message;
1219
1452
  return {
1220
1453
  ...rest,
1221
1454
  ...ref !== void 0 ? { ref } : {},
1222
1455
  ...channel !== void 0 ? { channel } : {},
1456
+ ...threadId !== void 0 ? { threadId } : {},
1457
+ ...hop !== void 0 ? { hop } : {},
1223
1458
  content: decodeContent(message.kind, message.content)
1224
1459
  };
1225
1460
  }
@@ -1320,7 +1555,10 @@ function hubTools(hub, registry, options) {
1320
1555
  from: message.from,
1321
1556
  to: message.to,
1322
1557
  kind: message.kind,
1558
+ ...message.ref !== void 0 ? { ref: message.ref } : {},
1323
1559
  ...message.channel !== void 0 ? { channel: message.channel } : {},
1560
+ ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
1561
+ ...message.hop !== void 0 ? { hop: message.hop } : {},
1324
1562
  ts: message.ts
1325
1563
  });
1326
1564
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -1462,70 +1700,93 @@ function hubTools(hub, registry, options) {
1462
1700
  },
1463
1701
  {
1464
1702
  name: "bridge_chat",
1465
- description: 'Send a chat message to another agent on the hub. Use bridge_wait (long-poll) or bridge_poll to receive replies. `to` is the target peerId, or "all" to broadcast.',
1703
+ description: 'Send a chat message to another agent on the hub. Use bridge_wait (long-poll) or bridge_poll to receive replies. `to` is the target peerId, a group id, or "all" to broadcast. Optional `threadId` groups multi-turn dialogs; optional `ref` marks a reply-to.',
1466
1704
  inputSchema: schema(
1467
- { to: str('Target peerId, or "all" to broadcast.'), message: str("The message text.") },
1705
+ {
1706
+ to: str('Target peerId, group id, or "all" to broadcast.'),
1707
+ message: str("The message text."),
1708
+ threadId: optStr("Optional conversation / workflow thread id."),
1709
+ ref: optStr("Optional reply-to message id.")
1710
+ },
1468
1711
  ["to", "message"]
1469
1712
  ),
1470
- handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message))))
1713
+ handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message), {
1714
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
1715
+ ...args.ref !== void 0 ? { ref: String(args.ref) } : {}
1716
+ })))
1471
1717
  },
1472
1718
  {
1473
1719
  name: "bridge_task",
1474
- description: "Delegate a structured task to another agent. The receiving agent decides whether to accept; expect an ack (accepted/rejected/done/failed) via bridge_wait / bridge_poll. Track progress with bridge_task_status(ref) or bridge_tasks; wait specifically for the ack with bridge_wait({ ref }).",
1720
+ description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack.',
1475
1721
  inputSchema: schema(
1476
1722
  {
1477
- to: str('Target peerId, or "all" to broadcast.'),
1723
+ to: str('Target peerId, group id, or "all" to broadcast.'),
1478
1724
  prompt: str("What the receiving agent should do."),
1479
1725
  context: optStr("Optional background information for the task."),
1480
- deliverable: optStr("Optional expected deliverable description.")
1726
+ deliverable: optStr("Optional expected deliverable description."),
1727
+ timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1728
+ threadId: optStr("Optional conversation / workflow thread id.")
1481
1729
  },
1482
1730
  ["to", "prompt"]
1483
1731
  ),
1484
1732
  handler: wrap(true, async (args, peer) => receipt(hub.sendTask(peer, String(args.to), {
1485
1733
  prompt: String(args.prompt),
1486
1734
  ...args.context !== void 0 ? { context: String(args.context) } : {},
1487
- ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {}
1735
+ ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {},
1736
+ ...args.timeoutMs !== void 0 ? { timeoutMs: Number(args.timeoutMs) } : {},
1737
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {}
1488
1738
  })))
1489
1739
  },
1490
1740
  {
1491
1741
  name: "bridge_ack",
1492
- description: "Acknowledge a message received from another agent (usually a delegated task): accepted | rejected | done | failed. The ack is routed back to the original sender of `ref` and updates the hub task ledger (see bridge_task_status / bridge_tasks).",
1742
+ description: "Acknowledge a message received from another agent (usually a delegated task): accepted | working | rejected | done | failed. The ack is routed back to the original sender of `ref` and updates the hub task ledger (see bridge_task_status / bridge_tasks). `working` + `progress` (0-100) report intermediate state without finishing the task. Terminal statuses lock the task; later acks are recorded but do not reopen it.",
1493
1743
  inputSchema: schema(
1494
1744
  {
1495
1745
  ref: str("The id of the message being acknowledged."),
1496
- status: { type: "string", enum: ["accepted", "rejected", "done", "failed"], description: "acknowledgement status." },
1497
- note: optStr("Optional explanation for the acknowledgement.")
1746
+ status: { type: "string", enum: ["accepted", "working", "rejected", "done", "failed"], description: "acknowledgement status." },
1747
+ note: optStr("Optional explanation for the acknowledgement."),
1748
+ progress: int("Optional 0-100 progress (meaningful with status=working).")
1498
1749
  },
1499
1750
  ["ref", "status"]
1500
1751
  ),
1501
1752
  handler: wrap(true, async (args, peer) => {
1502
1753
  const status = String(args.status);
1503
- const valid = ["accepted", "rejected", "done", "failed"];
1754
+ const valid = ["accepted", "working", "rejected", "done", "failed"];
1504
1755
  if (!valid.includes(status)) {
1505
1756
  throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
1506
1757
  }
1507
- return receipt(hub.sendAck(peer, String(args.ref), { status, ...args.note !== void 0 ? { note: String(args.note) } : {} }));
1758
+ return receipt(hub.sendAck(peer, String(args.ref), {
1759
+ status,
1760
+ ...args.note !== void 0 ? { note: String(args.note) } : {},
1761
+ ...args.progress !== void 0 ? { progress: Number(args.progress) } : {}
1762
+ }));
1508
1763
  })
1509
1764
  },
1510
1765
  {
1511
1766
  name: "bridge_tasks",
1512
- description: "List delegated tasks involving you, newest first. role=sent = tasks you assigned; role=received = tasks assigned to you (or broadcast); status filters the ledger state (pending/accepted/rejected/done/failed). Each row carries the full ack timeline.",
1767
+ description: "List delegated tasks involving you, newest first. role=sent = tasks you assigned; role=received = tasks assigned to you (or broadcast/group); status filters the ledger state (pending/accepted/working/rejected/done/failed/timeout). Each row carries the full ack timeline and per-assignee state.",
1513
1768
  inputSchema: schema({
1514
1769
  role: { type: "string", enum: ["sent", "received", "all"], description: "Which side of the task to list (default all)." },
1515
- status: { type: "string", enum: ["pending", "accepted", "rejected", "done", "failed"], description: "Only tasks whose current ledger status matches." },
1770
+ status: { type: "string", enum: ["pending", "accepted", "working", "rejected", "done", "failed", "timeout"], description: "Only tasks whose current ledger status matches." },
1771
+ peer: optStr('Manager only: "all" or another peerId for the admin board.'),
1516
1772
  limit: int("How many tasks to return (default 50).")
1517
1773
  }),
1518
- handler: wrap(true, async (args, peer) => {
1774
+ handler: wrap(true, async (args, peer, sessionId) => {
1519
1775
  const role = args.role === void 0 ? void 0 : String(args.role);
1520
1776
  if (role !== void 0 && role !== "sent" && role !== "received" && role !== "all") {
1521
1777
  throw new Error(`invalid role: ${role} (expected sent | received | all)`);
1522
1778
  }
1523
1779
  const status = args.status === void 0 ? void 0 : String(args.status);
1524
- const validStatus = ["pending", "accepted", "rejected", "done", "failed"];
1780
+ const validStatus = ["pending", "accepted", "working", "rejected", "done", "failed", "timeout"];
1525
1781
  if (status !== void 0 && !validStatus.includes(status)) {
1526
1782
  throw new Error(`invalid status: ${status} (expected ${validStatus.join(" | ")})`);
1527
1783
  }
1528
- const tasks = hub.listTasks(peer, {
1784
+ let target = peer;
1785
+ if (args.peer !== void 0) {
1786
+ target = String(args.peer);
1787
+ if (target !== peer) requireManager(peer, `listing tasks of '${target}'`, sessionId);
1788
+ }
1789
+ const tasks = hub.listTasks(target === "all" ? "all" : target, {
1529
1790
  ...role !== void 0 ? { role } : {},
1530
1791
  ...status !== void 0 ? { status } : {},
1531
1792
  ...args.limit !== void 0 ? { limit: Number(args.limit) } : {}
@@ -1535,7 +1796,7 @@ function hubTools(hub, registry, options) {
1535
1796
  },
1536
1797
  {
1537
1798
  name: "bridge_task_status",
1538
- description: "Status of one delegated task: current ledger state (pending \u2192 accepted \u2192 done/failed, or rejected), the original prompt/context/deliverable, and every ack event (who, status, note, when). Use the task message id (the same id you pass as bridge_ack ref).",
1799
+ description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref).",
1539
1800
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1540
1801
  handler: wrap(true, async (args, peer, sessionId) => {
1541
1802
  const ref = String(args.ref);
@@ -1549,10 +1810,11 @@ function hubTools(hub, registry, options) {
1549
1810
  },
1550
1811
  {
1551
1812
  name: "bridge_wait",
1552
- 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. Loop this tool to hold a real-time conversation.",
1813
+ description: "Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. `ref` waits specifically for the ack of that task id. `threadId` narrows to one conversation thread. Loop this tool to hold a real-time conversation.",
1553
1814
  inputSchema: schema({
1554
1815
  from: optStr("Only wait for messages from this peerId."),
1555
1816
  ref: optStr("Only wait for the ack of this task message id (bridge_task id)."),
1817
+ threadId: optStr("Only wait for messages carrying this threadId."),
1556
1818
  timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
1557
1819
  }),
1558
1820
  handler: wrap(true, async (args, peer) => presentWait(await hub.wait(
@@ -1560,7 +1822,8 @@ function hubTools(hub, registry, options) {
1560
1822
  args.timeoutMs === void 0 ? options.defaultWaitMs ?? DEFAULT_WAIT_MS : Number(args.timeoutMs),
1561
1823
  args.from === void 0 ? void 0 : String(args.from),
1562
1824
  void 0,
1563
- args.ref === void 0 ? void 0 : String(args.ref)
1825
+ args.ref === void 0 ? void 0 : String(args.ref),
1826
+ args.threadId === void 0 ? void 0 : String(args.threadId)
1564
1827
  )))
1565
1828
  },
1566
1829
  {
@@ -2380,7 +2643,7 @@ function readBody(req) {
2380
2643
  import { DatabaseSync } from "node:sqlite";
2381
2644
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2382
2645
  function messageToRow(message) {
2383
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
2646
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
2384
2647
  }
2385
2648
  function rowToMessage(row) {
2386
2649
  const id = row.id;
@@ -2399,6 +2662,7 @@ function rowToMessage(row) {
2399
2662
  content,
2400
2663
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2401
2664
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2665
+ ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
2402
2666
  ts
2403
2667
  };
2404
2668
  }
@@ -2413,16 +2677,25 @@ var SQLiteStateStore = class {
2413
2677
  );
2414
2678
  CREATE TABLE IF NOT EXISTS messages (
2415
2679
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2416
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2680
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2417
2681
  );
2418
2682
  CREATE TABLE IF NOT EXISTS mailboxes (
2419
2683
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2420
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER NOT NULL
2684
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2421
2685
  );
2422
2686
  CREATE TABLE IF NOT EXISTS groups (
2423
2687
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2424
2688
  );
2689
+ CREATE TABLE IF NOT EXISTS tasks (
2690
+ id TEXT PRIMARY KEY, body TEXT NOT NULL, updated_at INTEGER NOT NULL
2691
+ );
2425
2692
  `);
2693
+ for (const table of ["messages", "mailboxes"]) {
2694
+ try {
2695
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2696
+ } catch {
2697
+ }
2698
+ }
2426
2699
  log.info(`sqlite state open: ${file}`);
2427
2700
  }
2428
2701
  db;
@@ -2431,17 +2704,17 @@ var SQLiteStateStore = class {
2431
2704
  appendMessage(message) {
2432
2705
  this.buffer.push(message);
2433
2706
  }
2434
- /** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
2707
+ /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2435
2708
  flush(snapshot) {
2436
2709
  const insert = this.db.prepare(
2437
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
2710
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2438
2711
  );
2439
2712
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2440
2713
  insert.run(...messageToRow(message));
2441
2714
  }
2442
2715
  this.db.exec("DELETE FROM mailboxes");
2443
2716
  const insertMail = this.db.prepare(
2444
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2717
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2445
2718
  );
2446
2719
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2447
2720
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
@@ -2460,6 +2733,13 @@ var SQLiteStateStore = class {
2460
2733
  for (const group of snapshot.groups) {
2461
2734
  insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
2462
2735
  }
2736
+ this.db.exec("DELETE FROM tasks");
2737
+ const insertTask = this.db.prepare(
2738
+ "INSERT INTO tasks (id, body, updated_at) VALUES (?, ?, ?)"
2739
+ );
2740
+ for (const task of snapshot.tasks) {
2741
+ insertTask.run(task.id, JSON.stringify(task), task.updatedAt);
2742
+ }
2463
2743
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
2464
2744
  const total = Number(row.n);
2465
2745
  if (total > this.historyLimit * 2) {
@@ -2507,7 +2787,16 @@ var SQLiteStateStore = class {
2507
2787
  } catch {
2508
2788
  }
2509
2789
  }
2510
- return { profiles, messages, mailboxes, groups };
2790
+ const tasks = [];
2791
+ for (const row of this.db.prepare("SELECT * FROM tasks").all()) {
2792
+ if (typeof row.body !== "string") continue;
2793
+ try {
2794
+ const parsed = JSON.parse(row.body);
2795
+ if (typeof parsed === "object" && parsed !== null) tasks.push(parsed);
2796
+ } catch {
2797
+ }
2798
+ }
2799
+ return { profiles, messages, mailboxes, groups, tasks };
2511
2800
  }
2512
2801
  /** Final flush + close (hub shutdown). */
2513
2802
  close(snapshot) {
@@ -2518,7 +2807,7 @@ var SQLiteStateStore = class {
2518
2807
 
2519
2808
  // src/index.ts
2520
2809
  var SERVER_NAME = "agent-comm-hub";
2521
- var SERVER_VERSION = "0.8.0";
2810
+ var SERVER_VERSION = "0.9.0";
2522
2811
  var DEFAULT_HOST = "127.0.0.1";
2523
2812
  var DEFAULT_PORT = 18764;
2524
2813
  var DEFAULT_PATH = "/mcp";
@@ -2621,7 +2910,7 @@ function startHub(config = {}, log = console) {
2621
2910
  if (dbDirty && dbStore !== void 0) {
2622
2911
  dbDirty = false;
2623
2912
  try {
2624
- dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2913
+ dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups(), tasks: hub.exportTasks() });
2625
2914
  } catch (error) {
2626
2915
  log.warn(`sqlite flush failed: ${error.message}`);
2627
2916
  }
@@ -2651,6 +2940,7 @@ function startHub(config = {}, log = console) {
2651
2940
  if (dbStore !== void 0) {
2652
2941
  dbStore.appendMessage(message);
2653
2942
  dbDirty = true;
2943
+ scheduleSave();
2654
2944
  }
2655
2945
  try {
2656
2946
  pushRelay(message);
@@ -2661,6 +2951,15 @@ function startHub(config = {}, log = console) {
2661
2951
  onMailboxesChanged: () => {
2662
2952
  if (dbStore === void 0) return;
2663
2953
  dbDirty = true;
2954
+ scheduleSave();
2955
+ },
2956
+ // Task ledger transitions (ack folding, SLA timeout, first delivery) are
2957
+ // their own persistence trigger: without it a crash between two roster
2958
+ // events loses the ledger state the admin board reads back.
2959
+ onTaskChanged: () => {
2960
+ if (dbStore === void 0) return;
2961
+ dbDirty = true;
2962
+ scheduleSave();
2664
2963
  },
2665
2964
  onGroupsChanged: () => {
2666
2965
  if (dbStore === void 0) return;
@@ -2711,6 +3010,7 @@ function startHub(config = {}, log = console) {
2711
3010
  const restoredMessages = hub.importHistory(state.messages);
2712
3011
  const restoredQueued = hub.importMailboxes(state.mailboxes);
2713
3012
  const restoredGroups = hub.importGroups(state.groups);
3013
+ const restoredTasks = hub.importTasks(state.tasks);
2714
3014
  if (restoredMessages + restoredQueued + restoredGroups > 0) {
2715
3015
  log.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
2716
3016
  dbDirty = true;
@@ -2813,7 +3113,7 @@ function startHub(config = {}, log = console) {
2813
3113
  flushSaves();
2814
3114
  if (dbStore !== void 0) {
2815
3115
  try {
2816
- dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
3116
+ dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups(), tasks: hub.exportTasks() });
2817
3117
  } catch (error) {
2818
3118
  log.warn(`sqlite close failed: ${error.message}`);
2819
3119
  }
@@ -2834,6 +3134,7 @@ export {
2834
3134
  DEFAULT_PORT,
2835
3135
  HerdrCtl,
2836
3136
  KINDS,
3137
+ MAX_HOP,
2837
3138
  McpStreamableHttpServer,
2838
3139
  PEER_ID_PATTERN,
2839
3140
  SERVER_NAME,
@@ -2841,13 +3142,16 @@ export {
2841
3142
  SQLiteStateStore,
2842
3143
  SessionRegistry,
2843
3144
  TERMINAL_TASK_STATUSES,
3145
+ ackToTaskStatus,
2844
3146
  addToken,
3147
+ aggregateTaskStatus,
2845
3148
  autoRegisterPeer,
2846
3149
  decodeContent,
2847
3150
  encodeContent,
2848
3151
  generateToken,
2849
3152
  hubTools,
2850
3153
  loadTokenFile,
3154
+ nextTaskStatus,
2851
3155
  present,
2852
3156
  readTokens,
2853
3157
  removeToken,