@cohortapp/agent-sdk 2.18.14 → 2.18.16

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.
@@ -35,6 +35,7 @@ import { recordOutbound } from "../../lib/comms/receipts.mjs";
35
35
  // inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
36
36
  // new surface cannot be added upstream without this file's switch noticing.
37
37
  import { ROOM_SURFACES, SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
38
+ import { isRetiredFirstReply } from "../../lib/assurance/first-reply.mjs";
38
39
 
39
40
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
40
41
 
@@ -741,11 +742,117 @@ export function scaffoldMarkerLeak(text) {
741
742
  * escalate rather than lose the message to an exception. `permanent`
742
743
  * means retrying cannot help (no route, or a NOT_FOUND/BAD_REQUEST).
743
744
  */
745
+ /**
746
+ * Can this inbound be answered with a REACTION rather than a sentence?
747
+ *
748
+ * Only a Cohort message in a real room can: a reaction is attached to a message
749
+ * id in a channel, and that is the one surface where both exist. A board
750
+ * comment, a doc comment, an email and a Slack item all answer `false` here
751
+ * even where the underlying product has reactions, because this daemon has no
752
+ * route to them — and a shape decision that promises a reaction the transport
753
+ * cannot make would turn "acknowledge cheaply" into "say nothing at all".
754
+ *
755
+ * @param {object} item
756
+ * @returns {boolean}
757
+ */
758
+ export function canReactTo(item) {
759
+ if (!item || item.service !== "cohort") return false;
760
+ const route = cohortReplyRoute(item);
761
+ if (route.transport !== "channel") return false;
762
+ return Boolean(str(item.message_id));
763
+ }
764
+
765
+ /**
766
+ * Put a reaction on the message that arrived.
767
+ *
768
+ * The cheapest honest acknowledgement there is: it tells the sender their
769
+ * message reached a person, and it adds no row to the channel. That second
770
+ * property is the whole argument for it — the measured flood
771
+ * (`lib/assurance/tier.mjs`) was 3,069 acknowledgement ROWS, and a reaction is
772
+ * an acknowledgement that is not a row.
773
+ *
774
+ * Fail-open and quiet: a reaction that cannot be made is not an error worth
775
+ * escalating, it is a reason to write the line instead, and the caller treats
776
+ * `{sent:false}` exactly that way.
777
+ *
778
+ * @param {object} item
779
+ * @param {string} emoji
780
+ * @param {object} [o] {cfg, agentRoot, reactImpl, fetchImpl} test seams
781
+ * @returns {Promise<{sent:boolean, via:string|null, error?:string}>}
782
+ */
783
+ export async function deliverReaction(item, emoji, o = {}) {
784
+ if (!canReactTo(item)) return { sent: false, via: null, error: "no reaction surface" };
785
+ const e = str(emoji);
786
+ if (!e) return { sent: false, via: null, error: "no emoji" };
787
+ try {
788
+ const route = cohortReplyRoute(item);
789
+ const agentRoot = o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
790
+ const cfg = o.cfg || (await import("../../lib/org/client.mjs")).loadOrgConfig(agentRoot);
791
+ const reactImpl = o.reactImpl || (await import("../../lib/org/messaging.mjs")).reactMessage;
792
+ const frame = await reactImpl(
793
+ { channel: route.channelId, messageId: str(item.message_id), emoji: e },
794
+ { cfg, agentRoot, idempotencyKey: `react-${str(item.message_id)}-${e}`, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) },
795
+ );
796
+ if (frame && frame.ok) {
797
+ recordOutbound({ service: "cohort", channel: route.channelId, kind: "reaction", via: "cohort", chars: 0, agentRoot });
798
+ return { sent: true, via: "cohort" };
799
+ }
800
+ return { sent: false, via: null, error: str(frame && frame.error && frame.error.message) || "react failed" };
801
+ } catch (err) {
802
+ return { sent: false, via: null, error: (err && err.message) || String(err) };
803
+ }
804
+ }
805
+
744
806
  export async function deliver(item, text, o = {}) {
745
807
  const kind = o.kind || "reply";
746
808
  if (!item || !text || !String(text).trim()) {
747
809
  return { sent: false, via: null, error: "nothing to deliver" };
748
810
  }
811
+ // ── THE REGISTER, AT THE CHOKEPOINT ──────────────────────────────────────
812
+ // Every outbound this daemon makes crosses this function — the answer, the
813
+ // acknowledgement and the assurance sweep alike — which is why the scaffold
814
+ // check below lives here rather than at three call sites. The retired
815
+ // placeholder replies get the same treatment, and for a reason the composer
816
+ // guards cannot cover:
817
+ //
818
+ // On 2026-09-25 two seats (Ravi Patel, Daniel Connors) were still emitting
819
+ // "On it." / "Looking now." / "On it — digging in now." — 251 of them in six
820
+ // days, the last at 11:28Z that morning — with `clientMsgId` ending `-ack`,
821
+ // i.e. through this very path, while `AgentStatus.machine.daemon` reported
822
+ // sdkVersion 2.18.13 and `versionsAgree: true`. The published package at that
823
+ // version contains none of those strings and `sanitiseAckText` returns null
824
+ // for all three. The explanation is that the daemon runs
825
+ // `$AGENT_ROOT/scripts/daemon/maestro-daemon.mjs` — the seat's PRESERVED
826
+ // LOCAL COPY — and `versionsAgree` compares two npm version numbers, which
827
+ // says nothing whatever about that tree.
828
+ //
829
+ // WHAT THIS GUARD DOES AND DOES NOT REACH — stated exactly, because the first
830
+ // version of this comment overclaimed it and a reader would have taken the
831
+ // stale-seat vector for closed.
832
+ //
833
+ // IT DOES protect every outbound of an UPDATED daemon, at the last point
834
+ // before bytes leave the machine, whatever composed the text:
835
+ // the answer, the acknowledgement, the assurance sweep, and any
836
+ // composer added later that nobody remembers to guard. That is
837
+ // the case for putting it here rather than at three call sites.
838
+ // IT DOES NOT reach the two seats that were actually emitting these
839
+ // sentences. This file is `scripts/daemon/deliver.mjs`, and it
840
+ // lives in the very tree those seats have preserved a stale copy
841
+ // of — a stale tree ships a stale deliver.mjs alongside its
842
+ // stale composer. THE REMEDY FOR THOSE SEATS IS THE ROLLOUT
843
+ // (publish, then `scripts/local-triggers/autoupdate.sh` on each
844
+ // box, verified by a server-recorded presence.beat rather than
845
+ // by `versionsAgree`). Nothing in this file can shorten that.
846
+ //
847
+ // The other plane is covered separately: `lib/comms/send-gate.screenOutbound`
848
+ // and `screenHookPayload` apply the same register to `messaging_send`,
849
+ // `email_send`, `email_draft_send` and `org_call_share_step`, which is how a
850
+ // SPAWNED SESSION typing "On it." itself gets refused — that path never
851
+ // crosses this function.
852
+ if (isRetiredFirstReply(text)) {
853
+ try { console.error(`[deliver] refused: retired placeholder reply in outbound body ${JSON.stringify(String(text).slice(0, 120))} — this is a stale composer, not a message (permanent, not retrying)`); } catch { /* */ }
854
+ return { sent: false, via: null, permanent: true, channel: item && item.channel_id, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: retired placeholder reply — the first line must be generated from the actual thread, or nothing" };
855
+ }
749
856
  const leak = scaffoldMarkerLeak(text);
750
857
  if (leak) {
751
858
  const surface = (item && item.channel_id) ? "room" : "unknown";
@@ -827,6 +934,8 @@ export async function deliverWithRetry(item, text, o = {}) {
827
934
  export default {
828
935
  deliver,
829
936
  deliverWithRetry,
937
+ canReactTo,
938
+ deliverReaction,
830
939
  resolveSlackChannel,
831
940
  replyTargetOf,
832
941
  canDeliverTo,
@@ -6,7 +6,7 @@ import { spawn } from "child_process";
6
6
  import { appendFileSync, mkdirSync, writeFileSync, readFileSync, renameSync, existsSync, readdirSync, unlinkSync } from "fs";
7
7
  import { randomUUID } from "crypto";
8
8
  import { join, dirname } from "path";
9
- import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
9
+ import { releaseLock, releaseThreadLock, threadLockKey, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
10
10
  import { promoteDeferred } from "./inbox-deferral.mjs";
11
11
  import { recordSession } from "./health.mjs";
12
12
  import { startTyping, stopTyping } from "./typing-registry.mjs";
@@ -2020,7 +2020,16 @@ function spawnSession(entry) {
2020
2020
  {
2021
2021
  const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
2022
2022
  if (channel) {
2023
- releaseThreadLock(channel, item.thread_id);
2023
+ // THE SAME KEY THE DAEMON TOOK. `item.thread_id` alone is not that
2024
+ // key: an untreaded post locks `channel-main` and a Cohort DM locks
2025
+ // `dm-channel`, and neither is derivable from an empty thread id
2026
+ // without the item's own DM verdict. One derivation, both sides.
2027
+ releaseThreadLock(channel, threadLockKey({
2028
+ channel,
2029
+ threadId: item.thread_id,
2030
+ isDm: item.is_dm === true,
2031
+ channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
2032
+ }));
2024
2033
  // Now that the lock is gone, promote any messages that were
2025
2034
  // deferred behind it. Latest-wins: a burst of N messages
2026
2035
  // collapses into ONE re-dispatch carrying the most recent
@@ -2157,7 +2166,16 @@ function spawnSession(entry) {
2157
2166
  {
2158
2167
  const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
2159
2168
  if (channel) {
2160
- releaseThreadLock(channel, item.thread_id);
2169
+ // THE SAME KEY THE DAEMON TOOK. `item.thread_id` alone is not that
2170
+ // key: an untreaded post locks `channel-main` and a Cohort DM locks
2171
+ // `dm-channel`, and neither is derivable from an empty thread id
2172
+ // without the item's own DM verdict. One derivation, both sides.
2173
+ releaseThreadLock(channel, threadLockKey({
2174
+ channel,
2175
+ threadId: item.thread_id,
2176
+ isDm: item.is_dm === true,
2177
+ channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
2178
+ }));
2161
2179
  const promo = promoteDeferred(channel, AGENT_REPO_DIR);
2162
2180
  if (promo.promoted > 0) {
2163
2181
  logSession({
@@ -18,11 +18,26 @@
18
18
  * every `.deferred` file in any service inbox dir whose body
19
19
  * references `channel`. If exactly one is found, renames it back
20
20
  * to its original name (next poll picks it up). If N>1 are found,
21
- * keeps only the LATEST (by timestamp), promotes that one, and
22
- * marks the others `.processed-bundled` for the audit trail —
23
- * the latest item's `thread_context` already contains the prior
24
- * messages as conversation history, so Claude sees everything
25
- * and can compose a single coherent reply.
21
+ * keeps only the LATEST (by timestamp) of each BUNDLING GROUP,
22
+ * promotes those, and marks the others `.processed-bundled` for
23
+ * the audit trail.
24
+ *
25
+ * WHAT A BUNDLING GROUP IS, AND WHY IT IS NOT JUST "THE SURFACE".
26
+ * The collapse is only sound when the promoted item genuinely
27
+ * carries what is being folded away. That used to be asserted
28
+ * unconditionally — "the latest item's `thread_context` already
29
+ * contains the prior messages" — and it is true of a Slack
30
+ * channel or DM item and FALSE of a Cohort space post, which
31
+ * `lib/org/inbound` never hydrates a thread context for. Fold a
32
+ * second person's question into one of those and it is not
33
+ * bundled, it is deleted: marked `.processed-bundled` and never
34
+ * answered by anyone.
35
+ *
36
+ * So a group is (surface, sender) — a person's own flurry always
37
+ * collapses to their latest word — UNLESS the surface's newest
38
+ * item carries `thread_context`, in which case the whole surface
39
+ * collapses as before, because the session really will see every
40
+ * message it stands for.
26
41
  *
27
42
  * The file format is the YAML emitted by the slack/gmail/calendar
28
43
  * pollers (a flat top-level object with quoted scalar fields, plus an
@@ -155,6 +170,42 @@ function readScalar(body, field) {
155
170
  return null;
156
171
  }
157
172
 
173
+ /**
174
+ * Does this file carry a TOP-LEVEL key at all, block scalar or not?
175
+ *
176
+ * `readScalar` answers "what is this field's single-line value", which is the
177
+ * wrong question for `thread_context`: the pollers write it as a block scalar
178
+ * (`thread_context: |`), so its value never lives on the key's own line. All
179
+ * the bundling decision needs to know is whether the field is PRESENT — the
180
+ * pollers emit it only when there is history to emit (`if (item.thread_context)`
181
+ * in scripts/poller/utils.mjs), so presence is exactly "this item carries the
182
+ * conversation around it".
183
+ *
184
+ * @param {string} body
185
+ * @param {string} field
186
+ * @returns {boolean}
187
+ */
188
+ function hasTopLevelKey(body, field) {
189
+ if (typeof body !== "string") return false;
190
+ // TWO MECHANISMS, ONE PROPERTY, and the redundancy is stated rather than
191
+ // pretended away: the pattern is anchored at column 0, so an indented line
192
+ // cannot match it and the `continue` below is a BELT, not the mechanism.
193
+ // Deleting the `continue` alone changes no behaviour — a mutation of it
194
+ // leaves the suite green, and that is honest rather than a hole. What the
195
+ // suite does refuse is the realistic future edit: loosening this anchor to
196
+ // `^\s*` (the shape `readScalar` had before audit L7) with nothing else in
197
+ // the way. That is pinned as a BEHAVIOUR, in inbox-deferral.test.mjs — "L7:
198
+ // body line `thread_context:` in a block scalar does not fake carrying
199
+ // history" — because the property worth pinning is that a colleague's
200
+ // question survives, not which of two lines delivered it.
201
+ const re = new RegExp(`^${escapeRegExp(field)}\\s*:`);
202
+ for (const line of body.split("\n")) {
203
+ if (/^\s/.test(line)) continue; // indented — belongs to a block, not top level
204
+ if (re.test(line)) return true;
205
+ }
206
+ return false;
207
+ }
208
+
158
209
  /**
159
210
  * Promote every `.deferred` item targeting `channel` back into the
160
211
  * live inbox. Bundles bursts: if multiple deferred items exist for
@@ -219,6 +270,26 @@ export function promoteDeferred(channel, agentRoot) {
219
270
  readScalar(body, "thread_id") ||
220
271
  readScalar(body, "scope_id") ||
221
272
  "",
273
+ // WHO SENT IT, and WHETHER THE ITEM CARRIES ITS CONVERSATION.
274
+ //
275
+ // These two decide whether bundling is a bundle or a deletion. The
276
+ // latest-wins collapse is justified by one sentence in this module's
277
+ // header — "the latest item's thread_context already contains the
278
+ // prior messages as conversation history, so Claude sees everything" —
279
+ // and that sentence is TRUE OF SOME ITEMS AND FALSE OF OTHERS. Slack
280
+ // channel and DM items carry `thread_context` (slack-poller.mjs:434,
281
+ // :558); a Cohort space post does NOT, because `lib/org/inbound`
282
+ // never hydrates one for an untreaded space message. Bundling a
283
+ // second person's ask into a Cohort item that carries no history is
284
+ // not folding a duplicate away, it is marking their question
285
+ // `.processed-bundled` and never answering it.
286
+ //
287
+ // So: the collapse now requires evidence. Same sender is always safe
288
+ // (a person's own flurry — their newest message is their latest word
289
+ // either way). A DIFFERENT sender may only be folded into an item that
290
+ // actually carries the room's history.
291
+ sender: (readScalar(body, "sender") || "").trim().toLowerCase(),
292
+ carriesHistory: hasTopLevelKey(body, "thread_context"),
222
293
  });
223
294
  }
224
295
  if (matches.length === 0) continue;
@@ -226,13 +297,35 @@ export function promoteDeferred(channel, agentRoot) {
226
297
  // Group by surface within the channel, then bundle latest-wins WITHIN each
227
298
  // group. Distinct surfaces each promote their own latest; none is dropped
228
299
  // just because a newer message landed on a different surface in the room.
229
- const groups = new Map();
300
+ const surfaces = new Map();
230
301
  for (const m of matches) {
231
- if (!groups.has(m.surface)) groups.set(m.surface, []);
232
- groups.get(m.surface).push(m);
302
+ if (!surfaces.has(m.surface)) surfaces.set(m.surface, []);
303
+ surfaces.get(m.surface).push(m);
304
+ }
305
+
306
+ // SPLIT A SURFACE BY SENDER UNLESS ITS WINNER CARRIES THE HISTORY.
307
+ // With history present this is byte-for-byte the old behaviour: one group
308
+ // per surface, latest wins, everything else bundled. Without it, each
309
+ // sender keeps their own latest and promotes it — so two people's asks
310
+ // serialise into two sessions rather than one of them disappearing.
311
+ const groups = [];
312
+ for (const group of surfaces.values()) {
313
+ group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
314
+ const winner = group[group.length - 1];
315
+ const senders = new Set(group.map((m) => m.sender));
316
+ if (senders.size <= 1 || winner.carriesHistory) {
317
+ groups.push(group);
318
+ continue;
319
+ }
320
+ const bySender = new Map();
321
+ for (const m of group) {
322
+ if (!bySender.has(m.sender)) bySender.set(m.sender, []);
323
+ bySender.get(m.sender).push(m);
324
+ }
325
+ for (const g of bySender.values()) groups.push(g);
233
326
  }
234
327
 
235
- for (const group of groups.values()) {
328
+ for (const group of groups) {
236
329
  // Latest-wins: lex-sort ISO timestamps, take the most recent.
237
330
  group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
238
331
  const latest = group[group.length - 1];
@@ -311,6 +311,37 @@ export function checkRecentlySent(channel, threadTs, type) {
311
311
  return { allowed: true };
312
312
  }
313
313
 
314
+ /**
315
+ * PURE. The ONE definition of which conversation a thread lock covers.
316
+ *
317
+ * WHY THIS EXISTS AS A FUNCTION. The normalisation used to be written twice —
318
+ * once where the lock is taken (agent-daemon, as an inline ternary) and once
319
+ * where it is released (`releaseThreadLock`, as a different inline rule) — and
320
+ * the two did not agree. The acquiring side turned a DM into `dm-channel` from
321
+ * `item.is_dm`; the releasing side only recognised a DM by a channel id
322
+ * starting with "D", which is a SLACK id shape. A Cohort DM therefore took a
323
+ * `dm-channel` lock and released nothing, and the room stayed locked for the
324
+ * full sixty-minute TTL with every later message deferred behind it. Two
325
+ * copies of a key derivation is one copy too many; this is now the only one.
326
+ *
327
+ * @param {object} o
328
+ * @param {string} o.channel
329
+ * @param {string} [o.threadId] the item's own thread id, if it has one
330
+ * @param {boolean} [o.isDm] the daemon's own DM verdict (item.is_dm)
331
+ * @param {boolean} [o.channelMainLock=true] false restores the pre-2026-09-25
332
+ * behaviour where an untreaded channel post took no lock at all
333
+ * @returns {string|null} the lock key, or null when this item takes no lock
334
+ */
335
+ export function threadLockKey(o = {}) {
336
+ const channel = o.channel;
337
+ if (!channel) return null;
338
+ // A Slack DM id ("D…") is a DM whatever the caller believed.
339
+ if (String(channel).startsWith("D")) return "dm-channel";
340
+ if (o.threadId) return String(o.threadId);
341
+ if (o.isDm === true) return "dm-channel";
342
+ return o.channelMainLock === false ? null : "channel-main";
343
+ }
344
+
314
345
  /**
315
346
  * Check if a session was already dispatched for the same thread recently.
316
347
  * Prevents multiple sessions from responding to the same thread when
@@ -516,7 +547,16 @@ export function releaseThreadLock(channel, threadTs) {
516
547
  // the first was skipped with "thread_dedup: DM channel already
517
548
  // dispatched 1200s ago").
518
549
  if (channel.startsWith("D")) threadTs = "dm-channel";
519
- if (!threadTs) return; // non-DM channel without a thread — no lock to release
550
+ // A caller that hands us nothing is asking us to guess, and the only guess
551
+ // that mirrors the acquiring side is `threadLockKey`'s: an untreaded, non-DM
552
+ // channel post locks the room's main feed. Unlinking a lock that was never
553
+ // taken (the off-switch case, or a genuinely lockless item) is a no-op under
554
+ // the catch below, so guessing here can only ever free a lock, never wedge
555
+ // one. The reverse — returning early, as this did — left every
556
+ // `channel-main` lock to expire on its sixty-minute TTL, which is the whole
557
+ // channel gagged for an hour after one message.
558
+ if (!threadTs) threadTs = threadLockKey({ channel, threadId: null, isDm: false });
559
+ if (!threadTs) return;
520
560
  const safeKey = sanitiseItemId(`thread-${channel}-${threadTs}`);
521
561
  const lockPath = join(LOCKS_DIR, `${safeKey}.lock`);
522
562
  try {