@cohortapp/agent-sdk 2.3.1 → 2.4.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.
Files changed (162) hide show
  1. package/bin/maestro.mjs +37 -50
  2. package/framework-features.json +30 -0
  3. package/lib/backlog.mjs +136 -0
  4. package/lib/cadences.mjs +63 -2
  5. package/lib/cadences.test.mjs +105 -0
  6. package/lib/capability/inventory.mjs +542 -0
  7. package/lib/capability/inventory.test.mjs +232 -0
  8. package/lib/capability/probe.mjs +255 -0
  9. package/lib/channels/contract.mjs +37 -1
  10. package/lib/channels/contract.test.mjs +25 -1
  11. package/lib/channels/inbox-item.mjs +20 -0
  12. package/lib/claude-bin.mjs +37 -3
  13. package/lib/claude-bin.test.mjs +42 -8
  14. package/lib/execution/disposition.mjs +501 -0
  15. package/lib/execution/disposition.test.mjs +482 -0
  16. package/lib/execution/drive.mjs +352 -0
  17. package/lib/execution/drive.test.mjs +270 -0
  18. package/lib/execution/effects.mjs +340 -0
  19. package/lib/execution/effects.test.mjs +193 -0
  20. package/lib/execution/index.mjs +152 -0
  21. package/lib/execution/intake.mjs +581 -0
  22. package/lib/execution/intake.test.mjs +343 -0
  23. package/lib/execution/journal.mjs +374 -0
  24. package/lib/execution/journal.test.mjs +261 -0
  25. package/lib/execution/match.mjs +331 -0
  26. package/lib/execution/match.test.mjs +235 -0
  27. package/lib/execution/pipeline.mjs +341 -0
  28. package/lib/execution/pipeline.test.mjs +389 -0
  29. package/lib/execution/route.mjs +332 -0
  30. package/lib/execution/route.test.mjs +186 -0
  31. package/lib/execution/surface-policy.mjs +446 -0
  32. package/lib/execution/surface-policy.test.mjs +162 -0
  33. package/lib/goals/admission.mjs +209 -0
  34. package/lib/goals/admission.test.mjs +139 -0
  35. package/lib/goals/classify.mjs +206 -0
  36. package/lib/goals/classify.test.mjs +109 -0
  37. package/lib/goals/collaborate.mjs +415 -0
  38. package/lib/goals/collaborate.test.mjs +324 -0
  39. package/lib/goals/gaps.mjs +111 -0
  40. package/lib/goals/gaps.test.mjs +284 -0
  41. package/lib/goals/loop.mjs +537 -0
  42. package/lib/goals/loop.test.mjs +719 -0
  43. package/lib/identity/persona.mjs +247 -0
  44. package/lib/identity/persona.test.mjs +117 -0
  45. package/lib/kpi.mjs +469 -0
  46. package/lib/kpi.test.mjs +244 -0
  47. package/lib/mandate/audit.mjs +168 -0
  48. package/lib/mandate/audit.test.mjs +195 -0
  49. package/lib/mandate/cache.mjs +162 -0
  50. package/lib/mandate/derive.mjs +317 -0
  51. package/lib/mandate/derive.test.mjs +224 -0
  52. package/lib/mandate/model.mjs +352 -0
  53. package/lib/mandate/model.test.mjs +145 -0
  54. package/lib/mandate/refresh.mjs +187 -0
  55. package/lib/mandate/refresh.test.mjs +293 -0
  56. package/lib/mcp/server.test.mjs +4 -4
  57. package/lib/org/approvals.mjs +14 -2
  58. package/lib/org/client.mjs +79 -25
  59. package/lib/org/client.test.mjs +54 -1
  60. package/lib/org/doctor.mjs +64 -0
  61. package/lib/org/doctor.test.mjs +31 -2
  62. package/lib/org/inbound/directedness.mjs +720 -0
  63. package/lib/org/inbound/directedness.test.mjs +543 -0
  64. package/lib/org/inbound/facts.mjs +501 -0
  65. package/lib/org/inbound/facts.test.mjs +375 -0
  66. package/lib/org/inbound/hydrate.mjs +535 -0
  67. package/lib/org/inbound/hydrate.test.mjs +326 -0
  68. package/lib/org/inbound/index.mjs +233 -0
  69. package/lib/org/inbound/index.test.mjs +324 -0
  70. package/lib/org/inbound/io.mjs +141 -0
  71. package/lib/org/inbound/project.mjs +201 -0
  72. package/lib/org/inbound/project.test.mjs +287 -0
  73. package/lib/org/inbound/surfaces.mjs +257 -0
  74. package/lib/org/knowledge.mjs +10 -1
  75. package/lib/org/knowledge.test.mjs +8 -1
  76. package/lib/org/leases.mjs +5 -0
  77. package/lib/org/mesh.mjs +45 -2
  78. package/lib/org/mesh.test.mjs +55 -0
  79. package/lib/org/messaging.mjs +180 -15
  80. package/lib/org/messaging.test.mjs +117 -0
  81. package/lib/org/param-contract.mjs +694 -0
  82. package/lib/org/param-contract.test.mjs +451 -0
  83. package/lib/org/protocol.checksum +1 -1
  84. package/lib/org/protocol.mjs +8 -0
  85. package/lib/org/protocol.test.mjs +5 -1
  86. package/lib/org/push.mjs +1025 -0
  87. package/lib/org/push.test.mjs +690 -0
  88. package/lib/org/tool-surface.mjs +138 -38
  89. package/lib/org/tool-surface.test.mjs +13 -8
  90. package/lib/org/typing.mjs +341 -0
  91. package/lib/org/typing.test.mjs +291 -0
  92. package/lib/plan/compile.mjs +510 -0
  93. package/lib/plan/compile.test.mjs +286 -0
  94. package/lib/plan/emit.mjs +256 -0
  95. package/lib/plan/emit.test.mjs +246 -0
  96. package/lib/plan/explain.mjs +226 -0
  97. package/lib/plan/explain.test.mjs +188 -0
  98. package/lib/plan/schema.mjs +140 -0
  99. package/lib/resource-governor.mjs +47 -1
  100. package/lib/resource-governor.test.mjs +21 -1
  101. package/lib/setup/enroll-from-cohort.mjs +84 -16
  102. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  103. package/lib/setup/sections/identity.mjs +15 -4
  104. package/lib/setup/sections/identity.test.mjs +94 -0
  105. package/lib/setup/sections/inventory.mjs +178 -0
  106. package/lib/setup/sections/inventory.test.mjs +198 -0
  107. package/lib/setup/sections/mandate.mjs +392 -0
  108. package/lib/setup/sections/mandate.test.mjs +373 -0
  109. package/lib/setup/sections/subagents.mjs +427 -0
  110. package/lib/setup/sections/subagents.test.mjs +429 -0
  111. package/lib/setup/sections/verify.mjs +121 -0
  112. package/lib/setup/sections/verify.test.mjs +175 -0
  113. package/lib/setup/sot.mjs +2 -0
  114. package/lib/subagents/cli.mjs +463 -0
  115. package/lib/subagents/cli.test.mjs +389 -0
  116. package/lib/subagents/client.mjs +373 -0
  117. package/lib/subagents/client.test.mjs +309 -0
  118. package/lib/subagents/gap.mjs +268 -0
  119. package/lib/subagents/gap.test.mjs +234 -0
  120. package/lib/subagents/lock.mjs +296 -0
  121. package/lib/subagents/lock.test.mjs +248 -0
  122. package/lib/subagents/manifest.mjs +224 -0
  123. package/lib/subagents/manifest.test.mjs +175 -0
  124. package/lib/subagents/refs.mjs +274 -0
  125. package/lib/subagents/refs.test.mjs +204 -0
  126. package/lib/subagents/resolve.mjs +455 -0
  127. package/lib/subagents/resolve.test.mjs +422 -0
  128. package/lib/subagents/schema.mjs +467 -0
  129. package/lib/subagents/schema.test.mjs +306 -0
  130. package/package.json +9 -4
  131. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  132. package/policies/ai-disclosure.yaml +42 -2
  133. package/scaffold/CLAUDE.md +16 -2
  134. package/schedules/triggers/goal-steward.md +79 -0
  135. package/scripts/ci/conformance-org-api.mjs +792 -0
  136. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  137. package/scripts/daemon/agent-daemon.mjs +70 -11
  138. package/scripts/daemon/cadence-handlers.mjs +187 -5
  139. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  140. package/scripts/daemon/inbox-deferral.mjs +45 -2
  141. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  142. package/scripts/daemon/inbox-wake.mjs +282 -0
  143. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  144. package/scripts/daemon/maestro-daemon.mjs +23 -0
  145. package/scripts/daemon/prompt-builder.mjs +41 -1
  146. package/scripts/daemon/responder.mjs +56 -0
  147. package/scripts/daemon/typing-registry.mjs +55 -2
  148. package/scripts/daemon/typing-registry.test.mjs +25 -0
  149. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  150. package/scripts/poller/inbox-scan-poller.mjs +26 -1
  151. package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
  152. package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
  153. package/scripts/poller/slack-poller.mjs +32 -0
  154. package/scripts/poller/slack-socket-mode.mjs +27 -1
  155. package/scripts/poller/slack-socket-mode.test.mjs +52 -0
  156. package/scripts/poller/utils.mjs +47 -0
  157. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  158. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  159. package/scripts/setup/generate-plan.mjs +108 -0
  160. package/scripts/setup/init-capability-manifest.mjs +70 -0
  161. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  162. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
package/lib/org/mesh.mjs CHANGED
@@ -331,6 +331,34 @@ async function beatOnce(ctx) {
331
331
  logWarn(`presence beat failed (${err && err.message}) — org may be down, continuing standalone`);
332
332
  return [];
333
333
  }
334
+
335
+ // A REJECTED beat is not a thrown beat. `presenceBeat` RESOLVES with a res
336
+ // FRAME — {ok:false,error:{code,message}} — for 401/403/404/500 alike, so the
337
+ // catch above only ever sees transport failures. Without this, a beat the
338
+ // server refuses is indistinguishable from one it accepted: nothing is logged,
339
+ // extractDirectives yields [], and the agent looks perfectly healthy while it
340
+ // is not in the mesh at all. Observed in the wild: an empty token produced a
341
+ // silent 401 on every beat for the life of the process.
342
+ //
343
+ // WARN ONLY — do NOT return early. An error frame is also how the kill switch
344
+ // arrives for a DEACTIVATED token: the server refuses the beat and rides
345
+ // `directive: "halt"` on the error. Returning here would drop exactly the
346
+ // signal that must never be dropped, so the directive walk below still runs.
347
+ //
348
+ // Logged, not thrown: the loop stays fail-open by design (the org being down
349
+ // must never take the daemon down). Being LOUD is the fix, not dying.
350
+ if (frame && frame.ok === false) {
351
+ const code = (frame.error && frame.error.code) || "UNKNOWN";
352
+ const msg = (frame.error && frame.error.message) || "";
353
+ logWarn(
354
+ `presence beat REJECTED by the org (${code}${msg ? `: ${msg}` : ""}) — ` +
355
+ `this agent is NOT in the mesh and will not receive directives` +
356
+ (code === "UNAUTHORIZED"
357
+ ? `. Check COHORT_API_KEY in .env is present and paired`
358
+ : ""),
359
+ );
360
+ }
361
+
334
362
  const directives = extractDirectives(frame);
335
363
  for (const d of directives) {
336
364
  try { await handleDirective(d, ctx); } catch { /* each directive is independently fail-open */ }
@@ -421,13 +449,28 @@ export async function connectOrgMesh(o = {}) {
421
449
  if (entry && entry.id) {
422
450
  // Fetch THIS seat's granted tools (SP5); falls back to workspace-wide when absent.
423
451
  ctx.agentMemberId = entry.id;
424
- await client.register(entry, {
452
+ // The rich self-entry is projected onto hq's `.strict()` registerSchema by
453
+ // the wire contract inside client.call (param-contract#toRegisterParams);
454
+ // passing it raw used to 400 on every boot.
455
+ const frame = await client.register(entry, {
425
456
  base: conn.base,
426
457
  token: conn.token,
427
458
  idempotencyKey: `registry.register:${entry.id}`,
428
459
  fetchImpl: o.fetchImpl,
429
460
  });
430
- logInfo(`registered ${entry.id} with the org mesh`);
461
+ // NEVER claim success we did not get. `register` fails OPEN (it returns an
462
+ // error frame rather than throwing), and this line used to log "registered
463
+ // …" unconditionally — so a rejected registration looked healthy on every
464
+ // boot while the agent was absent from the directory.
465
+ if (frame && frame.ok) {
466
+ logInfo(`registered ${entry.id} with the org mesh`);
467
+ } else {
468
+ const err = (frame && frame.error) || {};
469
+ logWarn(
470
+ `registration REJECTED for ${entry.id} (${err.code || "unknown"}: ${err.message || "no detail"}) ` +
471
+ `— this agent is NOT in the org directory; presence-beat will retry`,
472
+ );
473
+ }
431
474
  }
432
475
  } catch (err) {
433
476
  logWarn(`registration failed (${err && err.message}) — continuing; presence-beat will retry`);
@@ -216,6 +216,61 @@ test("org unreachable: presence beat that rejects never throws (fail-open)", asy
216
216
  handle.stop();
217
217
  });
218
218
 
219
+ test("a REJECTED beat (error frame, not a throw) is loudly warned, not silently ignored", async () => {
220
+ const root = tmpRepo();
221
+ // A refused beat resolves with an error FRAME — it does not throw — so the
222
+ // fail-open catch above never sees it. Before this was handled, an agent with
223
+ // a bad/empty token 401'd on every beat for the life of the process while
224
+ // logging nothing: it was not in the mesh, could not receive `halt`, and
225
+ // looked perfectly healthy.
226
+ const client = fakeClient({
227
+ beatFrames: [{ ok: false, error: { code: "UNAUTHORIZED", message: "bad key" } }],
228
+ });
229
+
230
+ const warnings = [];
231
+ const realWarn = console.warn;
232
+ console.warn = (...a) => { warnings.push(a.join(" ")); };
233
+ let directives;
234
+ try {
235
+ const handle = await connectOrgMesh({
236
+ agentRoot: root, cfg: cfgEnabled(), client,
237
+ setInterval: () => ({ unref() {} }),
238
+ });
239
+ directives = await _internals.beatOnce(handle._ctx);
240
+ handle.stop();
241
+ } finally {
242
+ console.warn = realWarn;
243
+ }
244
+
245
+ assert.deepEqual(directives, [], "a rejected beat yields no directives");
246
+ const warned = warnings.join("\n");
247
+ assert.match(warned, /REJECTED/, "the rejection must be logged, not swallowed");
248
+ assert.match(warned, /UNAUTHORIZED/, "the error code must reach the operator");
249
+ assert.match(warned, /COHORT_API_KEY/, "an auth failure must name the likely cause");
250
+ });
251
+
252
+ test("an accepted beat logs no rejection warning", async () => {
253
+ const root = tmpRepo();
254
+ const client = fakeClient({ beatFrames: [{ ok: true, result: { directives: [] } }] });
255
+ const warnings = [];
256
+ const realWarn = console.warn;
257
+ console.warn = (...a) => { warnings.push(a.join(" ")); };
258
+ try {
259
+ const handle = await connectOrgMesh({
260
+ agentRoot: root, cfg: cfgEnabled(), client,
261
+ setInterval: () => ({ unref() {} }),
262
+ });
263
+ await _internals.beatOnce(handle._ctx);
264
+ handle.stop();
265
+ } finally {
266
+ console.warn = realWarn;
267
+ }
268
+ assert.ok(
269
+ !warnings.join("\n").includes("REJECTED"),
270
+ "a healthy beat must not cry wolf",
271
+ );
272
+ });
273
+
219
274
  test("registration failure at boot is swallowed; the mesh still comes up", async () => {
220
275
  const root = tmpRepo();
221
276
  const client = fakeClient();
@@ -195,12 +195,19 @@ export async function sendMessage(params = {}, o = {}) {
195
195
 
196
196
  // (3) the wire — idempotent on the client message id.
197
197
  const idempotencyId = params.idempotencyId != null ? String(params.idempotencyId) : "";
198
+ // WIRE NAMES MUST MATCH hq's zod schema (server/validation/message.sendMessageBase):
199
+ // channelId, body, threadRootId, idempotencyId
200
+ // This sent `channel`, `threadId` and `id`, so EVERY messaging.send from the
201
+ // SDK was rejected with "channelId: Required" — no agent could post to Cohort
202
+ // at all. The legacy names are kept alongside for any older server that still
203
+ // reads them; the canonical ones are what hq validates.
198
204
  const wire = {
205
+ channelId: channel,
199
206
  channel,
200
207
  body: text,
201
208
  ...(Array.isArray(params.mentions) && params.mentions.length ? { mentions: params.mentions.map(String) } : {}),
202
- ...(params.threadId != null ? { threadId: String(params.threadId) } : {}),
203
- ...(idempotencyId ? { id: idempotencyId } : {}),
209
+ ...(params.threadId != null ? { threadRootId: String(params.threadId), threadId: String(params.threadId) } : {}),
210
+ ...(idempotencyId ? { idempotencyId, id: idempotencyId } : {}),
204
211
  };
205
212
  const frame = await call("messaging.send", wire, {
206
213
  base: org.base,
@@ -237,7 +244,7 @@ export async function reactMessage(params = {}, o = {}) {
237
244
  const org = resolveOrg(o.cfg);
238
245
  if (!org) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
239
246
 
240
- const frame = await call("messaging.react", { channel, messageId, emoji }, {
247
+ const frame = await call("messaging.react", { channelId: channel, channel, messageId, emoji }, {
241
248
  base: org.base,
242
249
  token: org.token,
243
250
  idempotencyKey: o.idempotencyKey,
@@ -265,6 +272,11 @@ export async function fetchHistory(params = {}, o = {}) {
265
272
  const frame = await call(
266
273
  "messaging.history",
267
274
  {
275
+ // hq's handler validates `channelId` (zod). The SDK sent `channel`, so
276
+ // messaging.history returned BAD_REQUEST on every call and no caller ever
277
+ // received history. Send both: `channelId` is what the server reads, and
278
+ // `channel` is kept for any older server still accepting the legacy name.
279
+ channelId: channel,
268
280
  channel,
269
281
  ...(params.since != null ? { since: params.since } : {}),
270
282
  ...(params.cursor != null ? { cursor: params.cursor } : {}),
@@ -372,10 +384,17 @@ export async function joinCall(params = {}, o = {}) {
372
384
  * here as `nextCursor`) so a crash never loses or double-delivers events.
373
385
  *
374
386
  * Mention detection: an event whose `mentions[]` (or `mentioned_agents[]`)
375
- * includes this agent's id, OR a DM-kind event addressed to it, counts as
376
- * directed. We do NOT include the agent's own outbound echoes (`from` == self).
387
+ * includes this agent's id, OR a message in a PRIVATE ROOM this agent belongs
388
+ * to (DM / group DM / huddle), counts as directed. We do NOT include the
389
+ * agent's own outbound echoes (`from` == self).
390
+ *
391
+ * The org event feed is org-wide and redacted, so room membership has to come
392
+ * from the agent itself — we resolve it once per pull via `messaging.channels`
393
+ * (see {@link isDirectedAtAgent} for why this is load-bearing rather than an
394
+ * optimisation). Fail-open: if the roster cannot be read we simply pass
395
+ * `undefined`, and the filter stays conservative rather than guessing.
377
396
  *
378
- * @param {object} o - { cfg, agentId?, cursor?, limit?, fetchImpl? }
397
+ * @param {object} o - { cfg, agentId?, cursor?, limit?, fetchImpl?, myChannelIds? }
379
398
  * @returns {Promise<{events:object[], nextCursor:(number|string|null)}>}
380
399
  */
381
400
  export async function pullInbound(o = {}) {
@@ -389,6 +408,21 @@ export async function pullInbound(o = {}) {
389
408
  const r = await read(path, { base: org.base, token: org.token, fetchImpl: o.fetchImpl });
390
409
  if (!r.ok || !r.payload) return { events: [], nextCursor: cursor };
391
410
 
411
+ // Which rooms am I in? Injectable for tests; otherwise one cheap RPC per pull.
412
+ let myChannelIds = o.myChannelIds;
413
+ if (myChannelIds == null) {
414
+ try {
415
+ const channels = await listChannels({ cfg: o.cfg, fetchImpl: o.fetchImpl });
416
+ if (Array.isArray(channels)) {
417
+ myChannelIds = new Set(
418
+ channels.map((c) => String((c && (c.id || c.channelId)) || "")).filter(Boolean),
419
+ );
420
+ }
421
+ } catch {
422
+ myChannelIds = undefined; // unknown → conservative filter, never a throw
423
+ }
424
+ }
425
+
392
426
  const raw = Array.isArray(r.payload) ? r.payload : (Array.isArray(r.payload.events) ? r.payload.events : []);
393
427
  let maxSeq = cursor;
394
428
  const events = [];
@@ -396,20 +430,126 @@ export async function pullInbound(o = {}) {
396
430
  if (ev && (ev.seq != null) && Number.isFinite(Number(ev.seq))) {
397
431
  maxSeq = Math.max(Number(maxSeq) || 0, Number(ev.seq));
398
432
  }
399
- const directed = isDirectedAtAgent(ev, me);
400
- if (!directed) continue;
401
- events.push(toMessageEvent(ev, me));
433
+ const directed = isDirectedAtAgent(ev, me, { myChannelIds });
434
+ if (directed) {
435
+ events.push(toMessageEvent(ev, me));
436
+ continue;
437
+ }
438
+ // MENTION CANDIDATE. The org-wide feed says `mentionCount` — a NUMBER —
439
+ // and deliberately does NOT name who was mentioned: it is readable by every
440
+ // agent holding `org.read`, so listing member ids there would leak the
441
+ // association graph of private rooms (the payload is a REDACTED audit row
442
+ // by design). That redaction is right, and it is also why this filter alone
443
+ // is deaf to an @mention in a space.
444
+ //
445
+ // So resolve it the ACL-safe way: mark it a candidate here and confirm it
446
+ // below against `messaging.history`, which hq gates PER MEMBER. If this
447
+ // agent cannot read that room, it gets nothing back and the candidate is
448
+ // dropped — fail-closed, with no widening of what the shared feed says.
449
+ const p = ev.payload || ev;
450
+ const count = Number(p.mentionCount || 0);
451
+ if (me && count > 0) {
452
+ const cand = toMessageEvent(ev, me);
453
+ cand._mentionCandidate = true;
454
+ events.push(cand);
455
+ }
456
+ }
457
+
458
+ // HYDRATE THE BODY. The org event feed is redacted by design — it carries
459
+ // {actor, channelId, channelKind, counts, ts} and NO message text — so a
460
+ // projected event arrives with `text: ""`. An empty inbound is worse than no
461
+ // inbound: the classifier sees nothing to classify and the agent would
462
+ // "reply" to a blank message.
463
+ //
464
+ // The body is fetched over `messaging.history`, which is ACL'd server-side to
465
+ // rooms this agent belongs to — so hydration cannot widen what the agent can
466
+ // see, it only re-joins content the agent was always entitled to read. One
467
+ // fetch per affected channel, not per message.
468
+ //
469
+ // Fail-open: a message we cannot hydrate is dropped rather than delivered
470
+ // blank, because a blank inbound produces a nonsense reply in a real room.
471
+ const needBody = events.filter((e) => (!e.text || e._mentionCandidate) && e.kind !== "call");
472
+ if (needBody.length) {
473
+ const byChannel = new Map();
474
+ for (const e of needBody) {
475
+ if (!e.channel_id) continue;
476
+ if (!byChannel.has(e.channel_id)) byChannel.set(e.channel_id, []);
477
+ byChannel.get(e.channel_id).push(e);
478
+ }
479
+ for (const [channelId, pending] of byChannel) {
480
+ try {
481
+ const history = await fetchHistory(
482
+ { channel: channelId, limit: 50 },
483
+ { cfg: o.cfg, fetchImpl: o.fetchImpl },
484
+ );
485
+ const byId = new Map(
486
+ (Array.isArray(history) ? history : [])
487
+ .map((m) => [String((m && (m.id || m.messageId)) || ""), m])
488
+ .filter(([id]) => id),
489
+ );
490
+ for (const e of pending) {
491
+ const hit = byId.get(String(e.message_id));
492
+ if (!hit) continue;
493
+ e.text = String(hit.body ?? hit.text ?? "");
494
+ // The ACL'd read is the ONLY place mention identities are trusted.
495
+ e._mentions = Array.isArray(hit.mentions)
496
+ ? hit.mentions.map((m) => String((m && (m.memberId || m.id)) ?? m))
497
+ : [];
498
+ if (!e.from.name || e.from.name === e.from.id) {
499
+ e.from.name = String(hit.authorName || hit.author?.displayName || e.from.name || "");
500
+ }
501
+ }
502
+ } catch {
503
+ /* fail-open: leave these unhydrated; they are dropped just below */
504
+ }
505
+ }
402
506
  }
403
- return { events, nextCursor: raw.length ? maxSeq : cursor };
507
+
508
+ // Confirm the mention candidates against what history actually returned. A
509
+ // candidate survives ONLY if this agent's id is in that message's `mentions`
510
+ // — proven by an ACL'd read, never inferred from the shared feed.
511
+ const confirmed = events.filter((e) => {
512
+ if (!e._mentionCandidate) return true;
513
+ const ok = Array.isArray(e._mentions) && e._mentions.map(String).includes(String(me));
514
+ if (!ok) return false;
515
+ e.reason = "mention";
516
+ return true;
517
+ });
518
+ for (const e of confirmed) { delete e._mentionCandidate; delete e._mentions; }
519
+
520
+ const delivered = confirmed.filter((e) => e.kind === "call" || (e.text && e.text.trim()));
521
+ return { events: delivered, nextCursor: raw.length ? maxSeq : cursor };
404
522
  }
405
523
 
406
524
  /**
407
525
  * Decide whether an org event is directed at this agent (an inbound it should
408
526
  * react to) vs ambient chatter / its own echo. Pure.
409
- * @param {object} ev @param {string} me agent id
527
+ *
528
+ * WHY THIS NEEDS `myChannelIds`. The `/v1/events` feed is ORG-scoped, not
529
+ * member-scoped — an agent sees every messaging event in the org — and the
530
+ * payload is deliberately REDACTED: hq emits only
531
+ * { actor, channelId, channelKind, recipientCount, mentionCount, ts }
532
+ * with no body and, crucially, NO recipient list. That redaction is correct
533
+ * (an org-wide feed must not leak who DMs whom), but it means "is this for me?"
534
+ * cannot be answered from the event alone. The agent's OWN channel membership
535
+ * is the missing half, and the agent already knows it (`messaging.channels`).
536
+ *
537
+ * This bit in production: a human DM'd an agent six times and the agent never
538
+ * saw a single message. The old code looked for a `to` array the payload never
539
+ * carries, then fell back to `p.kind` containing "dm" — but the event's kind is
540
+ * "send" and the DM-ness lives in `channelKind: "DM"`, which nothing inspected.
541
+ * Every human DM was silently dropped.
542
+ *
543
+ * Without `myChannelIds` this stays CONSERVATIVE for DMs (returns false rather
544
+ * than guessing), because the failure mode of guessing yes is an agent barging
545
+ * into other people's private conversations.
546
+ *
547
+ * @param {object} ev
548
+ * @param {string} me agent id
549
+ * @param {{myChannelIds?: Set<string>|string[]}} [opts]
410
550
  * @returns {boolean}
411
551
  */
412
- export function isDirectedAtAgent(ev, me) {
552
+ export function isDirectedAtAgent(ev, me, opts = {}) {
413
553
  if (!ev || typeof ev !== "object") return false;
414
554
  const family = ev.family || (ev.kind ? String(ev.kind).split(".")[0] : "");
415
555
  if (family && family !== "messaging" && family !== "calling") return false;
@@ -423,21 +563,46 @@ export function isDirectedAtAgent(ev, me) {
423
563
  .map((m) => String(m));
424
564
  if (me && mentions.includes(String(me))) return true;
425
565
 
426
- // A DM / direct call-invite addressed to this agent is directed even with no
427
- // explicit @mention.
566
+ // An explicit recipient list, when a producer supplies one.
428
567
  const to = []
429
568
  .concat(Array.isArray(p.to) ? p.to : (p.to != null ? [p.to] : []))
430
569
  .concat(Array.isArray(p.invitees) ? p.invitees : [])
431
570
  .map((t) => String(t));
432
571
  if (me && to.includes(String(me))) return true;
433
572
 
573
+ // The REAL hq shape: a private room I am a member of. Membership is what makes
574
+ // it mine — in a DM/group-DM/huddle every message is addressed to the room,
575
+ // so no @mention is required.
576
+ const mine = normaliseChannelIds(opts.myChannelIds);
577
+ const channelId = String(p.channelId || p.channel || p.callId || "");
578
+ const channelKind = String(p.channelKind || p.channel_kind || "").toUpperCase();
579
+ const PRIVATE_ROOMS = ["DM", "GROUP_DM", "HUDDLE"];
580
+ if (mine && channelId && mine.has(channelId)) {
581
+ if (PRIVATE_ROOMS.includes(channelKind)) return true;
582
+ // A call event in one of my rooms is an invite I should react to.
583
+ if (family === "calling") return true;
584
+ }
585
+
586
+ // Legacy/other producers that put the room kind in `kind` rather than
587
+ // `channelKind`. Unchanged behaviour for them.
434
588
  const kind = String(p.kind || ev.kind || "").toLowerCase();
435
- if (kind.includes("dm") || kind.includes("invite") || family === "calling") {
589
+ if (kind.includes("dm") || kind.includes("invite")) {
590
+ return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
591
+ }
592
+ // A bare `calling` event with no room context: only when we cannot scope it.
593
+ if (family === "calling" && !mine) {
436
594
  return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
437
595
  }
438
596
  return false;
439
597
  }
440
598
 
599
+ /** Accept a Set, an array, or nothing; null means "membership unknown". */
600
+ function normaliseChannelIds(v) {
601
+ if (v instanceof Set) return v;
602
+ if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
603
+ return null;
604
+ }
605
+
441
606
  /**
442
607
  * Project an org messaging/calling event onto the unified MessageEvent contract
443
608
  * (lib/channels/contract.MessageEvent) so eventToInboxItem can serialise it into
@@ -236,3 +236,120 @@ test("pullInbound: org disabled → empty, cursor preserved (fail-open)", async
236
236
  assert.deepEqual(events, []);
237
237
  assert.equal(nextCursor, 5);
238
238
  });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // The REAL hq event shape — regression for the silent-DM-drop
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /** Exactly what hq appends for a human DM (redacted: no body, no recipients). */
245
+ function hqDmEvent(overrides = {}) {
246
+ return {
247
+ seq: 9880,
248
+ family: "messaging",
249
+ kind: "send",
250
+ entityId: "cmsnbhmqy004blj016kot2pwi",
251
+ actor: "human-1",
252
+ at: "2026-08-10T14:18:55.978Z",
253
+ payload: {
254
+ ts: "2026-08-10T14:18:55.978Z",
255
+ actor: "human-1",
256
+ threaded: false,
257
+ channelId: "dm-human-agent",
258
+ channelKind: "DM",
259
+ mentionCount: 0,
260
+ recipientCount: 2,
261
+ attachmentCount: 0,
262
+ ...overrides,
263
+ },
264
+ };
265
+ }
266
+
267
+ test("isDirectedAtAgent: a human DM in MY room is directed (the production silent-drop)", () => {
268
+ // Six real DMs were dropped: the filter wanted a `to` list the redacted
269
+ // payload never carries, and checked `kind` for "dm" when the kind is "send"
270
+ // and the room type lives in `channelKind`.
271
+ const mine = new Set(["dm-human-agent"]);
272
+ assert.equal(isDirectedAtAgent(hqDmEvent(), "agent-self", { myChannelIds: mine }), true);
273
+ });
274
+
275
+ test("isDirectedAtAgent: a DM I am NOT in is NOT mine (org feed is org-wide)", () => {
276
+ // /v1/events is org-scoped, so an agent sees other people's DM events too.
277
+ // Reacting to those would be barging into private conversations.
278
+ const mine = new Set(["dm-human-agent"]);
279
+ const someoneElses = hqDmEvent({ channelId: "dm-human-other" });
280
+ assert.equal(isDirectedAtAgent(someoneElses, "agent-self", { myChannelIds: mine }), false);
281
+ });
282
+
283
+ test("isDirectedAtAgent: without membership context it stays CONSERVATIVE on DMs", () => {
284
+ // Guessing "yes" without knowing the roster is the dangerous direction.
285
+ assert.equal(isDirectedAtAgent(hqDmEvent(), "agent-self"), false);
286
+ });
287
+
288
+ test("isDirectedAtAgent: my own echo in my own DM is still ignored", () => {
289
+ const mine = new Set(["dm-human-agent"]);
290
+ const echo = hqDmEvent({ actor: "agent-self" });
291
+ echo.actor = "agent-self";
292
+ assert.equal(isDirectedAtAgent(echo, "agent-self", { myChannelIds: mine }), false);
293
+ });
294
+
295
+ test("isDirectedAtAgent: a PUBLIC channel message in my room is NOT directed on its own", () => {
296
+ // Membership in a public channel is not an invitation to answer everything;
297
+ // that needs an @mention (which the redacted payload cannot yet express —
298
+ // hq emits mentionCount, not the ids).
299
+ const mine = new Set(["general"]);
300
+ const pub = hqDmEvent({ channelId: "general", channelKind: "PUBLIC" });
301
+ assert.equal(isDirectedAtAgent(pub, "agent-self", { myChannelIds: mine }), false);
302
+ });
303
+
304
+ test("isDirectedAtAgent: an explicit mentions[] still wins anywhere", () => {
305
+ const mine = new Set(["general"]);
306
+ const pub = hqDmEvent({ channelId: "general", channelKind: "PUBLIC", mentions: ["agent-self"] });
307
+ assert.equal(isDirectedAtAgent(pub, "agent-self", { myChannelIds: mine }), true);
308
+ });
309
+
310
+ test("isDirectedAtAgent: a call event in my room is an invite", () => {
311
+ const mine = new Set(["room-1"]);
312
+ const ev = { family: "calling", kind: "start", actor: "human-1",
313
+ payload: { channelId: "room-1", topic: "standup" } };
314
+ assert.equal(isDirectedAtAgent(ev, "agent-self", { myChannelIds: mine }), true);
315
+ });
316
+
317
+ test("isDirectedAtAgent: the SHARED feed never names who was mentioned", () => {
318
+ // hq's chain payload is a REDACTED audit row read by every agent holding
319
+ // `org.read`, so it carries `mentionCount` and deliberately NOT the member
320
+ // ids — listing them would leak the association graph of private rooms. This
321
+ // filter therefore cannot resolve a space @mention on its own, BY DESIGN;
322
+ // pullInbound confirms candidates against the per-member `messaging.history`
323
+ // read instead. Asserted so nobody "fixes" the deafness by widening the feed.
324
+ const shared = {
325
+ family: "messaging", kind: "send", entityId: "msg_1", actor: "human-1",
326
+ payload: { channelId: "space-general", channelKind: "PUBLIC", mentionCount: 1 },
327
+ };
328
+ assert.equal(isDirectedAtAgent(shared, "agent-self"), false);
329
+ });
330
+
331
+ test("isDirectedAtAgent: an explicit mentions[] still wins when a producer supplies one", () => {
332
+ // hq's chain payload used to carry `mentionCount` — a NUMBER — so an agent
333
+ // tailing the feed could see that a mention happened but not that it was the
334
+ // one mentioned. Combined with hq deferring to live daemons, an @mention in a
335
+ // public space reached nobody: the server stayed quiet and the agent was deaf.
336
+ // hq now emits `mentions: [memberId…]`, which this filter already understood.
337
+ const ev = {
338
+ seq: 10500,
339
+ family: "messaging",
340
+ kind: "send",
341
+ entityId: "msg_1",
342
+ actor: "human-1",
343
+ payload: {
344
+ channelId: "space-general",
345
+ channelKind: "PUBLIC",
346
+ mentions: ["agent-self"],
347
+ mentionCount: 1,
348
+ recipientCount: 12,
349
+ },
350
+ };
351
+ // Membership is NOT required for a mention — being named is the invitation.
352
+ assert.equal(isDirectedAtAgent(ev, "agent-self"), true);
353
+ // …and someone else's mention in the same space is still not ours.
354
+ assert.equal(isDirectedAtAgent(ev, "agent-other"), false);
355
+ });