@cotal-ai/connector-core 0.58.0 → 0.60.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.
@@ -11,6 +11,8 @@ import { execFileSync } from "node:child_process";
11
11
  import { z } from "zod";
12
12
  import { isConcreteChannel, channelInAllow, AmbiguousPeerError, assertLifecycleToken, isPermissionDenied, renderLifecycleBlocked, LANG_PROBLEM_DETAIL_KIND } from "@cotal-ai/core";
13
13
  import { afterRecallMark } from "./agent.js";
14
+ // The neutralization and the per-item rendering live in `framing.ts`, one convention shared with
15
+ // the auto-injected block, and are used here rather than restated. See that file for the rule.
14
16
  import { attributionSafe, fmtBody, fmtItem, fmtFrom } from "./framing.js";
15
17
  import { FEEDBACK_URL, PUBLIC_FEEDBACK_URL, isAuthed } from "./config.js";
16
18
  import { buildOrientation, renderOrientation } from "./orientation.js";
@@ -81,6 +83,9 @@ export function parseToolArgs(spec, args) {
81
83
  .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
82
84
  .join("; ")}`);
83
85
  }
86
+ /** The closed EMPTY input, for an adapter that republishes a tool with no arguments of its own.
87
+ * A host given this refuses extras itself; a host given no `inputSchema` at all forwards them. */
88
+ export const NO_TOOL_ARGS = z.strictObject({});
84
89
  /**
85
90
  * Refuse ANY caller-supplied argument to a tool an adapter publishes with none — returning the
86
91
  * refusal text, or `undefined` when the call is clean.
@@ -91,13 +96,21 @@ export function parseToolArgs(spec, args) {
91
96
  * tool while every sibling refuses it. The wording matches {@link parseToolArgs} so a caller cannot
92
97
  * tell which mechanism turned it away, and this stays dependency-free for hosts that bundle.
93
98
  */
94
- /** The closed EMPTY input, for an adapter that republishes a tool with no arguments of its own.
95
- * A host given this refuses extras itself; a host given no `inputSchema` at all forwards them. */
96
- export const NO_TOOL_ARGS = z.strictObject({});
97
99
  export function refuseAnyArgs(name, args) {
98
100
  const keys = args && typeof args === "object" ? Object.keys(args) : [];
99
101
  return keys.length ? `${name}: unknown argument(s): ${keys.join(", ")} — this tool takes no arguments` : undefined;
100
102
  }
103
+ /** Compact age: `12s`, `47m`, `3h`, `2d`. */
104
+ function ageText(ms) {
105
+ const s = Math.max(0, Math.floor(ms / 1000));
106
+ if (s < 60)
107
+ return `${s}s`;
108
+ if (s < 3600)
109
+ return `${Math.floor(s / 60)}m`;
110
+ if (s < 86_400)
111
+ return `${Math.floor(s / 3600)}h`;
112
+ return `${Math.floor(s / 86_400)}d`;
113
+ }
101
114
  function statusGlyph(s) {
102
115
  return s === "working" ? "●" : s === "waiting" ? "◐" : s === "idle" ? "○" : "·";
103
116
  }
@@ -108,8 +121,6 @@ const ATTENTION_DESC = {
108
121
  dnd: "dnd — channel chatter no longer wakes you (it still arrives in your next turn); DMs, anycast, and @mentions still wake you",
109
122
  focus: "focus — only DMs and anycast reach your context; an @mention wakes you to pull; untagged channel chatter is held on the channel — read it with cotal_inbox",
110
123
  };
111
- /** The neutralization and the per-item rendering live in `framing.ts`, one convention shared with
112
- * the auto-injected block, and are used here rather than restated. See that file for the rule. */
113
124
  /**
114
125
  * HOW MUCH OF THE INBOX ONE RESPONSE MAY CARRY, in characters.
115
126
  *
@@ -157,9 +168,12 @@ export function renderInbox(opts) {
157
168
  //
158
169
  // Stuck means "no response could carry this", so it is measured against the friendliest response
159
170
  // there is: this item alone, its head, and any rider, with no held-note at all.
171
+ // Every set in this walk is keyed by receive key. An id-less item's wire id is the empty string,
172
+ // so a set of wire ids makes every id-less item the same item: a held giant vanished from the
173
+ // note when a small id-less item went out, and recall moved its mark past an unread one (#613).
160
174
  const stuck = new Set(ordered
161
175
  .filter((i) => opts.head([i]).length + 1 + itemCost(i) + (warning ? warning.length + 2 : 0) > budget)
162
- .map((i) => i.id));
176
+ .map((i) => i.recvKey));
163
177
  const assemble = (shown, held, tier) => {
164
178
  const note = heldNote(held, peek, stuck, tier);
165
179
  const parts = [];
@@ -194,14 +208,14 @@ export function renderInbox(opts) {
194
208
  let used = 0;
195
209
  let strictGap = false; // the in-order lane stops at its first gap; the free lane steps over its own
196
210
  for (const i of ordered) {
197
- const strict = strictIds.has(i.id);
211
+ const strict = strictIds.has(i.recvKey);
198
212
  if (strict && strictGap)
199
213
  continue;
200
214
  const cost = itemCost(i);
201
215
  if (used + cost > budget) {
202
- // A message nothing could ever carry is not a gap: it will never become deliverable, so the
203
- // walk steps over it and the note says so. Anything else IS a gap, and the ordered lane waits.
204
- if (strict && !stuck.has(i.id))
216
+ // In the ordered lane even a message no response could carry whole is a gap: the mark would
217
+ // pass it if the walk stepped over it, so the lane waits for it to go out in parts (#613).
218
+ if (strict)
205
219
  strictGap = true;
206
220
  continue;
207
221
  }
@@ -209,8 +223,8 @@ export function renderInbox(opts) {
209
223
  used += cost;
210
224
  }
211
225
  const heldOf = () => {
212
- const ids = new Set(shown.map((i) => i.id));
213
- return ordered.filter((i) => !ids.has(i.id));
226
+ const keys = new Set(shown.map((i) => i.recvKey));
227
+ return ordered.filter((i) => !keys.has(i.recvKey));
214
228
  };
215
229
  let held = heldOf();
216
230
  let text = assemble(shown, held, "full");
@@ -241,9 +255,9 @@ function itemCost(i) {
241
255
  * The tail that keeps a windowed response honest: what is still there, and that it was not lost.
242
256
  *
243
257
  * TWO KINDS OF HELD, because they are not the same promise. Most held mail is waiting its turn and
244
- * a later call delivers it. A message larger than one whole response is not waiting for anything:
245
- * calling again will never produce it, and saying "call again for the next batch" over it would be
246
- * a queue that looks like it is moving when it is not.
258
+ * a later call delivers it whole. A message larger than one whole response never arrives whole: it
259
+ * goes out in parts through {@link renderPart}, after the smaller mail, so the note says that rather
260
+ * than promising it in the next batch.
247
261
  *
248
262
  * THE NOTE IS BOUNDED. It names at most {@link NAMED_STUCK} of the stuck messages and counts the
249
263
  * rest, and it truncates a sender's name, because a steady stream of oversized mail would otherwise
@@ -252,7 +266,7 @@ function itemCost(i) {
252
266
  function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
253
267
  if (!held.length || tier === "none")
254
268
  return "";
255
- const stuck = held.filter((i) => stuckIds.has(i.id));
269
+ const stuck = held.filter((i) => stuckIds.has(i.recvKey));
256
270
  const waiting = held.length - stuck.length;
257
271
  if (tier === "compact") {
258
272
  const bits = [];
@@ -269,7 +283,7 @@ function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
269
283
  }
270
284
  const parts = [];
271
285
  if (waiting) {
272
- const dms = held.filter((i) => i.kind !== "channel" && !stuckIds.has(i.id)).length;
286
+ const dms = held.filter((i) => i.kind !== "channel" && !stuckIds.has(i.recvKey)).length;
273
287
  // Under peek nothing is cleared, so the next call returns THIS window again. Telling a peeking
274
288
  // caller to call again for the next batch is a promise the read cannot keep, and an obedient
275
289
  // caller loops on it forever.
@@ -284,10 +298,115 @@ function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
284
298
  .map((i) => `${fmtFrom(i).slice(0, 40)} (${itemCost(i).toLocaleString("en-US")} chars)`)
285
299
  .join(", ");
286
300
  const rest = stuck.length - Math.min(NAMED_STUCK, stuck.length);
287
- parts.push(`${stuck.length} message${stuck.length === 1 ? " is" : "s are"} larger than one response can carry and cannot be delivered by this tool at all: ${named}${rest ? `, and ${rest} more` : ""}. ${stuck.length === 1 ? "It stays" : "They stay"} buffered and uncleared, and calling again will not produce ${stuck.length === 1 ? "it" : "them"}.`);
301
+ parts.push(`${stuck.length} message${stuck.length === 1 ? " is" : "s are"} larger than one response can carry: ${named}${rest ? `, and ${rest} more` : ""}. ${stuck.length === 1 ? "It stays" : "They stay"} buffered and uncleared, and once no smaller mail is waiting, each call delivers the next part of one of them; a message is cleared only after its last part goes out.`);
288
302
  }
289
303
  return `\n\n… ${parts.join(" ")}`;
290
304
  }
305
+ /**
306
+ * How far each oversized message has been read, by receive key, per agent (#613).
307
+ *
308
+ * It lives beside the agent rather than in the reply, so a reconnect, which keeps the agent and its
309
+ * buffer, resumes where the last part ended. A process restart loses it, and the redelivered message
310
+ * starts again from its first part: repeated, never skipped. An entry goes when its message is no
311
+ * longer offered, whether by its last part or by anything else that consumed it.
312
+ */
313
+ const partOffsets = new WeakMap();
314
+ /**
315
+ * One `cotal_inbox` read at a time per agent (#613). A read decides what to hide, render and clear from
316
+ * state it took before awaiting recall, so a second read that overlapped it decided from a snapshot the
317
+ * first had already moved past: it hid a recalled message the first had just read in part, and moved
318
+ * the recall mark past the rest of it. A read waits for the one before it to finish, failed or not.
319
+ */
320
+ const inboxReads = new WeakMap();
321
+ function oneReadAtATime(read) {
322
+ return (agent, config, args) => {
323
+ const turn = (inboxReads.get(agent) ?? Promise.resolve()).then(() => read(agent, config, args));
324
+ inboxReads.set(agent, turn.catch(() => undefined));
325
+ return turn;
326
+ };
327
+ }
328
+ /** The read position for an oversized message, after dropping positions of messages no longer offered. */
329
+ function partCursor(agent, offered) {
330
+ let m = partOffsets.get(agent);
331
+ if (!m)
332
+ partOffsets.set(agent, (m = new Map()));
333
+ const live = new Set(offered.map((i) => i.recvKey));
334
+ for (const k of m.keys())
335
+ if (!live.has(k))
336
+ m.delete(k);
337
+ return m;
338
+ }
339
+ /**
340
+ * One part of a message no single response can carry: the next slice of its rendered form, with a
341
+ * line saying which characters these are and whether more follow (#613).
342
+ *
343
+ * The slice is of the RENDERED item, so its indented continuations come with it, and it starts on
344
+ * its own indented line, so a cut that lands mid-line cannot put peer text at column zero. Its size
345
+ * is what the window has left once the line above it, the note and any rider are in, and the
346
+ * finished text is measured like {@link renderInbox}'s. `end` is where the next part starts; the
347
+ * caller clears the message only when `done`, and advances nothing on a peek.
348
+ */
349
+ function renderPart(opts) {
350
+ const budget = opts.budget ?? INBOX_WINDOW_CHARS;
351
+ const full = fmtItem(opts.item);
352
+ const total = full.length;
353
+ const fmt = (n) => n.toLocaleString("en-US");
354
+ const assemble = (end, tier) => {
355
+ const done = end >= total;
356
+ const next = opts.peek
357
+ ? "A peek advances nothing, so read without peek to take this part."
358
+ : done
359
+ ? "This was its last part, so it is now cleared."
360
+ : "It stays buffered and uncleared until its last part goes out; call cotal_inbox again for the next part.";
361
+ const head = `Part of a message larger than one response, from ${fmtFrom(opts.item).slice(0, 40)}: characters ${fmt(opts.offset + 1)}-${fmt(end)} of ${fmt(total)}${opts.peek ? " (peek: nothing cleared)" : ""}. ${next}`;
362
+ const body = `${head}\n ${full.slice(opts.offset, end)}${heldNote(opts.others, opts.peek, opts.stuck, tier)}`;
363
+ return opts.warning ? `${body}\n\n${opts.warning}` : body;
364
+ };
365
+ for (const tier of NOTE_TIERS) {
366
+ // Measure everything but the slice with the widest numbers the header can carry, then fill.
367
+ const room = budget - assemble(opts.offset, tier).length - 2 * fmt(total).length;
368
+ if (room <= 0)
369
+ continue;
370
+ let end = Math.min(total, opts.offset + room);
371
+ // Never split a surrogate pair across two parts.
372
+ if (end < total && /[\uD800-\uDBFF]/.test(full[end - 1]))
373
+ end--;
374
+ const text = assemble(end, tier);
375
+ if (text.length <= budget && end > opts.offset)
376
+ return { text, end, done: end >= total };
377
+ }
378
+ throw new Error(`cotal_inbox: no room for a part of a ${fmt(total)}-character message in a ${fmt(budget)}-character window`);
379
+ }
380
+ /**
381
+ * The reply for a call that could carry no whole message: the next part of `item`, a message too
382
+ * large for any response, or `undefined` when there is none. The read position moves only when the
383
+ * part went out, and `last` records the message as handed over only with its last part, so nothing
384
+ * is cleared before it was handed over (#603). `last` is a buffered message's ack, or for focus
385
+ * recall the mark or the ahead record that a whole recalled item would have moved.
386
+ */
387
+ function partReply(opts) {
388
+ const { agent, item, peek } = opts;
389
+ if (!item)
390
+ return undefined;
391
+ const cursor = partCursor(agent, opts.offered);
392
+ const part = renderPart({
393
+ item,
394
+ offset: cursor.get(item.recvKey) ?? 0,
395
+ peek,
396
+ others: opts.offered.filter((i) => i !== item),
397
+ stuck: opts.stuck,
398
+ warning: opts.warning,
399
+ });
400
+ if (!peek) {
401
+ if (part.done) {
402
+ opts.last(item);
403
+ cursor.delete(item.recvKey);
404
+ }
405
+ else
406
+ cursor.set(item.recvKey, part.end);
407
+ }
408
+ return part.text;
409
+ }
291
410
  /**
292
411
  * The recall warning, bounded and budgeted like every other part of a response.
293
412
  *
@@ -589,7 +708,12 @@ export function cotalToolSpecs(config, source = "connector") {
589
708
  .map(([c]) => `#${c}`)
590
709
  : [];
591
710
  const mutedHint = muted.length ? ` (locally muted ${muted.join(", ")}; DM to reach)` : "";
592
- const condition = p.condition ? ` (${p.condition.code})` : "";
711
+ // The condition with how long it has held, and the age of the last harness-reported work
712
+ // event (#618): a turn that died or stopped advancing 40m ago no longer reads like a live one.
713
+ const now = Date.now();
714
+ const since = p.condition?.since;
715
+ const active = p.activeAt === undefined ? "" : ` · active ${ageText(now - p.activeAt)} ago`;
716
+ const condition = (p.condition ? ` (${p.condition.code}${since === undefined ? "" : ` for ${ageText(now - since)}`})` : "") + active;
593
717
  const progress = p.status === "working" ? `working${condition} · progress unknown` : `${p.status}${condition}`;
594
718
  return `${statusGlyph(p.status)} ${who} — ${progress}${p.activity ? `: ${p.activity}` : ""}${attn}${me}${mutedHint}${id}`;
595
719
  });
@@ -603,11 +727,11 @@ export function cotalToolSpecs(config, source = "connector") {
603
727
  {
604
728
  name: "cotal_inbox",
605
729
  title: "Cotal: read incoming messages",
606
- description: "Read messages other agents have sent you since you last checked: channel broadcasts, direct messages, and role requests. It clears ONLY what it actually returns to you (nothing at all when peek is true), and one call carries at most a receivable window: direct messages and role requests first, then channel traffic, with replayed history last. Anything that does not fit stays buffered and is named in the reply, so call again for the next batch. A single message larger than one whole response is never consumed either: it is named with its sender and size and stays buffered, since delivering it is impossible and clearing it would lose it. In focus mode it also pulls back the channel chatter held since you entered focus.",
730
+ description: "Read messages other agents have sent you since you last checked: channel broadcasts, direct messages, and role requests. It clears ONLY what it actually returns to you (nothing at all when peek is true), and one call carries at most a receivable window: direct messages and role requests first, then channel traffic, with replayed history last. Anything that does not fit stays buffered and is named in the reply, so call again for the next batch. A single message larger than one whole response is delivered in parts: once no smaller mail is waiting, each call carries the next part of it, a peek shows the current part without moving on, and the message is cleared only after its last part goes out. In focus mode it also pulls back the channel chatter held since you entered focus.",
607
731
  schema: {
608
732
  peek: z.boolean().optional().describe("If true, show messages without clearing them."),
609
733
  },
610
- async run(agent, _config, { peek, scope }) {
734
+ run: oneReadAtATime(async (agent, _config, { peek, scope }) => {
611
735
  const inboxScope = scope ?? "all";
612
736
  // SELECT, RENDER, THEN CLEAR EXACTLY WHAT WENT OUT (#603). The old order drained the whole
613
737
  // scope up front, so a payload too large for the host to deliver had already been marked
@@ -616,10 +740,10 @@ export function cotalToolSpecs(config, source = "connector") {
616
740
  // It is not the only acker: the inbox's own overflow valve acks what it evicts, so an item
617
741
  // that arrives while this call is awaiting recall can still be evicted and lost. That is the
618
742
  // buffer's documented bounded local loss (see MeshAgent.buffer), unchanged by this path.
619
- const buffered = agent.peekInbox(inboxScope);
620
743
  const automaticPending = scope ? agent.inboxCount("automatic") : 0;
621
744
  if (agent.attention !== "focus") {
622
- const { text, shown, held } = renderInbox({
745
+ const buffered = agent.peekInbox(inboxScope);
746
+ const { text, shown, held, stuck } = renderInbox({
623
747
  items: buffered,
624
748
  peek,
625
749
  head: (s) => scope
@@ -630,6 +754,18 @@ export function cotalToolSpecs(config, source = "connector") {
630
754
  return ok(scope
631
755
  ? `No pull-only messages.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
632
756
  : "Inbox empty, no new messages.");
757
+ if (!shown.length) {
758
+ const part = partReply({
759
+ agent,
760
+ item: buffered.find((i) => stuck.has(i.recvKey)),
761
+ offered: buffered,
762
+ stuck,
763
+ peek: peek ?? false,
764
+ last: (i) => agent.drainInboxDeliveries([i.recvKey]),
765
+ });
766
+ if (part)
767
+ return ok(part);
768
+ }
633
769
  // The response exists before anything is acked: an ack is a claim that these messages were
634
770
  // handed over, so nothing may be cleared while the handing-over is still hypothetical. And
635
771
  // it is the ASSEMBLED response that decides, so what is acked is what a caller was handed.
@@ -640,8 +776,14 @@ export function cotalToolSpecs(config, source = "connector") {
640
776
  }
641
777
  // Focus: the live buffer holds only DMs/anycast; the channel ambient + @mentions were
642
778
  // acked-and-dropped at ingest, so pull them back from the channel stream here (replay-gated,
643
- // "since you entered focus"). Recall is read-only, so peek only affects the live buffer.
644
- const recall = await agent.recallAmbient();
779
+ // "since you entered focus"). Recall acks nothing, so peek only affects the live buffer.
780
+ // A recalled message already part read stays offered until its last part goes out (#613).
781
+ const before = new Set(agent.peekInbox("all").map((i) => i.recvKey));
782
+ const underway = new Set([...(partOffsets.get(agent)?.keys() ?? [])].filter((k) => !before.has(k)));
783
+ const recall = await agent.recallAmbient(underway);
784
+ // Read after recall, which can hand an id-less message back into the buffer (#662).
785
+ const buffered = agent.peekInbox(inboxScope);
786
+ const bufferedIds = new Set(buffered.map((i) => i.recvKey));
645
787
  // RECALL HAS TO ADVANCE, or windowing it starves it. Recall is re-derived from an unchanged
646
788
  // frontier on every call, so showing its first window and stopping there returned the same
647
789
  // prefix forever while the reply promised a next batch: measured as three identical replies
@@ -690,12 +832,11 @@ export function cotalToolSpecs(config, source = "connector") {
690
832
  const warning = [droppedNote(recall.droppedChannels), aheadNote(aheadWithheld)]
691
833
  .filter(Boolean)
692
834
  .join(" ");
693
- const bufferedIds = new Set(buffered.map((i) => i.recvKey));
694
835
  const { text, shown: all, stuck } = renderInbox({
695
836
  items: [...buffered, ...fresh],
696
837
  peek,
697
838
  warning,
698
- strictIds: new Set(clocked.map((i) => i.id)),
839
+ strictIds: new Set(clocked.map((i) => i.recvKey)),
699
840
  head: (s) => scope
700
841
  ? `${s.length} message${s.length === 1 ? "" : "s"}. Buffered pull-only items were cleared; normal focus channel items are read-only recall:`
701
842
  : `${s.length} message${s.length === 1 ? "" : "s"}${peek ? " (peek: live buffer not cleared)" : ""} in focus mode; channel items are recall since you focused:`,
@@ -704,6 +845,32 @@ export function cotalToolSpecs(config, source = "connector") {
704
845
  return ok(scope
705
846
  ? `No pull-only messages and no normal focus recall.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
706
847
  : "Inbox empty, no new messages, and no channel chatter since you entered focus.");
848
+ if (!all.length) {
849
+ // A recalled message goes out in parts too, and its last part moves what a whole one would
850
+ // have: the mark for the ordered lane, whose first item is the only one it may take, or the
851
+ // ahead record, so the mark never passes a message that has not been handed over.
852
+ const first = clocked[0];
853
+ const item = buffered.find((i) => stuck.has(i.recvKey)) ??
854
+ (first && stuck.has(first.recvKey) ? first : aheadFresh.find((i) => stuck.has(i.recvKey)));
855
+ const part = partReply({
856
+ agent,
857
+ item,
858
+ offered: [...buffered, ...fresh],
859
+ stuck,
860
+ peek: peek ?? false,
861
+ warning,
862
+ last: (i) => {
863
+ if (bufferedIds.has(i.recvKey))
864
+ agent.drainInboxDeliveries([i.recvKey]);
865
+ else if (aheadIds.has(i.recvKey))
866
+ agent.noteRecalledAhead(i.recvKey);
867
+ else
868
+ agent.noteRecalled({ ts: i.ts, id: i.recvKey });
869
+ },
870
+ });
871
+ if (part)
872
+ return ok(part);
873
+ }
707
874
  // Render first, ack second, and only ever ids from the buffered lane: acking a recall id
708
875
  // would mark it handled, so a later live copy of that channel message would be dropped.
709
876
  if (!peek) {
@@ -723,14 +890,14 @@ export function cotalToolSpecs(config, source = "connector") {
723
890
  for (const i of shownRecall)
724
891
  if (aheadIds.has(i.recvKey))
725
892
  agent.noteRecalledAhead(i.recvKey);
726
- const shownClocked = shownRecall.filter((i) => !aheadIds.has(i.id));
893
+ const shownClocked = shownRecall.filter((i) => !aheadIds.has(i.recvKey));
727
894
  const last = shownClocked[shownClocked.length - 1];
728
895
  if (last)
729
896
  agent.noteRecalled({ ts: last.ts, id: last.recvKey });
730
897
  void stuck;
731
898
  }
732
899
  return ok(text);
733
- },
900
+ }),
734
901
  },
735
902
  {
736
903
  name: "cotal_send",
@@ -767,10 +934,14 @@ export function cotalToolSpecs(config, source = "connector") {
767
934
  schema: {
768
935
  to: z.string().describe("The peer's name (or instance id)."),
769
936
  text: z.string().describe("The message."),
937
+ replyTo: z
938
+ .string()
939
+ .optional()
940
+ .describe("The id of the peer's message this DM answers. Omit it to answer the peer's oldest unanswered message; when that peer's waiting messages belong to more than one conversation, the DM is refused with their ids."),
770
941
  },
771
- async run(agent, _config, { to, text: msg }) {
942
+ async run(agent, _config, { to, text: msg, replyTo }) {
772
943
  try {
773
- const { peer, ack, recipientStatusAtSend } = await agent.dm(to, msg);
944
+ const { peer, ack, recipientStatusAtSend } = await agent.dm(to, msg, { replyTo });
774
945
  const dup = ack.duplicate ? " duplicate publication." : "";
775
946
  return ok(`DM stored as seq ${ack.seq} for ${peer.card.name} (recipient was ${recipientStatusAtSend} at send; delivery not confirmed).${dup}`);
776
947
  }