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/cli.js CHANGED
@@ -424,7 +424,40 @@ import { randomUUID } from "node:crypto";
424
424
  var KINDS = ["chat", "task", "notice", "ack"];
425
425
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
426
426
  var BROADCAST = "all";
427
- var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed"];
427
+ var MAX_HOP = 5;
428
+ var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed", "timeout"];
429
+ function ackToTaskStatus(ack) {
430
+ return ack;
431
+ }
432
+ function nextTaskStatus(current, ack) {
433
+ if (TERMINAL_TASK_STATUSES.includes(current)) return null;
434
+ const next = ackToTaskStatus(ack);
435
+ switch (current) {
436
+ case "pending":
437
+ return next;
438
+ case "accepted":
439
+ return next;
440
+ case "working":
441
+ if (next === "accepted") return "working";
442
+ return next;
443
+ default:
444
+ return null;
445
+ }
446
+ }
447
+ function aggregateTaskStatus(assignees) {
448
+ if (assignees.length === 0) return "pending";
449
+ const statuses = assignees.map((a) => a.status);
450
+ const active = statuses.filter((s) => s === "pending" || s === "accepted" || s === "working");
451
+ if (active.length > 0) {
452
+ if (active.some((s) => s === "working")) return "working";
453
+ if (active.some((s) => s === "accepted")) return "accepted";
454
+ return "pending";
455
+ }
456
+ if (statuses.every((s) => s === "done")) return "done";
457
+ if (statuses.some((s) => s === "failed")) return "failed";
458
+ if (statuses.some((s) => s === "timeout")) return "timeout";
459
+ return "rejected";
460
+ }
428
461
  function decodeContent(kind, content) {
429
462
  if (kind !== "task" && kind !== "ack") return content;
430
463
  try {
@@ -458,10 +491,14 @@ var AgentHub = class {
458
491
  groups = /* @__PURE__ */ new Map();
459
492
  /** Task ledger: task message id → record (status + ack timeline). */
460
493
  taskLedger = /* @__PURE__ */ new Map();
494
+ /** SLA timers keyed by task id (cleared when the task turns terminal). */
495
+ taskTimers = /* @__PURE__ */ new Map();
461
496
  gcTimer;
462
497
  /** Stop the idle GC (call when the hub shuts down). */
463
498
  dispose() {
464
499
  if (this.gcTimer !== void 0) clearInterval(this.gcTimer);
500
+ for (const timer of this.taskTimers.values()) clearTimeout(timer);
501
+ this.taskTimers.clear();
465
502
  }
466
503
  gcTick(idleTimeoutMs) {
467
504
  const now = Date.now();
@@ -625,29 +662,49 @@ var AgentHub = class {
625
662
  return last !== void 0 && Date.now() - last < (this.options.connectedWindowMs ?? 3e4);
626
663
  }
627
664
  /** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
628
- send(from, to, kind, content) {
629
- return this.route(from, to, kind, content);
665
+ send(from, to, kind, content, opts) {
666
+ return this.route(from, to, kind, content, opts?.ref, opts?.threadId);
630
667
  }
631
- /** Send a structured task message to `to` (or {@link BROADCAST}). Records
632
- * a ledger entry so the sender can later query status / wait for acks. */
668
+ /** Send a structured task message to `to` (peer, {@link BROADCAST}, or a
669
+ * group id). Records a ledger entry so the sender can later query status /
670
+ * wait for acks. */
633
671
  sendTask(from, to, task) {
634
- const message = this.route(from, to, "task", JSON.stringify(task));
672
+ const threadId = task.threadId;
673
+ const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
635
674
  this.recordTask(message, task);
636
675
  return message;
637
676
  }
638
677
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
639
- * ledger when `ref` is a known task; chat/notice refs still route. */
678
+ * ledger when `ref` is a known task (looked up in the ledger first, so acks
679
+ * survive history-ring eviction); chat/notice refs still route via history. */
640
680
  sendAck(from, ref, ack) {
641
- const original = this.historyRing.findLast((message2) => message2.id === ref);
642
- if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
643
- const message = this.route(from, original.from, "ack", JSON.stringify(ack), ref);
681
+ const task = this.taskLedger.get(ref);
682
+ let target;
683
+ if (task !== void 0) {
684
+ target = task.from;
685
+ } else {
686
+ const original = this.historyRing.findLast((message2) => message2.id === ref);
687
+ if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
688
+ target = original.from;
689
+ }
690
+ const message = this.route(from, target, "ack", JSON.stringify(ack), ref);
644
691
  this.applyAckToLedger(message, from, ref, ack);
645
692
  return message;
646
693
  }
694
+ /** Resolve the peer ids a task is assigned to (broadcast / group fan-out). */
695
+ resolveAssignees(from, to) {
696
+ if (to === BROADCAST) return this.peers().filter((peer) => peer !== from);
697
+ const group = this.groups.get(to);
698
+ if (group !== void 0) return group.members.filter((member) => member !== from);
699
+ return [to];
700
+ }
647
701
  /** Create or refresh the ledger row for a newly sent task. */
648
702
  recordTask(message, task) {
649
703
  const now = message.ts;
650
- this.taskLedger.set(message.id, {
704
+ const assigneeIds = this.resolveAssignees(message.from, message.to);
705
+ const assignees = assigneeIds.map((peerId) => ({ peerId, status: "pending", acks: [] }));
706
+ const timeoutMs = typeof task.timeoutMs === "number" && Number.isFinite(task.timeoutMs) && task.timeoutMs > 0 ? Math.floor(task.timeoutMs) : void 0;
707
+ const record = {
651
708
  id: message.id,
652
709
  from: message.from,
653
710
  to: message.to,
@@ -656,25 +713,105 @@ var AgentHub = class {
656
713
  ...task.deliverable !== void 0 ? { deliverable: task.deliverable } : {},
657
714
  status: "pending",
658
715
  acks: [],
716
+ assignees,
717
+ ...timeoutMs !== void 0 ? { timeoutMs, deadlineAt: now + timeoutMs } : {},
718
+ ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
659
719
  createdAt: now,
660
720
  updatedAt: now
661
- });
721
+ };
722
+ this.taskLedger.set(message.id, record);
723
+ this.scheduleTaskTimeout(record);
662
724
  this.evictOverflowTasks();
725
+ this.options.onTaskChanged?.(this.taskOf(record.id));
726
+ }
727
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. */
728
+ scheduleTaskTimeout(record) {
729
+ if (record.deadlineAt === void 0) return;
730
+ const delay = Math.max(0, record.deadlineAt - Date.now());
731
+ const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
732
+ timer.unref?.();
733
+ this.taskTimers.set(record.id, timer);
734
+ }
735
+ /** Mark a non-terminal task `timeout` and leave a system timeline event. */
736
+ fireTaskTimeout(taskId) {
737
+ this.taskTimers.delete(taskId);
738
+ const record = this.taskLedger.get(taskId);
739
+ if (record === void 0) return;
740
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) return;
741
+ const now = Date.now();
742
+ const event = {
743
+ from: record.to,
744
+ status: "failed",
745
+ note: `task timed out after ${record.timeoutMs ?? 0}ms without a terminal ack`,
746
+ ts: now,
747
+ messageId: `timeout-${record.id}`,
748
+ applied: true,
749
+ system: true
750
+ };
751
+ record.acks.push(event);
752
+ for (const assignee of record.assignees) {
753
+ if (!TERMINAL_TASK_STATUSES.includes(assignee.status)) {
754
+ assignee.status = "timeout";
755
+ assignee.acks.push({ ...event });
756
+ }
757
+ }
758
+ record.status = "timeout";
759
+ record.updatedAt = now;
760
+ this.clearTaskTimer(taskId);
761
+ this.options.onTaskChanged?.(this.taskOf(taskId));
663
762
  }
664
- /** Fold an ack into the task's timeline and status. Unknown refs are a
665
- * no-op (chat/notice can be acked for routing without a ledger row). */
763
+ clearTaskTimer(taskId) {
764
+ const timer = this.taskTimers.get(taskId);
765
+ if (timer !== void 0) {
766
+ clearTimeout(timer);
767
+ this.taskTimers.delete(taskId);
768
+ }
769
+ }
770
+ /** Note that `target` has taken delivery of a task (waiter handoff / queue). */
771
+ markTaskDelivered(taskId, target, ts) {
772
+ const record = this.taskLedger.get(taskId);
773
+ if (record === void 0) return;
774
+ const assignee = record.assignees.find((a) => a.peerId === target);
775
+ if (assignee === void 0 || assignee.deliveredMs !== void 0) return;
776
+ assignee.deliveredMs = ts;
777
+ record.updatedAt = ts;
778
+ this.options.onTaskChanged?.(this.taskOf(taskId));
779
+ }
780
+ /**
781
+ * Fold an ack into the task's timeline and (when legal) its status.
782
+ * - Unknown refs are a no-op (chat/notice can be acked for routing without a ledger row).
783
+ * - Terminal tasks keep the event on the timeline with `applied: false`.
784
+ * - Broadcast / group tasks update the acking assignee first, then re-aggregate.
785
+ */
666
786
  applyAckToLedger(message, from, ref, ack) {
667
787
  const record = this.taskLedger.get(ref);
668
788
  if (record === void 0) return;
669
- record.acks.push({
789
+ const next = nextTaskStatus(record.status, ack.status);
790
+ const assignee = record.assignees.find((a) => a.peerId === from);
791
+ const assigneeNext = assignee === void 0 ? null : nextTaskStatus(assignee.status, ack.status);
792
+ const applied = record.assignees.length <= 1 ? next !== null : assigneeNext !== null;
793
+ const event = {
670
794
  from,
671
795
  status: ack.status,
672
796
  ...ack.note !== void 0 ? { note: ack.note } : {},
797
+ ...typeof ack.progress === "number" && Number.isFinite(ack.progress) ? { progress: Math.max(0, Math.min(100, ack.progress)) } : {},
673
798
  ts: message.ts,
674
- messageId: message.id
675
- });
676
- record.status = ack.status;
799
+ messageId: message.id,
800
+ applied
801
+ };
802
+ record.acks.push(event);
803
+ if (assignee !== void 0 && assigneeNext !== null) {
804
+ assignee.status = assigneeNext;
805
+ assignee.acks.push({ ...event });
806
+ }
807
+ if (record.assignees.length <= 1) {
808
+ if (next !== null) record.status = next;
809
+ } else {
810
+ record.status = aggregateTaskStatus(record.assignees);
811
+ }
677
812
  record.updatedAt = message.ts;
813
+ if (TERMINAL_TASK_STATUSES.includes(record.status)) this.clearTaskTimer(record.id);
814
+ this.options.onTaskChanged?.(this.taskOf(record.id));
678
815
  }
679
816
  /** Keep the ledger bounded: drop oldest terminal tasks first. */
680
817
  evictOverflowTasks() {
@@ -683,19 +820,25 @@ var AgentHub = class {
683
820
  let overflow = this.taskLedger.size - MAX_TASKS;
684
821
  for (const record of evictable) {
685
822
  if (overflow <= 0) break;
823
+ this.clearTaskTimer(record.id);
686
824
  this.taskLedger.delete(record.id);
687
825
  overflow--;
688
826
  }
689
827
  }
690
- /** One task by message id (the ack `ref`). */
828
+ /** Deep-ish copy of one task by message id (the ack `ref`). */
691
829
  taskOf(ref) {
692
830
  const record = this.taskLedger.get(ref);
693
- return record === void 0 ? void 0 : { ...record, acks: record.acks.map((a) => ({ ...a })) };
831
+ if (record === void 0) return void 0;
832
+ return {
833
+ ...record,
834
+ acks: record.acks.map((a) => ({ ...a })),
835
+ assignees: record.assignees.map((a) => ({ ...a, acks: a.acks.map((e) => ({ ...e })) }))
836
+ };
694
837
  }
695
838
  /**
696
839
  * Tasks involving `peer`. `role`:
697
840
  * - `sent` — tasks this peer delegated (`from`)
698
- * - `received` — tasks addressed to this peer (or broadcast)
841
+ * - `received` — tasks addressed to this peer, a group they are in, or broadcast
699
842
  * - `all` (default) — either side
700
843
  * Optional `status` filter matches the ledger's current status.
701
844
  */
@@ -704,17 +847,94 @@ var AgentHub = class {
704
847
  const limit = Math.max(1, options?.limit ?? 50);
705
848
  const out = [];
706
849
  for (const record of this.taskLedger.values()) {
707
- const isSent = record.from === peerId;
708
- const isReceived = record.to === peerId || record.to === BROADCAST;
709
- if (role === "sent" && !isSent) continue;
710
- if (role === "received" && !isReceived) continue;
711
- if (role === "all" && !isSent && !isReceived) continue;
850
+ if (peerId !== "all") {
851
+ const isSent = record.from === peerId;
852
+ const isReceived = record.to === peerId || record.to === BROADCAST || record.assignees.some((a) => a.peerId === peerId) || (this.groups.get(record.to)?.members.includes(peerId) ?? false);
853
+ if (role === "sent" && !isSent) continue;
854
+ if (role === "received" && !isReceived) continue;
855
+ if (role === "all" && !isSent && !isReceived) continue;
856
+ }
712
857
  if (options?.status !== void 0 && record.status !== options.status) continue;
713
- out.push({ ...record, acks: record.acks.map((a) => ({ ...a })) });
858
+ const copy = this.taskOf(record.id);
859
+ if (copy !== void 0) out.push(copy);
714
860
  }
715
861
  out.sort((a, b) => b.updatedAt - a.updatedAt);
716
862
  return out.slice(0, limit);
717
863
  }
864
+ /** Snapshot of every task (persistence). */
865
+ exportTasks() {
866
+ return [...this.taskLedger.values()].map((record) => this.taskOf(record.id)).filter(Boolean);
867
+ }
868
+ /** Restore the task ledger at boot (and re-arm SLA timers). */
869
+ importTasks(tasks) {
870
+ if (!Array.isArray(tasks)) return 0;
871
+ let count = 0;
872
+ for (const raw of tasks) {
873
+ if (typeof raw !== "object" || raw === null) continue;
874
+ const t = raw;
875
+ if (typeof t.id !== "string" || typeof t.from !== "string" || typeof t.to !== "string" || typeof t.prompt !== "string") continue;
876
+ if (typeof t.status !== "string" || !TERMINAL_TASK_STATUSES.includes(t.status) && t.status !== "pending" && t.status !== "accepted" && t.status !== "working") continue;
877
+ const acks = Array.isArray(t.acks) ? t.acks.flatMap((rawAck) => {
878
+ if (typeof rawAck !== "object" || rawAck === null) return [];
879
+ const a = rawAck;
880
+ if (typeof a.from !== "string" || typeof a.status !== "string" || typeof a.ts !== "number" || typeof a.messageId !== "string") return [];
881
+ return [{
882
+ from: a.from,
883
+ status: a.status,
884
+ ...typeof a.note === "string" ? { note: a.note } : {},
885
+ ...typeof a.progress === "number" ? { progress: a.progress } : {},
886
+ ts: a.ts,
887
+ messageId: a.messageId,
888
+ applied: a.applied !== false,
889
+ ...a.system === true ? { system: true } : {}
890
+ }];
891
+ }) : [];
892
+ const assignees = Array.isArray(t.assignees) && t.assignees.length > 0 ? t.assignees.flatMap((rawA) => {
893
+ if (typeof rawA !== "object" || rawA === null) return [];
894
+ const a = rawA;
895
+ if (typeof a.peerId !== "string" || typeof a.status !== "string") return [];
896
+ return [{
897
+ peerId: a.peerId,
898
+ status: a.status,
899
+ ...typeof a.deliveredMs === "number" ? { deliveredMs: a.deliveredMs } : {},
900
+ acks: Array.isArray(a.acks) ? a.acks.flatMap((rawE) => {
901
+ if (typeof rawE !== "object" || rawE === null) return [];
902
+ const e = rawE;
903
+ if (typeof e.from !== "string" || typeof e.status !== "string" || typeof e.ts !== "number" || typeof e.messageId !== "string") return [];
904
+ return [{
905
+ from: e.from,
906
+ status: e.status,
907
+ ...typeof e.note === "string" ? { note: e.note } : {},
908
+ ts: e.ts,
909
+ messageId: e.messageId,
910
+ applied: e.applied !== false
911
+ }];
912
+ }) : []
913
+ }];
914
+ }) : [{ peerId: t.to, status: t.status, acks: [] }];
915
+ const record = {
916
+ id: t.id,
917
+ from: t.from,
918
+ to: t.to,
919
+ prompt: t.prompt,
920
+ ...typeof t.context === "string" ? { context: t.context } : {},
921
+ ...typeof t.deliverable === "string" ? { deliverable: t.deliverable } : {},
922
+ status: t.status,
923
+ acks,
924
+ assignees,
925
+ ...typeof t.timeoutMs === "number" ? { timeoutMs: t.timeoutMs } : {},
926
+ ...typeof t.deadlineAt === "number" ? { deadlineAt: t.deadlineAt } : {},
927
+ ...typeof t.threadId === "string" ? { threadId: t.threadId } : {},
928
+ createdAt: typeof t.createdAt === "number" ? t.createdAt : Date.now(),
929
+ updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : Date.now()
930
+ };
931
+ this.taskLedger.set(record.id, record);
932
+ this.scheduleTaskTimeout(record);
933
+ count++;
934
+ }
935
+ this.evictOverflowTasks();
936
+ return count;
937
+ }
718
938
  /** Recent messages involving `peer` (inbound and outbound, plus group
719
939
  * channels the peer belongs to), newest first. */
720
940
  history(peerId, limit) {
@@ -798,20 +1018,7 @@ var AgentHub = class {
798
1018
  const group = this.groups.get(groupId);
799
1019
  if (group === void 0) throw new Error(`unknown group: ${groupId}`);
800
1020
  if (!group.members.includes(from)) throw new Error(`peer '${from}' is not a member of group '${groupId}'`);
801
- const message = {
802
- id: randomUUID(),
803
- from,
804
- to: groupId,
805
- kind: "chat",
806
- content,
807
- channel: groupId,
808
- ts: Date.now()
809
- };
810
- this.lastSeen.set(from, message.ts);
811
- for (const member of group.members) {
812
- if (member !== from && this.lastSeen.has(member)) this.deliver(member, message);
813
- }
814
- return message;
1021
+ return this.route(from, groupId, "chat", content);
815
1022
  }
816
1023
  /** Snapshot of every group for persistence. */
817
1024
  exportGroups() {
@@ -859,6 +1066,7 @@ var AgentHub = class {
859
1066
  content: m.content,
860
1067
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
861
1068
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1069
+ ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
862
1070
  ts: m.ts
863
1071
  });
864
1072
  }
@@ -892,6 +1100,7 @@ var AgentHub = class {
892
1100
  content: m.content,
893
1101
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
894
1102
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1103
+ ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
895
1104
  ts: m.ts
896
1105
  });
897
1106
  }
@@ -943,11 +1152,12 @@ var AgentHub = class {
943
1152
  * a multi-message burst (or a reconnect with a full queue) would silently
944
1153
  * lose messages that never reach wait/poll again.
945
1154
  */
946
- wait(peerId, timeoutMs, from, signal, ref) {
1155
+ wait(peerId, timeoutMs, from, signal, ref, threadId) {
947
1156
  const startedAt = Date.now();
948
1157
  const matches = (message) => {
949
1158
  if (from !== void 0 && message.from !== from) return false;
950
1159
  if (ref !== void 0 && !(message.kind === "ack" && message.ref === ref)) return false;
1160
+ if (threadId !== void 0 && message.threadId !== threadId) return false;
951
1161
  return true;
952
1162
  };
953
1163
  const queue = this.queues.get(peerId);
@@ -992,15 +1202,28 @@ var AgentHub = class {
992
1202
  timer,
993
1203
  onAbort,
994
1204
  ...from !== void 0 ? { from } : {},
995
- ...ref !== void 0 ? { ref } : {}
1205
+ ...ref !== void 0 ? { ref } : {},
1206
+ ...threadId !== void 0 ? { threadId } : {}
996
1207
  });
997
1208
  this.waiters.set(peerId, list);
998
1209
  });
999
1210
  }
1000
- /** Create a message from `from` addressed to `to` and deliver it. */
1001
- route(from, to, kind, content, ref) {
1211
+ /** Create a message from `from` addressed to `to` and deliver it. `to` may
1212
+ * be a peer id, {@link BROADCAST}, or a group id. */
1213
+ route(from, to, kind, content, ref, threadId) {
1002
1214
  if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1003
- if (to !== BROADCAST && !this.lastSeen.has(to)) throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1215
+ const group = this.groups.get(to);
1216
+ if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
1217
+ throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
1218
+ }
1219
+ let hop = 0;
1220
+ if (ref !== void 0) {
1221
+ const parent = this.historyRing.findLast((m) => m.id === ref);
1222
+ hop = (parent?.hop ?? 0) + 1;
1223
+ if (hop > MAX_HOP) {
1224
+ throw new Error(`hop limit exceeded (max ${MAX_HOP}): start a new task/thread instead of extending this chain`);
1225
+ }
1226
+ }
1004
1227
  const message = {
1005
1228
  id: randomUUID(),
1006
1229
  from,
@@ -1008,31 +1231,42 @@ var AgentHub = class {
1008
1231
  kind,
1009
1232
  content,
1010
1233
  ...ref !== void 0 ? { ref } : {},
1234
+ ...threadId !== void 0 ? { threadId } : {},
1235
+ ...group !== void 0 ? { channel: group.id } : {},
1236
+ ...hop > 0 ? { hop } : {},
1011
1237
  ts: Date.now()
1012
1238
  };
1013
1239
  this.lastSeen.set(from, message.ts);
1240
+ this.remember(message);
1014
1241
  if (to === BROADCAST) {
1015
1242
  for (const peer of this.peers()) {
1016
1243
  if (peer !== from) this.deliver(peer, message);
1017
1244
  }
1245
+ } else if (group !== void 0) {
1246
+ for (const member of group.members) {
1247
+ if (member !== from) this.deliver(member, message);
1248
+ }
1018
1249
  } else {
1019
1250
  this.deliver(to, message);
1020
1251
  }
1021
1252
  return message;
1022
1253
  }
1023
- /** Queue or hand off a message; wake the first matching waiter for its target. */
1254
+ /** Queue or hand off a message; wake the first matching waiter for its target.
1255
+ * History / onMessage are recorded by the caller (route / sendToGroup) —
1256
+ * exactly once per message, never per fan-out target. */
1024
1257
  deliver(target, message) {
1025
1258
  const list = this.waiters.get(target) ?? [];
1026
1259
  const index = list.findIndex((waiter) => {
1027
1260
  if (waiter.from !== void 0 && waiter.from !== message.from) return false;
1028
1261
  if (waiter.ref !== void 0 && !(message.kind === "ack" && message.ref === waiter.ref)) return false;
1262
+ if (waiter.threadId !== void 0 && message.threadId !== waiter.threadId) return false;
1029
1263
  return true;
1030
1264
  });
1265
+ if (message.kind === "task") this.markTaskDelivered(message.id, target, message.ts);
1031
1266
  if (index >= 0) {
1032
1267
  const [waiter] = list.splice(index, 1);
1033
1268
  this.waiters.set(target, list);
1034
1269
  clearTimeout(waiter.timer);
1035
- this.remember(message);
1036
1270
  waiter.resolve({ type: "message", message });
1037
1271
  return;
1038
1272
  }
@@ -1040,11 +1274,10 @@ var AgentHub = class {
1040
1274
  if (queue.length >= this.options.maxQueue) queue.shift();
1041
1275
  queue.push(message);
1042
1276
  this.queues.set(target, queue);
1043
- this.remember(message);
1044
1277
  this.options.onQueued?.(message, target);
1045
1278
  }
1046
- /** Append a delivered message to the history ring (and fire the
1047
- * persistence hook — one call per message, delivered or queued). */
1279
+ /** Append a sent message to the history ring (and fire the persistence /
1280
+ * relay hooks). Exactly one call per message — see route() / sendToGroup(). */
1048
1281
  remember(message) {
1049
1282
  this.historyRing.push(message);
1050
1283
  if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
@@ -1209,11 +1442,13 @@ function loadTokenFileVoid(rows) {
1209
1442
  // src/hub-tools.ts
1210
1443
  var DEFAULT_WAIT_MS = 3e4;
1211
1444
  function present(message) {
1212
- const { ref, channel, ...rest } = message;
1445
+ const { ref, channel, threadId, hop, ...rest } = message;
1213
1446
  return {
1214
1447
  ...rest,
1215
1448
  ...ref !== void 0 ? { ref } : {},
1216
1449
  ...channel !== void 0 ? { channel } : {},
1450
+ ...threadId !== void 0 ? { threadId } : {},
1451
+ ...hop !== void 0 ? { hop } : {},
1217
1452
  content: decodeContent(message.kind, message.content)
1218
1453
  };
1219
1454
  }
@@ -1314,7 +1549,10 @@ function hubTools(hub, registry, options) {
1314
1549
  from: message.from,
1315
1550
  to: message.to,
1316
1551
  kind: message.kind,
1552
+ ...message.ref !== void 0 ? { ref: message.ref } : {},
1317
1553
  ...message.channel !== void 0 ? { channel: message.channel } : {},
1554
+ ...message.threadId !== void 0 ? { threadId: message.threadId } : {},
1555
+ ...message.hop !== void 0 ? { hop: message.hop } : {},
1318
1556
  ts: message.ts
1319
1557
  });
1320
1558
  const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
@@ -1456,70 +1694,93 @@ function hubTools(hub, registry, options) {
1456
1694
  },
1457
1695
  {
1458
1696
  name: "bridge_chat",
1459
- 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.',
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.',
1460
1698
  inputSchema: schema(
1461
- { to: str('Target peerId, or "all" to broadcast.'), message: str("The message text.") },
1699
+ {
1700
+ to: str('Target peerId, group id, or "all" to broadcast.'),
1701
+ message: str("The message text."),
1702
+ threadId: optStr("Optional conversation / workflow thread id."),
1703
+ ref: optStr("Optional reply-to message id.")
1704
+ },
1462
1705
  ["to", "message"]
1463
1706
  ),
1464
- handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message))))
1707
+ handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message), {
1708
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {},
1709
+ ...args.ref !== void 0 ? { ref: String(args.ref) } : {}
1710
+ })))
1465
1711
  },
1466
1712
  {
1467
1713
  name: "bridge_task",
1468
- 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 }).",
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.',
1469
1715
  inputSchema: schema(
1470
1716
  {
1471
- to: str('Target peerId, or "all" to broadcast.'),
1717
+ to: str('Target peerId, group id, or "all" to broadcast.'),
1472
1718
  prompt: str("What the receiving agent should do."),
1473
1719
  context: optStr("Optional background information for the task."),
1474
- deliverable: optStr("Optional expected deliverable description.")
1720
+ deliverable: optStr("Optional expected deliverable description."),
1721
+ timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1722
+ threadId: optStr("Optional conversation / workflow thread id.")
1475
1723
  },
1476
1724
  ["to", "prompt"]
1477
1725
  ),
1478
1726
  handler: wrap(true, async (args, peer) => receipt(hub.sendTask(peer, String(args.to), {
1479
1727
  prompt: String(args.prompt),
1480
1728
  ...args.context !== void 0 ? { context: String(args.context) } : {},
1481
- ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {}
1729
+ ...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {},
1730
+ ...args.timeoutMs !== void 0 ? { timeoutMs: Number(args.timeoutMs) } : {},
1731
+ ...args.threadId !== void 0 ? { threadId: String(args.threadId) } : {}
1482
1732
  })))
1483
1733
  },
1484
1734
  {
1485
1735
  name: "bridge_ack",
1486
- 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).",
1736
+ 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.",
1487
1737
  inputSchema: schema(
1488
1738
  {
1489
1739
  ref: str("The id of the message being acknowledged."),
1490
- status: { type: "string", enum: ["accepted", "rejected", "done", "failed"], description: "acknowledgement status." },
1491
- note: optStr("Optional explanation for the acknowledgement.")
1740
+ status: { type: "string", enum: ["accepted", "working", "rejected", "done", "failed"], description: "acknowledgement status." },
1741
+ note: optStr("Optional explanation for the acknowledgement."),
1742
+ progress: int("Optional 0-100 progress (meaningful with status=working).")
1492
1743
  },
1493
1744
  ["ref", "status"]
1494
1745
  ),
1495
1746
  handler: wrap(true, async (args, peer) => {
1496
1747
  const status = String(args.status);
1497
- const valid = ["accepted", "rejected", "done", "failed"];
1748
+ const valid = ["accepted", "working", "rejected", "done", "failed"];
1498
1749
  if (!valid.includes(status)) {
1499
1750
  throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
1500
1751
  }
1501
- return receipt(hub.sendAck(peer, String(args.ref), { status, ...args.note !== void 0 ? { note: String(args.note) } : {} }));
1752
+ return receipt(hub.sendAck(peer, String(args.ref), {
1753
+ status,
1754
+ ...args.note !== void 0 ? { note: String(args.note) } : {},
1755
+ ...args.progress !== void 0 ? { progress: Number(args.progress) } : {}
1756
+ }));
1502
1757
  })
1503
1758
  },
1504
1759
  {
1505
1760
  name: "bridge_tasks",
1506
- 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.",
1761
+ 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.",
1507
1762
  inputSchema: schema({
1508
1763
  role: { type: "string", enum: ["sent", "received", "all"], description: "Which side of the task to list (default all)." },
1509
- status: { type: "string", enum: ["pending", "accepted", "rejected", "done", "failed"], description: "Only tasks whose current ledger status matches." },
1764
+ status: { type: "string", enum: ["pending", "accepted", "working", "rejected", "done", "failed", "timeout"], description: "Only tasks whose current ledger status matches." },
1765
+ peer: optStr('Manager only: "all" or another peerId for the admin board.'),
1510
1766
  limit: int("How many tasks to return (default 50).")
1511
1767
  }),
1512
- handler: wrap(true, async (args, peer) => {
1768
+ handler: wrap(true, async (args, peer, sessionId) => {
1513
1769
  const role = args.role === void 0 ? void 0 : String(args.role);
1514
1770
  if (role !== void 0 && role !== "sent" && role !== "received" && role !== "all") {
1515
1771
  throw new Error(`invalid role: ${role} (expected sent | received | all)`);
1516
1772
  }
1517
1773
  const status = args.status === void 0 ? void 0 : String(args.status);
1518
- const validStatus = ["pending", "accepted", "rejected", "done", "failed"];
1774
+ const validStatus = ["pending", "accepted", "working", "rejected", "done", "failed", "timeout"];
1519
1775
  if (status !== void 0 && !validStatus.includes(status)) {
1520
1776
  throw new Error(`invalid status: ${status} (expected ${validStatus.join(" | ")})`);
1521
1777
  }
1522
- const tasks = hub.listTasks(peer, {
1778
+ let target = peer;
1779
+ if (args.peer !== void 0) {
1780
+ target = String(args.peer);
1781
+ if (target !== peer) requireManager(peer, `listing tasks of '${target}'`, sessionId);
1782
+ }
1783
+ const tasks = hub.listTasks(target === "all" ? "all" : target, {
1523
1784
  ...role !== void 0 ? { role } : {},
1524
1785
  ...status !== void 0 ? { status } : {},
1525
1786
  ...args.limit !== void 0 ? { limit: Number(args.limit) } : {}
@@ -1529,7 +1790,7 @@ function hubTools(hub, registry, options) {
1529
1790
  },
1530
1791
  {
1531
1792
  name: "bridge_task_status",
1532
- 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).",
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).",
1533
1794
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1534
1795
  handler: wrap(true, async (args, peer, sessionId) => {
1535
1796
  const ref = String(args.ref);
@@ -1543,10 +1804,11 @@ function hubTools(hub, registry, options) {
1543
1804
  },
1544
1805
  {
1545
1806
  name: "bridge_wait",
1546
- 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.",
1807
+ description: "Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. `ref` waits specifically for the ack of that task id. `threadId` narrows to one conversation thread. Loop this tool to hold a real-time conversation.",
1547
1808
  inputSchema: schema({
1548
1809
  from: optStr("Only wait for messages from this peerId."),
1549
1810
  ref: optStr("Only wait for the ack of this task message id (bridge_task id)."),
1811
+ threadId: optStr("Only wait for messages carrying this threadId."),
1550
1812
  timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
1551
1813
  }),
1552
1814
  handler: wrap(true, async (args, peer) => presentWait(await hub.wait(
@@ -1554,7 +1816,8 @@ function hubTools(hub, registry, options) {
1554
1816
  args.timeoutMs === void 0 ? options.defaultWaitMs ?? DEFAULT_WAIT_MS : Number(args.timeoutMs),
1555
1817
  args.from === void 0 ? void 0 : String(args.from),
1556
1818
  void 0,
1557
- args.ref === void 0 ? void 0 : String(args.ref)
1819
+ args.ref === void 0 ? void 0 : String(args.ref),
1820
+ args.threadId === void 0 ? void 0 : String(args.threadId)
1558
1821
  )))
1559
1822
  },
1560
1823
  {
@@ -2374,7 +2637,7 @@ function readBody(req) {
2374
2637
  import { DatabaseSync } from "node:sqlite";
2375
2638
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2376
2639
  function messageToRow(message) {
2377
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.ts];
2640
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
2378
2641
  }
2379
2642
  function rowToMessage(row) {
2380
2643
  const id = row.id;
@@ -2393,6 +2656,7 @@ function rowToMessage(row) {
2393
2656
  content,
2394
2657
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2395
2658
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2659
+ ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
2396
2660
  ts
2397
2661
  };
2398
2662
  }
@@ -2407,16 +2671,25 @@ var SQLiteStateStore = class {
2407
2671
  );
2408
2672
  CREATE TABLE IF NOT EXISTS messages (
2409
2673
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2410
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER 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
2411
2675
  );
2412
2676
  CREATE TABLE IF NOT EXISTS mailboxes (
2413
2677
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2414
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, ts INTEGER 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
2415
2679
  );
2416
2680
  CREATE TABLE IF NOT EXISTS groups (
2417
2681
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2418
2682
  );
2683
+ CREATE TABLE IF NOT EXISTS tasks (
2684
+ id TEXT PRIMARY KEY, body TEXT NOT NULL, updated_at INTEGER NOT NULL
2685
+ );
2419
2686
  `);
2687
+ for (const table of ["messages", "mailboxes"]) {
2688
+ try {
2689
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2690
+ } catch {
2691
+ }
2692
+ }
2420
2693
  log2.info(`sqlite state open: ${file}`);
2421
2694
  }
2422
2695
  db;
@@ -2425,17 +2698,17 @@ var SQLiteStateStore = class {
2425
2698
  appendMessage(message) {
2426
2699
  this.buffer.push(message);
2427
2700
  }
2428
- /** Insert buffered messages and re-snapshot mailboxes/profiles/groups. */
2701
+ /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2429
2702
  flush(snapshot) {
2430
2703
  const insert = this.db.prepare(
2431
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
2704
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2432
2705
  );
2433
2706
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2434
2707
  insert.run(...messageToRow(message));
2435
2708
  }
2436
2709
  this.db.exec("DELETE FROM mailboxes");
2437
2710
  const insertMail = this.db.prepare(
2438
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2711
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2439
2712
  );
2440
2713
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2441
2714
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
@@ -2454,6 +2727,13 @@ var SQLiteStateStore = class {
2454
2727
  for (const group of snapshot.groups) {
2455
2728
  insertGroup.run(group.id, group.name ?? null, JSON.stringify(group.members), group.createdBy, group.createdAt);
2456
2729
  }
2730
+ this.db.exec("DELETE FROM tasks");
2731
+ const insertTask = this.db.prepare(
2732
+ "INSERT INTO tasks (id, body, updated_at) VALUES (?, ?, ?)"
2733
+ );
2734
+ for (const task of snapshot.tasks) {
2735
+ insertTask.run(task.id, JSON.stringify(task), task.updatedAt);
2736
+ }
2457
2737
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM messages").get();
2458
2738
  const total = Number(row.n);
2459
2739
  if (total > this.historyLimit * 2) {
@@ -2501,7 +2781,16 @@ var SQLiteStateStore = class {
2501
2781
  } catch {
2502
2782
  }
2503
2783
  }
2504
- return { profiles, messages, mailboxes, groups };
2784
+ const tasks = [];
2785
+ for (const row of this.db.prepare("SELECT * FROM tasks").all()) {
2786
+ if (typeof row.body !== "string") continue;
2787
+ try {
2788
+ const parsed = JSON.parse(row.body);
2789
+ if (typeof parsed === "object" && parsed !== null) tasks.push(parsed);
2790
+ } catch {
2791
+ }
2792
+ }
2793
+ return { profiles, messages, mailboxes, groups, tasks };
2505
2794
  }
2506
2795
  /** Final flush + close (hub shutdown). */
2507
2796
  close(snapshot) {
@@ -2512,7 +2801,7 @@ var SQLiteStateStore = class {
2512
2801
 
2513
2802
  // src/index.ts
2514
2803
  var SERVER_NAME = "agent-comm-hub";
2515
- var SERVER_VERSION = "0.8.0";
2804
+ var SERVER_VERSION = "0.9.0";
2516
2805
  var DEFAULT_HOST = "127.0.0.1";
2517
2806
  var DEFAULT_PORT = 18764;
2518
2807
  var DEFAULT_PATH = "/mcp";
@@ -2615,7 +2904,7 @@ function startHub(config = {}, log2 = console) {
2615
2904
  if (dbDirty && dbStore !== void 0) {
2616
2905
  dbDirty = false;
2617
2906
  try {
2618
- dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
2907
+ dbStore.flush({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups(), tasks: hub.exportTasks() });
2619
2908
  } catch (error) {
2620
2909
  log2.warn(`sqlite flush failed: ${error.message}`);
2621
2910
  }
@@ -2645,6 +2934,7 @@ function startHub(config = {}, log2 = console) {
2645
2934
  if (dbStore !== void 0) {
2646
2935
  dbStore.appendMessage(message);
2647
2936
  dbDirty = true;
2937
+ scheduleSave();
2648
2938
  }
2649
2939
  try {
2650
2940
  pushRelay(message);
@@ -2655,6 +2945,15 @@ function startHub(config = {}, log2 = console) {
2655
2945
  onMailboxesChanged: () => {
2656
2946
  if (dbStore === void 0) return;
2657
2947
  dbDirty = true;
2948
+ scheduleSave();
2949
+ },
2950
+ // Task ledger transitions (ack folding, SLA timeout, first delivery) are
2951
+ // their own persistence trigger: without it a crash between two roster
2952
+ // events loses the ledger state the admin board reads back.
2953
+ onTaskChanged: () => {
2954
+ if (dbStore === void 0) return;
2955
+ dbDirty = true;
2956
+ scheduleSave();
2658
2957
  },
2659
2958
  onGroupsChanged: () => {
2660
2959
  if (dbStore === void 0) return;
@@ -2705,6 +3004,7 @@ function startHub(config = {}, log2 = console) {
2705
3004
  const restoredMessages = hub.importHistory(state.messages);
2706
3005
  const restoredQueued = hub.importMailboxes(state.mailboxes);
2707
3006
  const restoredGroups = hub.importGroups(state.groups);
3007
+ const restoredTasks = hub.importTasks(state.tasks);
2708
3008
  if (restoredMessages + restoredQueued + restoredGroups > 0) {
2709
3009
  log2.info(`state restored from ${resolved.db}: ${restoredMessages} message(s), ${restoredQueued} queued, ${restoredGroups} group(s)`);
2710
3010
  dbDirty = true;
@@ -2807,7 +3107,7 @@ function startHub(config = {}, log2 = console) {
2807
3107
  flushSaves();
2808
3108
  if (dbStore !== void 0) {
2809
3109
  try {
2810
- dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups() });
3110
+ dbStore.close({ mailboxes: hub.exportMailboxes(), profiles: hub.exportProfiles(), groups: hub.exportGroups(), tasks: hub.exportTasks() });
2811
3111
  } catch (error) {
2812
3112
  log2.warn(`sqlite close failed: ${error.message}`);
2813
3113
  }
@@ -2883,6 +3183,9 @@ function validateRegistry(registry) {
2883
3183
  if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
2884
3184
  throw new Error(`registry: ${agent.id}: skill must be '~'-relative or null`);
2885
3185
  }
3186
+ if (agent.serverName !== void 0 && (typeof agent.serverName !== "string" || agent.serverName === "")) {
3187
+ throw new Error(`registry: ${agent.id}: serverName must be a non-empty string`);
3188
+ }
2886
3189
  if (agent.os !== void 0 && (!Array.isArray(agent.os) || agent.os.some((os2) => !["win32", "darwin", "linux"].includes(os2)))) {
2887
3190
  throw new Error(`registry: ${agent.id}: invalid os list`);
2888
3191
  }
@@ -3134,7 +3437,7 @@ async function mergeDshPatch(file, opts) {
3134
3437
  return "removed";
3135
3438
  }
3136
3439
  const ranges = hubEntryRanges();
3137
- if (ranges.length === 1 && lines.slice(ranges[0].start, ranges[0].end).some((line) => line.includes(`url: ${opts.url}`))) {
3440
+ if (ranges.length === 1 && lines.slice(ranges[0].start, ranges[0].end).some((line) => line.includes(`url: ${opts.url}`)) && lines.slice(ranges[0].start, ranges[0].end).some((line) => line.includes(`serverName: ${opts.serverName}`))) {
3138
3441
  return "unchanged";
3139
3442
  }
3140
3443
  await backup(file);
@@ -3205,9 +3508,10 @@ async function runSetup(options = {}) {
3205
3508
  }
3206
3509
  for (const agent of targetAgents) {
3207
3510
  for (const config of agent.configs) {
3511
+ const nameFor = config.serverName ?? agent.serverName ?? serverName;
3208
3512
  for (const file of expandConfigFile(config.file, home)) {
3209
3513
  try {
3210
- const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName }), { serverName, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName, url, remove }) : await mergeDshPatch(file, { serverName, url, remove });
3514
+ const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName: nameFor }), { serverName: nameFor, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName: nameFor, url, remove }) : await mergeDshPatch(file, { serverName: nameFor, url, remove });
3211
3515
  record(status, agent.id, file);
3212
3516
  } catch (error) {
3213
3517
  summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);