@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.
package/dist/agent.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { execFile } from "node:child_process";
2
3
  import { EventEmitter } from "node:events";
3
4
  import { hostname } from "node:os";
4
5
  import { normalizeMentions, RUN_LAUNCH_DEADLINE_MS, subjectMatches, isConcreteChannel, assertValidChannel, channelInAllow, resolvePeer as resolvePeerInRoster, CotalEndpoint, BASELINE_LIFECYCLE_ENDPOINT, assertLifecycleToken, EpEnvelopeError, isPublishPermissionDenied, unansweredRequest, renderLifecycleBlocked, partsToText, } from "@cotal-ai/core";
6
+ import { attributionSafe } from "./framing.js";
5
7
  import { invokeUserManager } from "./manager-call.js";
6
8
  /** Client-side floor for a spawn action's submit + follow. The manager acceptance carries the exact
7
9
  * connector-selected readiness budget, and core extends the follow through that budget plus
@@ -80,7 +82,12 @@ const OVERFLOW_EVICTION_CAP = 4 * MAX_INBOX;
80
82
  const MAX_AHEAD = 256;
81
83
  const CLASSIFICATION_CAP = 4096;
82
84
  const FOCUS_EXCLUSION_CAP = 4096;
85
+ /** #662: history reads one channel's recall makes while an id-less copy keeps arriving mid-read. */
86
+ const IDLESS_READS = 3;
83
87
  const PROTECTED_DISPOSITION_CAP = 4096;
88
+ /** How many unanswered directed messages, and how many asked questions, one session remembers for
89
+ * reply correlation. */
90
+ const MAX_CORRELATIONS = 1024;
84
91
  /** Repeated async NATS status errors can arrive once per ordered-consumer retry. They describe one
85
92
  * fault, not one hundred useful facts; keep the first visible and summarize at most twice a minute. */
86
93
  const ENDPOINT_ERROR_LOG_WINDOW_MS = 30_000;
@@ -89,7 +96,21 @@ const ENDPOINT_ERROR_LOG_WINDOW_MS = 30_000;
89
96
  * poll is the intake's whole latency: a fresh turn waits at most one interval plus one wake.
90
97
  * Deadlines are minutes-scale; fifteen seconds of intake lag is invisible to a run. */
91
98
  const TURN_POLL_MS = 15_000;
92
- import { randomUUID } from "node:crypto";
99
+ /** Largest epoch-ms a `Date` can hold; past it `toISOString()` throws. */
100
+ const MAX_DATE_MS = 8.64e15;
101
+ /** A `turn-pending` row as the manager contract declares it, with a deadline a `Date` can format. */
102
+ function isPendingTurn(t) {
103
+ const r = t;
104
+ return typeof r === "object" && r !== null && typeof r.goalId === "string" && typeof r.payload === "string" &&
105
+ Number.isSafeInteger(r.acceptedAt) && r.acceptedAt >= 0 &&
106
+ Number.isSafeInteger(r.deadlineAt) && r.deadlineAt >= 1 && r.deadlineAt <= MAX_DATE_MS;
107
+ }
108
+ import { createHash, randomUUID } from "node:crypto";
109
+ /** #662: what an id-less channel delivery shares with its stream copy. Both are the same published
110
+ * bytes, read through the same authentication, so their content is all that links them. */
111
+ function idlessDigest(m) {
112
+ return createHash("sha256").update(JSON.stringify(m)).digest("base64url");
113
+ }
93
114
  function sleep(ms) {
94
115
  return new Promise((r) => setTimeout(r, ms));
95
116
  }
@@ -145,6 +166,22 @@ function renderEscalation(p) {
145
166
  return `\nThis turn is a checkpoint escalated to you at step ${String(p.step)}: ${String(c.prompt)}${by}${wanted}`
146
167
  + `\nAnswer with: cotal run answer ${String(p.run)} ${String(p.step)} --value '<json>'`;
147
168
  }
169
+ /**
170
+ * A thin, mesh-native agent: a {@link CotalEndpoint} plus a buffered inbox and
171
+ * name-based peer resolution. This is the shared core behind the MCP server
172
+ * (and, later, the lifecycle hooks) — it owns the NATS connection and presence.
173
+ *
174
+ * Connecting is resilient: {@link start} kicks off a background retry loop so the
175
+ * MCP server is responsive immediately even if the mesh isn't up yet.
176
+ *
177
+ * Emits `"incoming"` (InboxItem) when a message is buffered or an unacked durable copy
178
+ * redelivers, so a push layer can apply its normal delivery policy again; `"mention-wake"`
179
+ * (InboxItem) when a `focus`-mode agent is @-mentioned on a channel — the body was
180
+ * acked-and-dropped (not buffered), so this
181
+ * only asks the push layer to *wake* the agent to pull it; `"wake"` (no payload) to ask that
182
+ * layer to wake the session now (the Stop→idle flush of held messages); `"error"` (Error) for
183
+ * endpoint faults.
184
+ */
148
185
  export class MeshAgent extends EventEmitter {
149
186
  ep;
150
187
  config;
@@ -225,6 +262,15 @@ export class MeshAgent extends EventEmitter {
225
262
  pullTrouble;
226
263
  turnPollBusy = false;
227
264
  _contextId;
265
+ /** The per-call correlation a {@link withCorrelation} scope binds for the sends inside it. */
266
+ correlation = new AsyncLocalStorage();
267
+ /** By message id, oldest first: the live DMs and anycasts peers sent us that no DM back has
268
+ * answered yet (bounded). `pending` marks one a DM in flight is answering; `text` is the start of
269
+ * its body, to name it when a DM must say which one it answers. */
270
+ unanswered = new Map();
271
+ /** By `contextId`, the questions this seat asked inside a {@link withCorrelation} scope, and whom
272
+ * they went to (bounded). See {@link answersQuestion}. */
273
+ asked = new Map();
228
274
  /** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient
229
275
  * published after it ("since you entered focus"). Undefined unless in focus. */
230
276
  focusSince;
@@ -238,6 +284,22 @@ export class MeshAgent extends EventEmitter {
238
284
  recvKeySecret = randomUUID().replace(/-/g, "");
239
285
  recvKeySeq = 0;
240
286
  focusExcludedIds = new Map();
287
+ /** #662: id-less channel deliveries settled this focus episode, per content digest. Live ingest has
288
+ * no stream sequence, so content is all an id-less delivery shares with its stream copy, and
289
+ * identical copies may each have had a different disposition. So recall binds each settled copy
290
+ * to a stream sequence and hands back only the copies no settled copy is bound to. Replaced, never
291
+ * cleared, per episode, so a hold from an earlier episode cannot un-count a later one. */
292
+ focusIdless = new Map();
293
+ /** Unbound copies plus bound sequences in {@link focusIdless}, bounded like the exclusion list. */
294
+ focusIdlessEntries = 0;
295
+ /** Bumped as each channel's recall read starts: a copy settled before a read that the read could
296
+ * not bind is behind the focus frontier or out of retention, and no later read will see it. */
297
+ idlessEpoch = 0;
298
+ /** Recall runs one at a time, so an older read can never settle over a newer one (#662). */
299
+ recallLock = Promise.resolve();
300
+ /** Bumped each time the transport goes down, so a settled copy's frontier read can tell whether
301
+ * the connection stayed up from the copy's arrival to the answer (#662). */
302
+ transportDrops = 0;
241
303
  focusRecallUnsafeChannels = new Set();
242
304
  _stopping = false;
243
305
  /** Presence writes go out IN CALL ORDER (#636, #2055). setStatus is two awaited puts and every
@@ -307,6 +369,8 @@ export class MeshAgent extends EventEmitter {
307
369
  if (this._transportConnected === e.connected)
308
370
  return;
309
371
  this._transportConnected = e.connected;
372
+ if (!e.connected)
373
+ this.transportDrops++;
310
374
  this.emit("transport", e);
311
375
  });
312
376
  // The endpoint's (re)binds are the single source of truth for connectedness: this fires on
@@ -484,6 +548,83 @@ export class MeshAgent extends EventEmitter {
484
548
  const clean = contextId?.trim();
485
549
  this._contextId = clean ? clean : undefined;
486
550
  }
551
+ /** Run `fn` with a per-call correlation for the sends it makes. A `replyTo` names the message a
552
+ * DM answers unless the DM names one itself, and `contextId` is then that message's
553
+ * conversation, copied from the message when the scope omits it. Without a `replyTo`,
554
+ * `contextId` is the caller's own conversation: a send, a question DM or an anycast carries it
555
+ * ahead of the agent-wide one from {@link setContextId}, and {@link answersQuestion} recognizes
556
+ * a DM back that copies it. A connector that serves several host sessions from one seat uses it,
557
+ * because one agent-wide context id cannot tell two concurrent sessions apart. */
558
+ withCorrelation(correlation, fn) {
559
+ const contextId = correlation.contextId?.trim() || undefined;
560
+ const replyTo = correlation.replyTo?.trim() || undefined;
561
+ const peerId = correlation.peerId?.trim() || undefined;
562
+ return this.correlation.run({ contextId, replyTo, peerId }, fn);
563
+ }
564
+ /** Whether `item` is a live DM answering a question this seat asked inside a
565
+ * {@link withCorrelation} scope: it copies that question's `contextId`, and its authenticated
566
+ * sender is the peer the question went to, or has the role an anycast question went to. A
567
+ * `contextId` is a string any peer can set, so the copy alone proves nothing. */
568
+ answersQuestion(item) {
569
+ if (item.kind !== "dm" || item.historical || !item.contextId)
570
+ return false;
571
+ const q = this.asked.get(item.contextId);
572
+ return !!q && (q.peerId === item.fromId || (!!q.role && q.role === item.fromRole));
573
+ }
574
+ /** The correlation a send carries, and the question it asks (a `contextId` the caller's scope
575
+ * owns, which {@link recordQuestion} remembers). `answered` is the message a DM answers: a reply
576
+ * copies its `contextId`, which belongs to the asker, and names it in `replyTo` (SPEC §5). */
577
+ stamp(answered) {
578
+ const c = this.correlation.getStore();
579
+ // A DM's own `replyTo` naming another message wins over the scope's, as in answering().
580
+ if (c?.replyTo && (!answered || answered.id === c.replyTo))
581
+ return { stamp: { replyTo: c.replyTo, contextId: c.contextId ?? answered?.contextId ?? this._contextId } };
582
+ if (answered?.contextId)
583
+ return { stamp: { replyTo: answered.id, contextId: answered.contextId } };
584
+ // A scope's `contextId` is the caller's own only when the scope names no message it answers.
585
+ const own = c?.replyTo ? undefined : c?.contextId;
586
+ return { stamp: { replyTo: answered?.id, contextId: own ?? this._contextId }, own };
587
+ }
588
+ /** Remember a question this seat asked, so {@link answersQuestion} can recognize its answer. */
589
+ recordQuestion(contextId, to) {
590
+ if (!contextId)
591
+ return;
592
+ this.asked.delete(contextId);
593
+ this.asked.set(contextId, to);
594
+ if (this.asked.size > MAX_CORRELATIONS)
595
+ this.asked.delete(this.asked.keys().next().value);
596
+ }
597
+ /** The unanswered message a DM to `peerId` answers. `replyTo` names it, and must be a message
598
+ * from that peer that is still waiting for an answer. Otherwise the scope's `replyTo` names it if
599
+ * it is that peer's, else it is the oldest one from that peer no other DM in flight is answering.
600
+ * The oldest is taken only while all of that peer's waiting messages, including those a DM in
601
+ * flight is answering, share one conversation (`contextId`): across several, a guess could put
602
+ * the answer in the wrong conversation, so the DM is refused with the list to choose from. A
603
+ * caller with its own `contextId` answers only in the conversation its scope names. */
604
+ answering(peerId, replyTo) {
605
+ if (replyTo) {
606
+ const e = this.unanswered.get(replyTo);
607
+ if (!e || e.fromId !== peerId)
608
+ throw new Error(`replyTo "${attributionSafe(replyTo)}" is not a message from this peer that is waiting for an answer`);
609
+ return { id: replyTo, contextId: e.contextId };
610
+ }
611
+ const c = this.correlation.getStore();
612
+ if (c?.replyTo) {
613
+ const e = this.unanswered.get(c.replyTo);
614
+ return e && e.fromId === peerId ? { id: c.replyTo, contextId: e.contextId } : undefined;
615
+ }
616
+ if (c?.contextId && c.peerId !== peerId)
617
+ return undefined;
618
+ // One a DM in flight is answering still waits until that DM is published, so it still counts.
619
+ const waiting = [...this.unanswered].filter(([, e]) => e.fromId === peerId);
620
+ if (new Set(waiting.map(([, e]) => e.contextId)).size > 1) {
621
+ const list = waiting.map(([id, e]) => ` • replyTo: ${attributionSafe(id)} — "${attributionSafe(e.text ?? "")}"`);
622
+ throw new Error(`this peer has messages from more than one conversation waiting for an answer. Re-send with ` +
623
+ `"replyTo" set to the id of the one this answers:\n${list.join("\n")}`);
624
+ }
625
+ const first = waiting.find(([, e]) => !e.pending);
626
+ return first && { id: first[0], contextId: first[1].contextId };
627
+ }
487
628
  /** Begin connecting with background retry. Resolves after the first completed mesh join. */
488
629
  start(retryMs = 3000) {
489
630
  return this.connectLoop(retryMs);
@@ -637,6 +778,12 @@ export class MeshAgent extends EventEmitter {
637
778
  if (!meta)
638
779
  throw new Error(`message ${m.id} delivered without MessageMeta — its class is unauthenticated`);
639
780
  const item = this.toInboxItem(m, meta.kind, meta.historical);
781
+ if ((item.kind === "dm" || item.kind === "anycast") && !item.historical && item.id !== "" && !this.answersQuestion(item)) {
782
+ // A DM back to this peer answers it (see dm()). An answer to our own question needs none.
783
+ this.unanswered.set(item.id, { fromId: item.fromId, contextId: item.contextId, text: item.text.slice(0, 80) });
784
+ if (this.unanswered.size > MAX_CORRELATIONS)
785
+ this.unanswered.delete(this.unanswered.keys().next().value);
786
+ }
640
787
  // Per-channel override is the FINAL word for a channel message (DMs/anycast are never channel-
641
788
  // scoped, so they bypass this entirely and always buffer). Evaluated BEFORE the global mode:
642
789
  // - `muted` → hard drop, incl. @mention (a mention rides the channel; you can't keep it if you
@@ -650,6 +797,14 @@ export class MeshAgent extends EventEmitter {
650
797
  // tag is payload-forgeable).
651
798
  if (item.kind === "channel") {
652
799
  const cm = this.channelModes.get(item.channel ?? "");
800
+ // #662: an id-less delivery is counted, not excluded, since recall can only match it by content.
801
+ // Content cannot tell a late copy of one recall handed back from a new identical message, so
802
+ // every copy takes its own disposition, and a late copy surfaces again rather than a new one
803
+ // losing its lane (#624: an empty id asserts no identity).
804
+ const digest = item.id === "" && (this.enteringFocus || this._attention === "focus") ? idlessDigest(m) : undefined;
805
+ const tally = digest === undefined ? undefined : this.reserveIdless(digest, item.channel ?? "");
806
+ const copy = tally ? this.settleIdless(tally) : undefined;
807
+ const idless = copy && { digest: digest, copy };
653
808
  // chatFrontier() is asynchronous. Channel traffic retained while entering focus must not also
654
809
  // appear in post-watermark recall if it lands after the server captured the frontier.
655
810
  if (this.enteringFocus)
@@ -677,6 +832,10 @@ export class MeshAgent extends EventEmitter {
677
832
  if (cm !== "quiet" && !snapshottedPullOnly && this._attention === "focus") {
678
833
  this.protectDisposition(item.id, "drop");
679
834
  delivery.ack();
835
+ // #662: recall cannot pick this copy out from an identical one with another disposition, so
836
+ // its body is held here, pull-only, and recall hands back only copies nothing is bound to.
837
+ if (item.id === "")
838
+ this.buffer(item, () => { }, true, idless);
680
839
  if (item.mentionsMe)
681
840
  this.emit("mention-wake", item);
682
841
  return;
@@ -686,16 +845,24 @@ export class MeshAgent extends EventEmitter {
686
845
  // on a real mesh as 119 injected digests / 0 assistant turns and an emergency compaction before
687
846
  // the seat's first real order could run. It stays recallable (cotal_inbox, recall), and a
688
847
  // historical @mention stays automatic: directed catch-up is the reader's call, not noise.
689
- const pullOnly = snapshottedPullOnly || (!item.mentionsMe && (item.historical || this.classificationUnsafe));
848
+ // A reply on a channel is pull-only too when the host answers every turn on the channel
849
+ // (config.channelRepliesPullOnly): that answer replies to the message that started the turn,
850
+ // so a turn on a peer's reply is a turn on its automatic output, and two such seats on one
851
+ // channel would answer each other without end (#2395).
852
+ const automaticReply = this.config.channelRepliesPullOnly === true && !!item.replyTo;
853
+ const pullOnly = snapshottedPullOnly || (!item.mentionsMe && (item.historical || automaticReply || this.classificationUnsafe));
690
854
  if (pullOnly)
691
855
  this.excludeFromFocus(item);
692
- this.buffer(item, delivery.ack, pullOnly);
856
+ this.buffer(item, delivery.ack, pullOnly, idless);
693
857
  return;
694
858
  }
695
859
  this.buffer(item, delivery.ack, false);
696
860
  }
697
- buffer(item, ack, pullOnly) {
698
- this.inbox.push({ item, ack, pullOnly, receivedAt: Date.now() });
861
+ buffer(item, ack, pullOnly, idless) {
862
+ const pending = { item, ack, pullOnly, receivedAt: Date.now() };
863
+ if (idless)
864
+ pending.idless = { tally: this.focusIdless, ...idless };
865
+ this.inbox.push(pending);
699
866
  if (this.inbox.length > MAX_INBOX) {
700
867
  // Prefer sacrificing pull-only backlog so it cannot crowd out DMs/mentions. Overflow remains
701
868
  // bounded local loss: evicted items are acked without being marked handled.
@@ -722,6 +889,9 @@ export class MeshAgent extends EventEmitter {
722
889
  const [evicted] = this.inbox.splice(index, 1);
723
890
  const sacrificingDirected = evicted.item.kind !== "channel";
724
891
  this.rememberEvicted(evicted);
892
+ // #662: a held id-less message is no longer settled here, so recall may hand it back.
893
+ if (evicted.idless?.tally === this.focusIdless)
894
+ this.unsettleIdless(evicted.idless.digest, evicted.idless.copy);
725
895
  // ...but NOT an id that is mid-delivery. Overflow prefers the oldest, which is exactly what a
726
896
  // surfaced batch is made of, so without this an arrival can ack a message a host is still
727
897
  // trying to hand to its runtime. Evicting bounds memory; acking is what makes it
@@ -791,6 +961,8 @@ export class MeshAgent extends EventEmitter {
791
961
  excludeFromFocus(item) {
792
962
  if ((!this.enteringFocus && this._attention !== "focus") || item.kind !== "channel" || !item.channel)
793
963
  return;
964
+ if (item.id === "")
965
+ return; // #662: an id-less delivery is counted in focusIdless instead
794
966
  if (!this.focusExcludedIds.has(item.id) && this.focusExcludedIds.size >= FOCUS_EXCLUSION_CAP) {
795
967
  const oldest = this.focusExcludedIds.entries().next().value;
796
968
  if (oldest) {
@@ -800,6 +972,125 @@ export class MeshAgent extends EventEmitter {
800
972
  }
801
973
  this.focusExcludedIds.set(item.id, item.channel);
802
974
  }
975
+ /** #662: the tally for one more settled or handed-back copy of an id-less message; the caller adds
976
+ * the copy. At the bound, the channel's recall is reported incomplete rather than risk handing
977
+ * back a copy it no longer counts. */
978
+ reserveIdless(digest, channel) {
979
+ if (this.focusIdlessEntries >= FOCUS_EXCLUSION_CAP) {
980
+ this.focusRecallUnsafeChannels.add(channel);
981
+ return undefined;
982
+ }
983
+ let tally = this.focusIdless.get(digest);
984
+ if (!tally)
985
+ this.focusIdless.set(digest, (tally = { channel, unbound: [], bound: new Set() }));
986
+ this.focusIdlessEntries++;
987
+ return tally;
988
+ }
989
+ /** #662: add one settled copy, bounded by the chat frontier read after it arrived. If that read
990
+ * fails, or answers only after the transport dropped, nothing bounds the copy, so the channel's
991
+ * recall is reported incomplete instead. */
992
+ settleIdless(tally) {
993
+ const episode = this.focusIdless;
994
+ const drops = this.transportDrops;
995
+ const unbounded = () => {
996
+ if (episode === this.focusIdless)
997
+ this.focusRecallUnsafeChannels.add(tally.channel);
998
+ };
999
+ const copy = { epoch: this.idlessEpoch, ready: Promise.resolve() };
1000
+ copy.ready = this.ep.chatFrontier().then((frontier) => {
1001
+ if (drops === this.transportDrops)
1002
+ copy.frontier = frontier;
1003
+ else
1004
+ unbounded();
1005
+ }, unbounded);
1006
+ tally.unbound.push(copy);
1007
+ return copy;
1008
+ }
1009
+ /** #662: forget a tally with nothing left in it, so the map is bounded by the entries it counts. */
1010
+ dropEmptyIdless(digest, tally) {
1011
+ if (!tally.unbound.length && !tally.bound.size)
1012
+ this.focusIdless.delete(digest);
1013
+ }
1014
+ /** #662: one buffered copy of an id-less message left the inbox unhandled. Only that copy is
1015
+ * un-counted, or the sequence it was bound to freed: an identical copy may have been muted. A copy
1016
+ * no read has bound yet stays in arrival order, marked, so the read binds it to its own stream
1017
+ * copy rather than letting an earlier identical copy take that one. */
1018
+ unsettleIdless(digest, copy) {
1019
+ const tally = this.focusIdless.get(digest);
1020
+ if (!tally)
1021
+ return;
1022
+ if (tally.unbound.includes(copy)) {
1023
+ copy.evicted = true;
1024
+ return;
1025
+ }
1026
+ if (copy.seq === undefined || !tally.bound.delete(copy.seq))
1027
+ return;
1028
+ this.focusIdlessEntries--;
1029
+ this.dropEmptyIdless(digest, tally);
1030
+ }
1031
+ /** #662: after a complete read of `channel` that started at `epoch`, a copy settled before it that
1032
+ * it could not bind will not be in a later read either, so it is dropped. The read is the
1033
+ * channel's whole retained window, so a bound sequence it lacks aged out. */
1034
+ settleIdlessRead(channel, epoch, retained) {
1035
+ for (const [digest, tally] of this.focusIdless) {
1036
+ if (tally.channel !== channel)
1037
+ continue;
1038
+ const before = tally.unbound.length + tally.bound.size;
1039
+ tally.unbound = tally.unbound.filter((c) => c.epoch >= epoch);
1040
+ for (const seq of tally.bound)
1041
+ if (!retained.has(seq))
1042
+ tally.bound.delete(seq);
1043
+ this.focusIdlessEntries -= before - tally.unbound.length - tally.bound.size;
1044
+ this.dropEmptyIdless(digest, tally);
1045
+ }
1046
+ }
1047
+ /** #662: the frontier reads of every copy of `channel` settled so far have answered. */
1048
+ async idlessReady(tallies, channel) {
1049
+ const reads = [];
1050
+ for (const tally of tallies.values())
1051
+ if (tally.channel === channel)
1052
+ for (const c of tally.unbound)
1053
+ reads.push(c.ready);
1054
+ await Promise.all(reads);
1055
+ }
1056
+ /** #662: bind the copies settled before the read that started at `epoch` to its unbound stream
1057
+ * copies `free` (ascending). Identical copies share a sender subject, so they arrive in stream
1058
+ * order, and a frontier, read after its copy arrived, bounds that copy and every copy before it.
1059
+ * Frontier reads can answer out of order, so their sizes say nothing about arrival order. So the
1060
+ * copies bind in arrival order, latest first, each to the latest free stream copy at or below the
1061
+ * lowest frontier from it on, and a stream copy none of them takes (a reconnect gap) stays free.
1062
+ * An evicted copy takes its own stream copy and leaves it free, so recall hands that one back. */
1063
+ bindIdless(tally, epoch, free) {
1064
+ let bound = Infinity;
1065
+ for (let i = tally.unbound.length - 1; i >= 0; i--) {
1066
+ const copy = tally.unbound[i];
1067
+ if (copy.frontier === undefined)
1068
+ continue;
1069
+ bound = Math.min(bound, copy.frontier);
1070
+ if (copy.epoch >= epoch)
1071
+ continue;
1072
+ let at = free.length - 1;
1073
+ while (at >= 0 && free[at] > bound)
1074
+ at--;
1075
+ if (at < 0)
1076
+ continue;
1077
+ const [seq] = free.splice(at, 1);
1078
+ tally.unbound.splice(i, 1);
1079
+ if (copy.evicted) {
1080
+ this.focusIdlessEntries--;
1081
+ continue;
1082
+ }
1083
+ copy.seq = seq;
1084
+ tally.bound.add(seq);
1085
+ }
1086
+ }
1087
+ /** #662: a copy settled after the read that started at `epoch` began may or may not be in it, so it
1088
+ * binds nothing there, and an unbound stream copy at `seq` it could be is neither free nor bound. */
1089
+ idlessUnsure(tally, epoch, seq) {
1090
+ if (!tally || tally.bound.has(seq))
1091
+ return false;
1092
+ return tally.unbound.some((c) => c.epoch >= epoch && c.frontier !== undefined && c.frontier >= seq);
1093
+ }
803
1094
  protectDisposition(id, disposition) {
804
1095
  if (id === "")
805
1096
  return; // #624: an empty id is never a dedup key, so it is never recorded as one
@@ -1187,6 +1478,8 @@ export class MeshAgent extends EventEmitter {
1187
1478
  if (mode === "focus") {
1188
1479
  await this.requireConnected();
1189
1480
  this.focusExcludedIds.clear();
1481
+ this.focusIdless = new Map();
1482
+ this.focusIdlessEntries = 0;
1190
1483
  this.focusRecallUnsafeChannels.clear();
1191
1484
  this.enteringFocus = this._attention !== "focus";
1192
1485
  try {
@@ -1195,6 +1488,8 @@ export class MeshAgent extends EventEmitter {
1195
1488
  catch (error) {
1196
1489
  this.enteringFocus = false;
1197
1490
  this.focusExcludedIds.clear();
1491
+ this.focusIdless = new Map();
1492
+ this.focusIdlessEntries = 0;
1198
1493
  this.focusRecallUnsafeChannels.clear();
1199
1494
  throw error;
1200
1495
  }
@@ -1205,6 +1500,8 @@ export class MeshAgent extends EventEmitter {
1205
1500
  this.enteringFocus = false;
1206
1501
  this.focusSince = undefined;
1207
1502
  this.focusExcludedIds.clear();
1503
+ this.focusIdless = new Map();
1504
+ this.focusIdlessEntries = 0;
1208
1505
  this.focusRecallUnsafeChannels.clear();
1209
1506
  this.resetRecallWalk();
1210
1507
  }
@@ -1223,8 +1520,15 @@ export class MeshAgent extends EventEmitter {
1223
1520
  * the per-channel window — and wildcard subscriptions (`team.>`), which recall cannot read back
1224
1521
  * per concrete sub-channel (#977: a wildcard join is not itself a channel ingest can consult a
1225
1522
  * replay policy for) and so cannot vouch for either (never-silent throughout). Empty unless in
1226
- * focus. */
1227
- async recallAmbient() {
1523
+ * focus. Calls run one at a time (#662). `underway` names recalled items that already went out in
1524
+ * part: an exclusion that lands after that does not hide one, since recall would then move past
1525
+ * the rest of it (#613). */
1526
+ async recallAmbient(underway = new Set()) {
1527
+ const run = this.recallLock.then(() => this.recallAmbientOnce(underway));
1528
+ this.recallLock = run.catch(() => { });
1529
+ return run;
1530
+ }
1531
+ async recallAmbientOnce(underway) {
1228
1532
  if (this._attention !== "focus" || this.focusSince === undefined)
1229
1533
  return { items: [], droppedChannels: [] };
1230
1534
  const items = [];
@@ -1238,12 +1542,91 @@ export class MeshAgent extends EventEmitter {
1238
1542
  droppedChannels.push(channel);
1239
1543
  continue;
1240
1544
  }
1241
- const { messages, dropped } = await this.ep.recallChannel(channel, this.focusSince);
1242
- for (const m of messages) {
1243
- if (!this.focusExcludedIds.has(m.id))
1244
- items.push(this.toInboxItem(m, "channel", true));
1545
+ const tallies = this.focusIdless;
1546
+ let epoch = 0;
1547
+ let read;
1548
+ // #662: a copy settled while a read runs may or may not be in it, so the read is retried with
1549
+ // that copy settled first, a bounded number of times.
1550
+ for (let attempt = 1; attempt <= IDLESS_READS; attempt++) {
1551
+ epoch = ++this.idlessEpoch;
1552
+ // The read starts after every copy settled before it knows its frontier, so a copy it
1553
+ // cannot bind is behind the focus start or out of retention.
1554
+ await this.idlessReady(tallies, channel);
1555
+ read = await this.ep.recallChannel(channel, this.focusSince);
1556
+ if (read.unanswered)
1557
+ break;
1558
+ await this.idlessReady(tallies, channel); // copies settled during the read
1559
+ const unsure = read.messages.some((m, i) => m.id === "" && this.idlessUnsure(tallies.get(idlessDigest(m)), epoch, read.seqs[i]));
1560
+ if (!unsure)
1561
+ break;
1562
+ }
1563
+ const { messages, seqs, dropped, unanswered } = read;
1564
+ // A read that did not answer says nothing about what the channel retains, so nothing settles.
1565
+ if (unanswered) {
1566
+ droppedChannels.push(channel);
1567
+ continue;
1245
1568
  }
1246
- if (dropped)
1569
+ if (this.focusRecallUnsafeChannels.has(channel)) {
1570
+ droppedChannels.push(channel);
1571
+ continue;
1572
+ }
1573
+ // #662: each copy settled before the read is bound to one retained stream copy at or below its
1574
+ // frontier. A copy no settled copy is bound to was never received (a reconnect gap) or was
1575
+ // evicted; it is handed back into the inbox, which acks each by receive key, since the recall
1576
+ // mark cannot order identical copies.
1577
+ const retained = new Set();
1578
+ const digests = [];
1579
+ const free = new Map();
1580
+ for (const [i, m] of messages.entries()) {
1581
+ if (m.id !== "" || tallies !== this.focusIdless)
1582
+ continue;
1583
+ retained.add(seqs[i]);
1584
+ const digest = (digests[i] = idlessDigest(m));
1585
+ if (tallies.get(digest)?.bound.has(seqs[i]))
1586
+ continue;
1587
+ const list = free.get(digest);
1588
+ if (list)
1589
+ list.push(seqs[i]);
1590
+ else
1591
+ free.set(digest, [seqs[i]]);
1592
+ }
1593
+ for (const [digest, list] of free) {
1594
+ const tally = tallies.get(digest);
1595
+ if (tally)
1596
+ this.bindIdless(tally, epoch, list);
1597
+ }
1598
+ let incomplete = dropped;
1599
+ for (const [i, m] of messages.entries()) {
1600
+ if (m.id !== "") {
1601
+ if (underway.has(m.id) || !this.focusExcludedIds.has(m.id))
1602
+ items.push(this.toInboxItem(m, "channel", true));
1603
+ continue;
1604
+ }
1605
+ if (tallies !== this.focusIdless)
1606
+ continue; // focus was left or re-entered during the read
1607
+ const seq = seqs[i];
1608
+ const digest = digests[i];
1609
+ const tally = tallies.get(digest);
1610
+ if (tally?.bound.has(seq))
1611
+ continue;
1612
+ // Still unsure after the last read: neither claimed nor handed back, and reported.
1613
+ if (this.idlessUnsure(tally, epoch, seq)) {
1614
+ incomplete = true;
1615
+ continue;
1616
+ }
1617
+ // A full inbox leaves it in the stream for a later call, rather than evict something else.
1618
+ const fresh = this.inbox.length < MAX_INBOX ? this.reserveIdless(digest, channel) : undefined;
1619
+ if (!fresh) {
1620
+ incomplete = true;
1621
+ continue;
1622
+ }
1623
+ const copy = { epoch, frontier: seq, ready: Promise.resolve(), seq };
1624
+ fresh.bound.add(seq);
1625
+ this.buffer(this.toInboxItem(m, "channel", true), () => { }, true, { digest, copy });
1626
+ }
1627
+ if (tallies === this.focusIdless)
1628
+ this.settleIdlessRead(channel, epoch, retained);
1629
+ if (incomplete)
1247
1630
  droppedChannels.push(channel);
1248
1631
  }
1249
1632
  items.sort((a, b) => a.ts - b.ts);
@@ -1255,7 +1638,7 @@ export class MeshAgent extends EventEmitter {
1255
1638
  const clean = normalizeMentions(mentions);
1256
1639
  if (clean)
1257
1640
  await this.assertKnownMentions(clean);
1258
- return this.ep.multicast(text, { channel, mentions: clean, contextId: this._contextId });
1641
+ return this.ep.multicast(text, { channel, mentions: clean, ...this.stamp().stamp });
1259
1642
  }
1260
1643
  /**
1261
1644
  * What a caller can TELL about a send target BEFORE the publish: whether the name
@@ -1315,7 +1698,9 @@ export class MeshAgent extends EventEmitter {
1315
1698
  }
1316
1699
  async anycast(role, text) {
1317
1700
  await this.requireConnected();
1318
- return this.ep.anycast(role, text, { contextId: this._contextId });
1701
+ const { stamp, own } = this.stamp();
1702
+ this.recordQuestion(own, { role });
1703
+ return this.ep.anycast(role, text, stamp);
1319
1704
  }
1320
1705
  /** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns
1321
1706
  * one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision —
@@ -1323,7 +1708,7 @@ export class MeshAgent extends EventEmitter {
1323
1708
  resolvePeer(target) {
1324
1709
  return resolvePeerInRoster(this.ep.getRoster(), target, { selfId: this.id });
1325
1710
  }
1326
- async dm(target, text) {
1711
+ async dm(target, text, opts = {}) {
1327
1712
  await this.requireConnected();
1328
1713
  // #1229: a miss is only a real "no peer" while the view is current. Under `unpopulated`
1329
1714
  // the roster may be a reconnect refill in progress, so wait once for the snapshot and
@@ -1347,10 +1732,25 @@ export class MeshAgent extends EventEmitter {
1347
1732
  // The only status we can truthfully attribute is the roster snapshot taken right before the
1348
1733
  // publish: recipient state can change the instant after, and the ack never tells us either way.
1349
1734
  const recipientStatusAtSend = peer.status;
1350
- const { msg, ack } = await this.ep.unicastAttributed(peer.card.id, text, {
1351
- contextId: this._contextId,
1352
- });
1353
- return { msg, peer, ack, recipientStatusAtSend };
1735
+ // A DM back to a peer answers the message `replyTo` names, or the oldest one it sent us that no
1736
+ // DM has answered yet (see answering()). It counts as answered only once the DM is published,
1737
+ // so a failed send leaves it to the retry.
1738
+ const answered = this.answering(peer.card.id, opts.replyTo?.trim() || undefined);
1739
+ const entry = answered && this.unanswered.get(answered.id);
1740
+ const { stamp, own } = this.stamp(answered);
1741
+ this.recordQuestion(own, { peerId: peer.card.id });
1742
+ if (entry)
1743
+ entry.pending = true;
1744
+ try {
1745
+ const { msg, ack } = await this.ep.unicastAttributed(peer.card.id, text, stamp);
1746
+ if (answered)
1747
+ this.unanswered.delete(answered.id);
1748
+ return { msg, peer, ack, recipientStatusAtSend };
1749
+ }
1750
+ finally {
1751
+ if (entry)
1752
+ entry.pending = false;
1753
+ }
1354
1754
  }
1355
1755
  // ---- supervision ---------------------------------------------------------
1356
1756
  /** Ask the manager to spawn a new teammate into this space (its `spawn` action).
@@ -1590,8 +1990,20 @@ export class MeshAgent extends EventEmitter {
1590
1990
  this.notePullTrouble(r.error ?? "refused with no message");
1591
1991
  return;
1592
1992
  }
1593
- this.pullTrouble = undefined;
1594
- const turns = r.data?.turns ?? [];
1993
+ const rows = r.data?.turns;
1994
+ // A reply with no turns array is no snapshot: reconciling on it would drop every accepted
1995
+ // turn, including one already shown. Keep what the seat holds until a well-formed pull.
1996
+ if (!Array.isArray(rows)) {
1997
+ this.notePullTrouble("turn-pending returned no turns array");
1998
+ return;
1999
+ }
2000
+ // A responder off the contract must not reach the formatter: one row with no numeric
2001
+ // deadline made peekPendingTurns throw on every frame.
2002
+ const turns = rows.filter(isPendingTurn);
2003
+ if (turns.length < rows.length)
2004
+ this.notePullTrouble(`turn-pending returned ${rows.length - turns.length} malformed turn(s), dropped`);
2005
+ else
2006
+ this.pullTrouble = undefined;
1595
2007
  const live = new Set(turns.map((t) => t.goalId));
1596
2008
  for (const id of [...this.activeTurns.keys()])
1597
2009
  if (!live.has(id))
@@ -1903,6 +2315,11 @@ export class MeshAgent extends EventEmitter {
1903
2315
  async setCondition(condition) {
1904
2316
  await this.inOrder(() => this.ep.setCondition(condition));
1905
2317
  }
2318
+ /** Relay harness-reported work progress (a turn event) as presence `activeAt`. The next heartbeat
2319
+ * carries it, so an observer can tell a turn that stopped advancing from one that is progressing. */
2320
+ noteActivity(at) {
2321
+ this.ep.noteActivity(at);
2322
+ }
1906
2323
  /** The working→idle boundary: yield `done` for every SURFACED turn (its payload was in the
1907
2324
  * context of the turn that just ended; ending without an explicit yield IS the done signal),
1908
2325
  * then re-poll immediately so a queued turn wakes the seat without waiting out the cadence.