@cohortapp/agent-sdk 2.9.0 → 2.10.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.
@@ -31,6 +31,10 @@ import { join } from "path";
31
31
  import { screenOutbound } from "../../lib/comms/send-gate.mjs";
32
32
  import { getHookBus } from "../../lib/hooks/bus.mjs";
33
33
  import { recordOutbound } from "../../lib/comms/receipts.mjs";
34
+ // Pure data (a frozen table, no imports of its own) — the surface vocabulary the
35
+ // inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
36
+ // new surface cannot be added upstream without this file's switch noticing.
37
+ import { SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
34
38
 
35
39
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
36
40
 
@@ -63,18 +67,288 @@ export function resolveSlackChannel(item) {
63
67
  return null;
64
68
  }
65
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Cohort surface routing
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * WHY THIS EXISTS — the `task/<title>` incident.
76
+ *
77
+ * Cohort inbound is not one surface, it is fourteen. Four of them are ROOMS (a
78
+ * DM, an @mention, a thread reply, a call invite) and a reply to those is a
79
+ * `messaging.send` into a channel id. The other ten are ENTITIES — a board
80
+ * item's comment thread, a doc's comment thread, a decision's thread, an
81
+ * approval, a handoff — and their reply surface is the ENTITY, not a room.
82
+ * `lib/org/inbound/project.mjs` names this exact failure mode in a comment.
83
+ *
84
+ * SOME OF THEM DO CARRY A CHANNEL, and this module routes them to the entity
85
+ * anyway. `lib/org/inbound/hydrate.mjs` sets
86
+ * `channelId: c.ids.channelId || (task && task.channelId) || ""`, so a task
87
+ * that lives in a Space has a real, sendable channel id, and the old code did
88
+ * deliver those — as a loose chat message in the Space, detached from the task
89
+ * thread the human was reading. THIS IS A DELIBERATE, VISIBLE BEHAVIOUR CHANGE:
90
+ * a board comment now gets a board comment, always, not "a board comment when
91
+ * we happen to have nowhere else to put it". The reply lands where the question
92
+ * was asked. If that is ever wrong, the fix is a `room()` fallback on the board
93
+ * case — not restoring `channel_id ||` as a silent first choice.
94
+ *
95
+ * Downstream never enforced it. `sendCohortReply` read
96
+ * `item.channel_id || item.channel`, and `item.channel` is the HUMAN LABEL
97
+ * (`lib/channels/inbox-item.mjs` carries `channel_label` there:
98
+ * `task/Roll out tier-3 guardrails…`, `doc/AI-BORN-CHARTER…pdf`,
99
+ * `approval/…`). So a board comment was answered by posting the reply to a
100
+ * channel whose id was a task's TITLE. hq's messaging.send resolves that to
101
+ * nothing and throws NOT_FOUND; the daemon caught it, logged a warning, and
102
+ * `deliverWithRetry` — whose permanent-failure test only matched send-gate
103
+ * blocks — retried the structurally impossible send three times, every sweep,
104
+ * forever. Six live items on one agent's disk were in that state.
105
+ *
106
+ * The rule this module now enforces, at the ONE chokepoint every outbound
107
+ * crosses (the answer, the acknowledgement and the assurance sweep all land in
108
+ * `deliver`):
109
+ *
110
+ * A LABEL IS NEVER AN ID. An item from a surface with no room is answered ON
111
+ * THAT SURFACE — a board comment gets a board comment — and an item that
112
+ * cannot be addressed at all fails LOUDLY, permanently, with the reason,
113
+ * rather than being guessed into a channel that does not exist.
114
+ *
115
+ * `item.channel` is not consulted anywhere on the send path any more, on any
116
+ * service: telegram's is `dm/<name>`, Cohort's is `task/<title>`. There is no
117
+ * correct case for it.
118
+ */
119
+
120
+ /** Surfaces that genuinely live in a Cohort room — these reply with a message. */
121
+ const CHANNEL_SURFACES = Object.freeze(["dm", "mention", "thread_reply", "call"]);
122
+
123
+ /**
124
+ * Error frame codes a retry cannot change.
125
+ *
126
+ * `UNAUTHORIZED` IS DELIBERATELY NOT ON THIS LIST. The others describe the
127
+ * REQUEST — a task that does not exist, a body hq will never accept, a scope
128
+ * this key does not hold — and re-sending the identical bytes cannot change the
129
+ * answer. A 401 describes the CREDENTIAL at one instant: a token mid-rotation,
130
+ * an hq restart between issuing and honouring, a clock skew on a signature.
131
+ * Treating it as permanent throws the reply away on the first blip of an
132
+ * otherwise healthy deployment, which is a strictly worse failure than one
133
+ * extra attempt.
134
+ */
135
+ const PERMANENT_RPC_CODES = Object.freeze([
136
+ "NOT_FOUND",
137
+ "BAD_REQUEST",
138
+ "FORBIDDEN",
139
+ "FORBIDDEN_SCOPE",
140
+ ]);
141
+
142
+ /**
143
+ * The one BAD_REQUEST a board comment is allowed to answer by re-asking with a
144
+ * different author kind. hq's `board/addTaskComment.ts` throws
145
+ * "this task has no responsible agent to reply" / "responsible agent not found"
146
+ * when `kind:"agent"` finds no assignee and no reviewer. Matched on the phrase
147
+ * both messages share, so a reworded message keeps working and an unrelated
148
+ * BAD_REQUEST (a malformed body, a bad id) stays permanent.
149
+ */
150
+ const NO_RESPONSIBLE_AGENT = /responsible agent/i;
151
+
152
+ function str(v) {
153
+ return v == null ? "" : String(v).trim();
154
+ }
155
+
156
+ /** The entity id `project.mjs` stamped into `raw_ref` (`cohort:<surface>:<id>:<seq>`). */
157
+ function entityFromRawRef(item) {
158
+ const m = /^cohort:[a-z_]+:(.+):[^:]*$/.exec(str(item && item.raw_ref));
159
+ return m ? str(m[1]) : "";
160
+ }
161
+
162
+ /**
163
+ * The inbound SURFACE this Cohort item came from.
164
+ *
165
+ * `raw_ref` is authoritative — `project.mjs` writes the surface name into it
166
+ * verbatim. `item.kind` is the fallback, but it is LOSSY: `doc_comment` (a
167
+ * workspace doc, family `files`) and `file_comment` (a chat attachment, family
168
+ * `file`) share the MessageEvent kind `file_comment` and take different reply
169
+ * methods with different id shapes, so they are told apart by the one thing
170
+ * that distinguishes them — the chat attachment always names its room.
171
+ */
172
+ export function cohortSurfaceOf(item) {
173
+ if (!item) return "";
174
+ // Guarded against the LEGACY raw_ref shape, which is `cohort:<channelId>:
175
+ // <messageId>` — still on disk today. An all-lowercase channel id would
176
+ // otherwise parse as a surface name and route a working DM to nowhere.
177
+ const m = /^cohort:([a-z_]+):/.exec(str(item.raw_ref));
178
+ if (m && SURFACE_NAMES.includes(m[1])) return m[1];
179
+ const kind = str(item.kind);
180
+ if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
181
+ if (!kind || kind === "message") return "dm";
182
+ return kind;
183
+ }
184
+
185
+ /**
186
+ * Where a reply to this Cohort item goes — a pure function of the item, so it
187
+ * can be asked BEFORE anything is promised (see `canDeliverTo`).
188
+ *
189
+ * @param {object} item daemon inbox item (service === "cohort")
190
+ * @returns {{transport:"channel", surface:string, channelId:string, target:string}
191
+ * |{transport:"rpc", surface:string, method:string, params:object, bodyKey:string, target:string}
192
+ * |{transport:null, surface:string, error:string}}
193
+ */
194
+ export function cohortReplyRoute(item) {
195
+ if (!item) return { transport: null, surface: "", error: "no item" };
196
+ const surface = cohortSurfaceOf(item);
197
+ const channelId = str(item.channel_id);
198
+ const threadId = str(item.thread_id);
199
+ const scopeId = str(item.scope_id);
200
+ const entity = entityFromRawRef(item);
201
+
202
+ // A surface whose reply IS a room message. `channel_id` only ever carries a
203
+ // real Cohort channel id (project.mjs guarantees it); blank means no room.
204
+ const room = (why) =>
205
+ channelId
206
+ ? { transport: "channel", surface, channelId, target: channelId }
207
+ : { transport: null, surface, error: why };
208
+
209
+ if (CHANNEL_SURFACES.includes(surface)) {
210
+ return room(`cohort ${surface} item has no channel_id — nothing to reply into`);
211
+ }
212
+
213
+ switch (surface) {
214
+ // ── board. The task id comes from `scope_id`, else `thread_id`, else
215
+ // `raw_ref`. hydrate.mjs sets `threadId: taskId`; `project.mjs`'s
216
+ // `scope_id` is `taskId || decisionId || fileId`, i.e. NOT surface-typed
217
+ // — which is safe here only because a board item is the one surface whose
218
+ // `scope_id` is a task (the live approval item on disk proves the general
219
+ // case: its `scope_id` is a task id while its `thread_id` is the approval,
220
+ // and approvals have no RPC route at all).
221
+ //
222
+ // `kind` DECIDES WHOSE NAME IS ON THE COMMENT, and hq resolves it
223
+ // server-side (`server/methods/board/addTaskComment.ts`):
224
+ // "agent" → the task's assignee, else its reviewer;
225
+ // "human" → the ACTING member, i.e. this paired agent.
226
+ // "human" is the safer call for authorship and the wrong one for
227
+ // LABELLING: `components/board/task-conversation.tsx` renders
228
+ // `kind === "human"` as the viewer's own "me" bubble WITHOUT an
229
+ // `<Avatar kind>` — so an AI reply arrived looking like something the
230
+ // reading human had written themselves, on the one lane whose whole
231
+ // argument is AI disclosure. So: ask for "agent", which is both the
232
+ // honest label and, for a directed board item, the correct author (the
233
+ // inbound is directed here BECAUSE this agent is the assignee/reviewer);
234
+ // and fall back to "human" for the one case hq refuses it — an
235
+ // unassigned, unreviewed task, where "agent" is a BAD_REQUEST and the
236
+ // acting member is the only author there is. See `fallbackParams`.
237
+ case "task_assigned":
238
+ case "task_comment": {
239
+ const taskId = scopeId || threadId || entity;
240
+ if (!taskId) {
241
+ return { transport: null, surface, error: "board item carries no task id (scope_id, thread_id and raw_ref all blank)" };
242
+ }
243
+ return {
244
+ transport: "rpc", surface,
245
+ method: "board.addTaskComment",
246
+ params: { taskId, kind: "agent" },
247
+ fallbackParams: { taskId, kind: "human" },
248
+ fallbackWhen: NO_RESPONSIBLE_AGENT,
249
+ bodyKey: "body",
250
+ target: `task:${taskId}`,
251
+ };
252
+ }
253
+
254
+ // ── workspace doc (family `files`). Both handles are the File id.
255
+ case "doc_comment": {
256
+ const fileId = scopeId || threadId || entity;
257
+ if (!fileId) {
258
+ return { transport: null, surface, error: "doc comment carries no file id (scope_id, thread_id and raw_ref all blank)" };
259
+ }
260
+ return {
261
+ transport: "rpc", surface,
262
+ method: "files.commentAdd",
263
+ params: { fileId },
264
+ bodyKey: "body",
265
+ target: `file:${fileId}`,
266
+ };
267
+ }
268
+
269
+ // ── chat attachment (family `file`). The handle is the opaque `fileKey`,
270
+ // which hydrate.mjs puts in `threadId` — `raw_ref` here holds the
271
+ // COMMENT id, not the file, so it is deliberately not a fallback. hq's
272
+ // `file.addComment` also requires the room the attachment lives in.
273
+ case "file_comment": {
274
+ const fileKey = threadId;
275
+ if (!fileKey) return { transport: null, surface, error: "chat file comment carries no fileKey (thread_id blank)" };
276
+ if (!channelId) return { transport: null, surface, error: `chat file comment on ${fileKey} has no channel_id — file.addComment requires one` };
277
+ return {
278
+ transport: "rpc", surface,
279
+ method: "file.addComment",
280
+ params: { fileKey, channelId },
281
+ bodyKey: "body",
282
+ target: `file:${fileKey}`,
283
+ };
284
+ }
285
+
286
+ // ── decision. NOTE the param is `text`, not `body` (decision/comment.ts).
287
+ case "decision": {
288
+ const decisionId = scopeId || threadId || entity;
289
+ if (!decisionId) {
290
+ return { transport: null, surface, error: "decision carries no decision id (scope_id, thread_id and raw_ref all blank)" };
291
+ }
292
+ return {
293
+ transport: "rpc", surface,
294
+ method: "decision.comment",
295
+ params: { decisionId },
296
+ bodyKey: "text",
297
+ target: `decision:${decisionId}`,
298
+ };
299
+ }
300
+
301
+ // ── escalation / calendar. hq attaches a real channel to these when the
302
+ // entity is room-bound, and that channel IS the surface. When it is not
303
+ // (an escalation hanging off a task, an event with no room) there is no
304
+ // prose surface to write into: neither family has a comment method.
305
+ case "escalation":
306
+ return room("escalation is not room-bound and has no comment surface (escalation.resolve/answer are verdicts, not messages)");
307
+ case "calendar":
308
+ return room("calendar event is not room-bound and has no comment surface");
309
+
310
+ // ── verdict surfaces. Answering these is an ACT, not a sentence, and
311
+ // guessing an act from generated prose is not something this transport
312
+ // is allowed to do. Undeliverable is the honest answer — assurance then
313
+ // escalates to an operator instead of promising a reply it cannot send.
314
+ case "approval":
315
+ return { transport: null, surface, error: "approval has no comment surface — a response is a verdict (approval.resolve), not a message" };
316
+ case "handoff":
317
+ return { transport: null, surface, error: "handoff has no comment surface — a response is handoff.accept/decline, not a message" };
318
+
319
+ // ── email. `channel_id` on this surface is a MAILBOX id, which
320
+ // messaging.send would reject exactly like a task title did. Replying
321
+ // needs `email.send` with a recipient address, and the inbox item does
322
+ // not carry one (only the sender's display name survives projection).
323
+ case "email":
324
+ return { transport: null, surface, error: "cohort email reply needs email.send with a recipient address; this item carries none (channel_id is a mailbox id, not a channel)" };
325
+
326
+ default:
327
+ return { transport: null, surface, error: `unknown cohort surface ${surface || "(none)"} — no reply route` };
328
+ }
329
+ }
330
+
66
331
  /**
67
- * The channel a reply to this item belongs in, whatever the service — the key
68
- * both the receipt ledger and the obligation ledger are keyed on.
332
+ * The place a reply to this item belongs, whatever the service — the key both
333
+ * the receipt ledger and the obligation ledger are keyed on.
334
+ *
335
+ * Routing is by SURFACE, not by person: the surface the inbound arrived on is
336
+ * the surface the answer belongs on. For a room that is the channel id; for an
337
+ * entity thread it is `task:<id>` / `file:<id>` / `decision:<id>`.
69
338
  *
70
- * Routing is by ROOM, not by person: the room the inbound arrived in is the
71
- * room the answer belongs in.
339
+ * Returns null — never a label — when nothing addressable exists.
72
340
  */
73
341
  export function replyTargetOf(item) {
74
342
  if (!item) return null;
75
343
  if (item.service === "slack") return resolveSlackChannel(item);
76
344
  if (item.service === "gmail") return item.sender_email || item.sender || null;
77
- return item.channel_id || item.channel || null;
345
+ if (item.service === "cohort") {
346
+ const route = cohortReplyRoute(item);
347
+ return route.transport ? route.target : null;
348
+ }
349
+ // Every other producer addresses by id. `item.channel` is the display LABEL
350
+ // (`dm/<name>`) and was the fallback here until it got handed to a send.
351
+ return item.channel_id || null;
78
352
  }
79
353
 
80
354
  /**
@@ -93,7 +367,13 @@ export const DELIVERABLE_SERVICES = Object.freeze(["slack", "gmail", "cohort"]);
93
367
 
94
368
  /**
95
369
  * Can `deliver` reach the human behind this item at all? Requires both a
96
- * transport for the service AND a resolvable room to write into.
370
+ * transport for the service AND a resolvable surface to write onto.
371
+ *
372
+ * This used to answer TRUE for every Cohort board/doc/decision item, because
373
+ * `replyTargetOf` fell through to the human label and a label is truthy. The
374
+ * assurance ledger asks this BEFORE it promises an acknowledgement, so a "yes"
375
+ * here on an unsendable item is how the daemon came to promise a reply it had
376
+ * no way to deliver.
97
377
  */
98
378
  export function canDeliverTo(item) {
99
379
  if (!item || !item.service) return false;
@@ -172,9 +452,13 @@ async function sendGmailResponse(item, text) {
172
452
  // ---------------------------------------------------------------------------
173
453
 
174
454
  /**
175
- * Send onto Cohort. Goes through lib/org/messaging.sendMessage rather than the
176
- * raw RPC so the outbound send-gate, the afterSend hook and cost attribution
177
- * all still run.
455
+ * Send onto Cohort — ONTO THE SURFACE THE ITEM CAME FROM.
456
+ *
457
+ * `cohortReplyRoute` decides. A room surface goes through
458
+ * lib/org/messaging.sendMessage (so the outbound send-gate, the afterSend hook
459
+ * and cost attribution all still run); an entity thread goes through its own
460
+ * comment method with the send-gate applied here. Nothing guesses a channel,
461
+ * and `item.channel` — a human label — is never read.
178
462
  *
179
463
  * `idempotencySuffix` distinguishes the several distinct things we may say
180
464
  * about ONE item — the acknowledgement, the FIRST progress update, the SECOND
@@ -189,29 +473,151 @@ async function sendGmailResponse(item, text) {
189
473
  * dedup doing its job.
190
474
  */
191
475
  async function sendCohortReply(item, text, o = {}) {
476
+ const route = cohortReplyRoute(item);
477
+
478
+ // LOUD. A reply with nowhere to go is a defect in the item or in the routing
479
+ // table above, and the whole reason this bug survived for months is that its
480
+ // symptom was a caught NOT_FOUND folded into a warning nobody read. Say the
481
+ // surface, say why, and mark it PERMANENT so the sweep stops re-sending it.
482
+ if (!route.transport) {
483
+ const why = `cannot address a reply to ${route.surface || "unknown"} item ${str(item && (item.raw_ref || item.id)) || "(no ref)"}: ${route.error}`;
484
+ console.error(`[deliver] ${why}`);
485
+ return { sent: false, via: null, permanent: true, surface: route.surface, error: why };
486
+ }
487
+
192
488
  try {
193
- const channel = item.channel_id || item.channel || "";
194
- if (!channel) return { sent: false, via: null, error: "no channel_id on item" };
195
- const { sendMessage } = await import("../../lib/org/messaging.mjs");
196
- const { loadOrgConfig } = await import("../../lib/org/client.mjs");
197
- const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
489
+ const agentRoot = o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
490
+ const cfg = o.cfg || (await import("../../lib/org/client.mjs")).loadOrgConfig(agentRoot);
198
491
  const base = item.message_id || item.id || item.raw_ref || Date.now();
199
492
  const suffix = o.idempotencySuffix ? `-${o.idempotencySuffix}` : "";
200
- const frame = await sendMessage(
201
- {
202
- channel,
203
- body: text,
204
- idempotencyId: `reply-${base}${suffix}`,
205
- ...(item.thread_id ? { threadId: item.thread_id } : {}),
206
- ...(Array.isArray(o.mentions) && o.mentions.length ? { mentions: o.mentions } : {}),
207
- },
208
- { cfg: loadOrgConfig(agentRoot), agentRoot },
209
- );
210
- if (frame && frame.ok) return { sent: true, via: "cohort", channel };
211
- return { sent: false, via: null, channel, error: (frame && frame.error && frame.error.message) || "send failed" };
493
+ const idempotencyId = `reply-${base}${suffix}`;
494
+
495
+ let frame;
496
+ if (route.transport === "channel") {
497
+ const sendMessageImpl = o.sendMessageImpl || (await import("../../lib/org/messaging.mjs")).sendMessage;
498
+ frame = await sendMessageImpl(
499
+ {
500
+ channel: route.channelId,
501
+ body: text,
502
+ idempotencyId,
503
+ ...(item.thread_id ? { threadId: item.thread_id } : {}),
504
+ ...(Array.isArray(o.mentions) && o.mentions.length ? { mentions: o.mentions } : {}),
505
+ },
506
+ {
507
+ cfg, agentRoot,
508
+ ...(o.gateImpl ? { gateImpl: o.gateImpl } : {}),
509
+ ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}),
510
+ },
511
+ );
512
+ } else {
513
+ frame = await sendCohortEntityComment(route, text, { ...o, cfg, agentRoot, idempotencyId });
514
+ }
515
+
516
+ if (frame && frame.ok) return { sent: true, via: route.transport === "channel" ? "cohort" : route.method, channel: route.target, surface: route.surface };
517
+ const code = str(frame && frame.error && frame.error.code);
518
+ const message = str(frame && frame.error && frame.error.message) || "send failed";
519
+ if (code && PERMANENT_RPC_CODES.includes(code)) {
520
+ // A NOT_FOUND/BAD_REQUEST is the server saying "this can never work".
521
+ // Retrying it is what produced `"attempts":3` on every failed row.
522
+ console.error(`[deliver] ${route.method || "messaging.send"} → ${route.target}: ${code} ${message} (permanent, not retrying)`);
523
+ return { sent: false, via: null, permanent: true, channel: route.target, surface: route.surface, code, error: `${code}: ${message}` };
524
+ }
525
+ return { sent: false, via: null, channel: route.target, surface: route.surface, ...(code ? { code } : {}), error: code ? `${code}: ${message}` : message };
212
526
  } catch (err) {
213
- return { sent: false, via: null, error: err && err.message };
527
+ return { sent: false, via: null, channel: route.target, surface: route.surface, error: err && err.message };
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Post onto a Cohort ENTITY thread (a task, a doc, a chat attachment, a
533
+ * decision) rather than into a room.
534
+ *
535
+ * The send-gate runs here too, and FAIL-CLOSED. `lib/org/messaging.sendMessage`
536
+ * is the chokepoint for room messages; these routes bypass it, so without this
537
+ * a board reply would be the one outbound in the system that skipped
538
+ * banned-phrase, AI-disclosure and information-barrier screening. Fail-closed
539
+ * matches messaging.mjs's own Cohort posture: never send what we could not
540
+ * screen.
541
+ *
542
+ * THE GATE IS CALLED WITH THE SAME ARGUMENTS THE ROOM LANE USES, all of them.
543
+ * `screenOutbound` defaults `firstContact` to TRUE (send-gate.mjs), and under
544
+ * the shipped `disclose-always` posture that blocks any message which does not
545
+ * proactively disclose AI. `lib/org/messaging.sendMessage` passes `false` on
546
+ * purpose — an org room is a continuing conversation with colleagues, not a
547
+ * cold approach — and so must this. Omitting it made the gate refuse 100% of
548
+ * board/doc/decision replies with FORBIDDEN_SCOPE, which is in
549
+ * {@link PERMANENT_RPC_CODES}: dropped on the first attempt, never retried.
550
+ * A routing table that reaches the wrong wire and a routing table that reaches
551
+ * no wire at all are the same bug wearing different error strings, so the test
552
+ * for this lane runs the REAL gate against the REAL policies rather than a stub
553
+ * (`deliver.test.mjs`, "the real send-gate").
554
+ */
555
+ async function sendCohortEntityComment(route, text, o = {}) {
556
+ const { errFrame } = await import("../../lib/org/protocol.mjs");
557
+ const gate = o.gateImpl || screenOutbound;
558
+ let screened;
559
+ try {
560
+ screened = await gate({
561
+ channel: "cohort",
562
+ recipient: route.target,
563
+ text,
564
+ agentRoot: o.agentRoot || AGENT_REPO_DIR,
565
+ jurisdiction: o.jurisdiction,
566
+ firstContact: o.firstContact !== undefined ? o.firstContact : false,
567
+ internalDomains: o.internalDomains || [],
568
+ allowlist: o.allowlist,
569
+ });
570
+ } catch (err) {
571
+ return errFrame("INTERNAL", `send-gate error: ${err && err.message ? err.message : String(err)}`);
572
+ }
573
+ if (!screened || !screened.allow) {
574
+ return errFrame("FORBIDDEN_SCOPE", (screened && screened.reason) || "blocked by send-gate");
575
+ }
576
+ const body = screened.redactedText != null ? screened.redactedText : text;
577
+
578
+ // Same enrollment resolution messaging.mjs uses — one place decides whether
579
+ // this agent is paired, and to which base.
580
+ const { isEnabled, configFromAgent, call } = await import("../../lib/org/client.mjs");
581
+ if (!isEnabled(o.cfg)) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
582
+ const c = configFromAgent(o.cfg);
583
+ if (!c.base) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
584
+
585
+ const callImpl = o.callImpl || call;
586
+ const wire = (params) =>
587
+ callImpl(
588
+ route.method,
589
+ { ...params, [route.bodyKey]: body },
590
+ { base: c.base, token: c.token, orgId: c.orgId, idempotencyKey: o.idempotencyId || undefined, fetchImpl: o.fetchImpl },
591
+ );
592
+
593
+ let frame = await wire(route.params);
594
+
595
+ // ONE re-ask, on ONE named refusal, with a DIFFERENT request — not a retry.
596
+ // `deliverWithRetry` re-sends the same bytes and cannot help here: hq has
597
+ // already told us this task has nobody to attribute an agent comment to, and
598
+ // it will say so again. See the `kind` note on the board route.
599
+ if (
600
+ frame && !frame.ok && route.fallbackParams &&
601
+ str(frame.error && frame.error.code) === "BAD_REQUEST" &&
602
+ (!route.fallbackWhen || route.fallbackWhen.test(str(frame.error && frame.error.message)))
603
+ ) {
604
+ console.warn(`[deliver] ${route.method} → ${route.target}: ${str(frame.error.message)} — re-asking as the acting member`);
605
+ frame = await wire(route.fallbackParams);
606
+ }
607
+
608
+ if (frame && frame.ok) {
609
+ try {
610
+ await getHookBus().emit("afterSend", { channel: "cohort", recipient: route.target, text: body, result: frame.result, source: route.method });
611
+ } catch { /* observe-only: a broken subscriber must never break a send */ }
612
+ // Cost/usage attribution, exactly as `lib/org/messaging.sendMessage` does it
613
+ // for a room send (its invariant #3). Without this the board/doc/decision
614
+ // lane was the one outbound family missing from the messaging ledger.
615
+ try {
616
+ const { attributeMessaging } = await import("../../lib/org/messaging.mjs");
617
+ await attributeMessaging({ cfg: o.cfg, agentRoot: o.agentRoot, action: route.method, channel: route.target }, o);
618
+ } catch { /* attribution is observational — never fail a delivered send on it */ }
214
619
  }
620
+ return frame;
215
621
  }
216
622
 
217
623
  // ---------------------------------------------------------------------------
@@ -231,11 +637,17 @@ async function sendCohortReply(item, text, o = {}) {
231
637
  * @param {string} text exactly what the human will read
232
638
  * @param {object} [o]
233
639
  * @param {string} [o.kind] receipt kind: "ack" | "progress" | "failure" | "reply"
234
- * @param {string[]} [o.mentions] org member ids to @-tag (cohort only)
640
+ * @param {string[]} [o.mentions] org member ids to @-tag (cohort room sends only)
235
641
  * @param {function} [o.fetchImpl] test seam
236
- * @returns {Promise<{sent:boolean, via:string|null, channel?:string, error?:string}>}
642
+ * @param {function} [o.callImpl] test seam — the org RPC (entity-thread routes)
643
+ * @param {function} [o.sendMessageImpl] test seam — messaging.send (room routes)
644
+ * @param {function} [o.gateImpl] test seam — the outbound send-gate
645
+ * @param {object} [o.cfg] test seam — org config, else loaded from disk
646
+ * @returns {Promise<{sent:boolean, via:string|null, channel?:string, surface?:string,
647
+ * permanent?:boolean, code?:string, error?:string}>}
237
648
  * NEVER throws — a transport failure is returned, so the caller can
238
- * escalate rather than lose the message to an exception.
649
+ * escalate rather than lose the message to an exception. `permanent`
650
+ * means retrying cannot help (no route, or a NOT_FOUND/BAD_REQUEST).
239
651
  */
240
652
  export async function deliver(item, text, o = {}) {
241
653
  const kind = o.kind || "reply";
@@ -302,8 +714,11 @@ export async function deliverWithRetry(item, text, o = {}) {
302
714
  last = await deliver(item, text, o);
303
715
  if (last.sent) return { ...last, attempts: i + 1 };
304
716
  // A policy block is a decision, not a fault — retrying re-runs the same
305
- // deterministic screen and gets the same answer. Stop and surface it.
306
- if (last.permanent || (last.error && /send-gate blocked|blocked by send-gate|FORBIDDEN/i.test(last.error))) {
717
+ // deterministic screen and gets the same answer. So is a NOT_FOUND: the
718
+ // server has said the thing we are writing to does not exist, and a second
719
+ // and third attempt only widen the hole. Every `"attempts":3` row in the
720
+ // response log was one impossible send tried three times. Stop, surface it.
721
+ if (last.permanent || (last.error && /send-gate blocked|blocked by send-gate|FORBIDDEN|NOT_FOUND/i.test(last.error))) {
307
722
  return { ...last, attempts: i + 1, permanent: true };
308
723
  }
309
724
  if (i < attempts - 1) await sleep(base * Math.pow(2, i));
@@ -311,4 +726,13 @@ export async function deliverWithRetry(item, text, o = {}) {
311
726
  return { ...last, attempts };
312
727
  }
313
728
 
314
- export default { deliver, deliverWithRetry, resolveSlackChannel, replyTargetOf, canDeliverTo, DELIVERABLE_SERVICES };
729
+ export default {
730
+ deliver,
731
+ deliverWithRetry,
732
+ resolveSlackChannel,
733
+ replyTargetOf,
734
+ canDeliverTo,
735
+ cohortReplyRoute,
736
+ cohortSurfaceOf,
737
+ DELIVERABLE_SERVICES,
738
+ };