@cohortapp/agent-sdk 2.3.0 → 2.3.2

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.
@@ -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,90 @@ 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);
433
+ const directed = isDirectedAtAgent(ev, me, { myChannelIds });
400
434
  if (!directed) continue;
401
435
  events.push(toMessageEvent(ev, me));
402
436
  }
403
- return { events, nextCursor: raw.length ? maxSeq : cursor };
437
+
438
+ // HYDRATE THE BODY. The org event feed is redacted by design — it carries
439
+ // {actor, channelId, channelKind, counts, ts} and NO message text — so a
440
+ // projected event arrives with `text: ""`. An empty inbound is worse than no
441
+ // inbound: the classifier sees nothing to classify and the agent would
442
+ // "reply" to a blank message.
443
+ //
444
+ // The body is fetched over `messaging.history`, which is ACL'd server-side to
445
+ // rooms this agent belongs to — so hydration cannot widen what the agent can
446
+ // see, it only re-joins content the agent was always entitled to read. One
447
+ // fetch per affected channel, not per message.
448
+ //
449
+ // Fail-open: a message we cannot hydrate is dropped rather than delivered
450
+ // blank, because a blank inbound produces a nonsense reply in a real room.
451
+ const needBody = events.filter((e) => !e.text && e.kind !== "call");
452
+ if (needBody.length) {
453
+ const byChannel = new Map();
454
+ for (const e of needBody) {
455
+ if (!e.channel_id) continue;
456
+ if (!byChannel.has(e.channel_id)) byChannel.set(e.channel_id, []);
457
+ byChannel.get(e.channel_id).push(e);
458
+ }
459
+ for (const [channelId, pending] of byChannel) {
460
+ try {
461
+ const history = await fetchHistory(
462
+ { channel: channelId, limit: 50 },
463
+ { cfg: o.cfg, fetchImpl: o.fetchImpl },
464
+ );
465
+ const byId = new Map(
466
+ (Array.isArray(history) ? history : [])
467
+ .map((m) => [String((m && (m.id || m.messageId)) || ""), m])
468
+ .filter(([id]) => id),
469
+ );
470
+ for (const e of pending) {
471
+ const hit = byId.get(String(e.message_id));
472
+ if (!hit) continue;
473
+ e.text = String(hit.body ?? hit.text ?? "");
474
+ if (!e.from.name || e.from.name === e.from.id) {
475
+ e.from.name = String(hit.authorName || hit.author?.displayName || e.from.name || "");
476
+ }
477
+ }
478
+ } catch {
479
+ /* fail-open: leave these unhydrated; they are dropped just below */
480
+ }
481
+ }
482
+ }
483
+
484
+ const delivered = events.filter((e) => e.kind === "call" || (e.text && e.text.trim()));
485
+ return { events: delivered, nextCursor: raw.length ? maxSeq : cursor };
404
486
  }
405
487
 
406
488
  /**
407
489
  * Decide whether an org event is directed at this agent (an inbound it should
408
490
  * react to) vs ambient chatter / its own echo. Pure.
409
- * @param {object} ev @param {string} me agent id
491
+ *
492
+ * WHY THIS NEEDS `myChannelIds`. The `/v1/events` feed is ORG-scoped, not
493
+ * member-scoped — an agent sees every messaging event in the org — and the
494
+ * payload is deliberately REDACTED: hq emits only
495
+ * { actor, channelId, channelKind, recipientCount, mentionCount, ts }
496
+ * with no body and, crucially, NO recipient list. That redaction is correct
497
+ * (an org-wide feed must not leak who DMs whom), but it means "is this for me?"
498
+ * cannot be answered from the event alone. The agent's OWN channel membership
499
+ * is the missing half, and the agent already knows it (`messaging.channels`).
500
+ *
501
+ * This bit in production: a human DM'd an agent six times and the agent never
502
+ * saw a single message. The old code looked for a `to` array the payload never
503
+ * carries, then fell back to `p.kind` containing "dm" — but the event's kind is
504
+ * "send" and the DM-ness lives in `channelKind: "DM"`, which nothing inspected.
505
+ * Every human DM was silently dropped.
506
+ *
507
+ * Without `myChannelIds` this stays CONSERVATIVE for DMs (returns false rather
508
+ * than guessing), because the failure mode of guessing yes is an agent barging
509
+ * into other people's private conversations.
510
+ *
511
+ * @param {object} ev
512
+ * @param {string} me agent id
513
+ * @param {{myChannelIds?: Set<string>|string[]}} [opts]
410
514
  * @returns {boolean}
411
515
  */
412
- export function isDirectedAtAgent(ev, me) {
516
+ export function isDirectedAtAgent(ev, me, opts = {}) {
413
517
  if (!ev || typeof ev !== "object") return false;
414
518
  const family = ev.family || (ev.kind ? String(ev.kind).split(".")[0] : "");
415
519
  if (family && family !== "messaging" && family !== "calling") return false;
@@ -423,21 +527,46 @@ export function isDirectedAtAgent(ev, me) {
423
527
  .map((m) => String(m));
424
528
  if (me && mentions.includes(String(me))) return true;
425
529
 
426
- // A DM / direct call-invite addressed to this agent is directed even with no
427
- // explicit @mention.
530
+ // An explicit recipient list, when a producer supplies one.
428
531
  const to = []
429
532
  .concat(Array.isArray(p.to) ? p.to : (p.to != null ? [p.to] : []))
430
533
  .concat(Array.isArray(p.invitees) ? p.invitees : [])
431
534
  .map((t) => String(t));
432
535
  if (me && to.includes(String(me))) return true;
433
536
 
537
+ // The REAL hq shape: a private room I am a member of. Membership is what makes
538
+ // it mine — in a DM/group-DM/huddle every message is addressed to the room,
539
+ // so no @mention is required.
540
+ const mine = normaliseChannelIds(opts.myChannelIds);
541
+ const channelId = String(p.channelId || p.channel || p.callId || "");
542
+ const channelKind = String(p.channelKind || p.channel_kind || "").toUpperCase();
543
+ const PRIVATE_ROOMS = ["DM", "GROUP_DM", "HUDDLE"];
544
+ if (mine && channelId && mine.has(channelId)) {
545
+ if (PRIVATE_ROOMS.includes(channelKind)) return true;
546
+ // A call event in one of my rooms is an invite I should react to.
547
+ if (family === "calling") return true;
548
+ }
549
+
550
+ // Legacy/other producers that put the room kind in `kind` rather than
551
+ // `channelKind`. Unchanged behaviour for them.
434
552
  const kind = String(p.kind || ev.kind || "").toLowerCase();
435
- if (kind.includes("dm") || kind.includes("invite") || family === "calling") {
553
+ if (kind.includes("dm") || kind.includes("invite")) {
554
+ return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
555
+ }
556
+ // A bare `calling` event with no room context: only when we cannot scope it.
557
+ if (family === "calling" && !mine) {
436
558
  return !me || to.includes(String(me)) || mentions.includes(String(me)) || to.length === 0;
437
559
  }
438
560
  return false;
439
561
  }
440
562
 
563
+ /** Accept a Set, an array, or nothing; null means "membership unknown". */
564
+ function normaliseChannelIds(v) {
565
+ if (v instanceof Set) return v;
566
+ if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
567
+ return null;
568
+ }
569
+
441
570
  /**
442
571
  * Project an org messaging/calling event onto the unified MessageEvent contract
443
572
  * (lib/channels/contract.MessageEvent) so eventToInboxItem can serialise it into
@@ -236,3 +236,80 @@ 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
+ });
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.3.0",
4
- "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
3
+ "version": "2.3.2",
4
+ "description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "agent-sdk": "./bin/maestro.mjs",
7
8
  "maestro": "./bin/maestro.mjs",
8
9
  "cohort": "./bin/maestro.mjs",
9
10
  "cohort-mcp": "./bin/cohort-mcp.mjs",
@@ -120,19 +120,46 @@ function logEvent(type, entry) {
120
120
  // ---------------------------------------------------------------------------
121
121
 
122
122
  async function poll() {
123
- const services = [
124
- { name: "slack", fn: pollSlack },
125
- { name: "gmail", fn: pollGmail },
126
- { name: "alex-gmail", fn: pollSecondaryGmail },
127
- { name: "calendar", fn: pollCalendar },
128
- { name: "voice", fn: pollVoice },
123
+ // COHORT IS THE DEFAULT SUBSTRATE. An agent's messaging, email, calendar,
124
+ // CRM, directory, spaces, boards, books and calls all live on the Cohort
125
+ // server. The third-party adapters below are LEGACY BRIDGES for agents that
126
+ // additionally sit in someone else's Slack or Gmail — they are not what a new
127
+ // agent should be doing by default.
128
+ //
129
+ // They used to be polled unconditionally, which meant every freshly-created
130
+ // agent burned a poll cycle on Slack, two Gmail accounts, Google Calendar and
131
+ // voice on a loop, and filled its log with "SLACK_TOKEN not set" /
132
+ // "App password not set" forever. Worse, the noise made a REAL failure
133
+ // (Cohort inbound not wired at all) invisible in the same log.
134
+ //
135
+ // Each is now gated on evidence that it is actually configured, exactly like
136
+ // the channel-bus platforms below. Nothing is removed — an agent that IS on
137
+ // Slack keeps working the moment its token exists.
138
+ const services = [];
139
+ const legacyBridges = [
140
+ { name: "slack", fn: pollSlack, on: () => Boolean(process.env.SLACK_TOKEN || process.env.SLACK_USER_TOKEN) },
141
+ { name: "gmail", fn: pollGmail, on: () => Boolean(process.env.GMAIL_APP_PASSWORD) },
142
+ { name: "alex-gmail", fn: pollSecondaryGmail, on: () => Boolean(process.env.SECONDARY_GMAIL_APP_PASSWORD) },
143
+ { name: "calendar", fn: pollCalendar, on: () => _existsSync(join(AGENT_REPO_DIR, "config/google-calendar.yaml")) || Boolean(process.env.GOOGLE_CALENDAR_CREDENTIALS) },
144
+ { name: "voice", fn: pollVoice, on: () => _existsSync(join(AGENT_REPO_DIR, "config/voice.yaml")) || Boolean(process.env.TWILIO_ACCOUNT_SID) },
129
145
  ];
146
+ for (const b of legacyBridges) {
147
+ let enabled = false;
148
+ try { enabled = b.on(); } catch { enabled = false; }
149
+ if (enabled) services.push({ name: b.name, fn: b.fn });
150
+ }
130
151
 
131
152
  // WS2: channel-bus platforms (Telegram, Baileys WhatsApp) deliver events as
132
153
  // inbox YAML via the daemon's channel loop. They have no API poller, so a
133
154
  // generic inbox scanner drains their items into the same pipeline. Gate on
134
155
  // the platform's config file so we don't scan dirs for disabled channels.
135
- for (const [name, gate] of [["telegram", "config/telegram.yaml"], ["whatsapp", "config/whatsapp.yaml"], ["orgmail", "config/orgmail.yaml"]]) {
156
+ // `cohort` is the SAME shape: the messaging-inbound cadence pulls org
157
+ // messages / @mentions / call-invites directed at this agent and writes them
158
+ // as inbox YAML under state/inbox/cohort/ — but nothing drained that
159
+ // directory, so the items simply accumulated and the agent never answered.
160
+ // The whole SP10 inbound chain existed except this last hop. Gated on org
161
+ // enrolment (config/org.yaml) exactly like the other channel sources.
162
+ for (const [name, gate] of [["telegram", "config/telegram.yaml"], ["whatsapp", "config/whatsapp.yaml"], ["orgmail", "config/orgmail.yaml"], ["cohort", "config/org.yaml"]]) {
136
163
  if (_existsSync(join(AGENT_REPO_DIR, gate))) {
137
164
  services.push({ name, fn: makeInboxScanPoller(name, { agentRoot: AGENT_REPO_DIR }) });
138
165
  }
@@ -48,6 +48,7 @@ import { fileURLToPath } from "node:url";
48
48
  import { spawn } from "node:child_process";
49
49
  import { recoverStaleClaims } from "../../lib/cadence-bus.mjs";
50
50
  import { isQuarterStartMonth } from "../../lib/cadences.mjs";
51
+ import { loadOrgConfig } from "../../lib/org/client.mjs";
51
52
 
52
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
53
54
 
@@ -389,13 +390,40 @@ async function guardQuarterlySelfAssessment({ now } = {}) {
389
390
 
390
391
  /** Read config/agent.json fail-open (for the org legs). Returns null on any error. */
391
392
  function loadAgentCfg(agentRoot) {
393
+ let agent = null;
392
394
  try {
393
395
  const p = join(agentRoot, "config", "agent.json");
394
- if (!existsSync(p)) return null;
395
- return JSON.parse(readFileSync(p, "utf8"));
396
+ if (existsSync(p)) agent = JSON.parse(readFileSync(p, "utf8"));
396
397
  } catch {
397
- return null;
398
+ agent = null;
399
+ }
400
+
401
+ // ORG ENROLMENT LIVES IN config/org.yaml, NOT config/agent.json.
402
+ //
403
+ // Every org-facing guard below gates on `cfg.org.cohort` and bails out with
404
+ // "org messaging not configured" when it is absent. `config/agent.json` has no
405
+ // `org` key at all — enrolment is written to `config/org.yaml` by
406
+ // `maestro setup` and read by lib/org/client.loadOrgConfig — so all SIX of
407
+ // those guards were dead on every enrolled agent, silently and permanently.
408
+ //
409
+ // Observed in production: a human DM'd an agent repeatedly and the
410
+ // messaging-inbound cadence ran ~every 45s, each time deciding "org messaging
411
+ // not configured" and routing nothing. The agent looked healthy the whole time.
412
+ //
413
+ // This is the SAME bug class loadOrgConfig was introduced to fix ("the daemon
414
+ // reading collective.yaml by mistake") — fixed there, missed here. Merging at
415
+ // this one chokepoint fixes every guard at once rather than per-guard.
416
+ let org = null;
417
+ try {
418
+ org = loadOrgConfig(agentRoot);
419
+ } catch {
420
+ org = null;
421
+ }
422
+
423
+ if (org && org.org) {
424
+ return { ...(agent || {}), org: { ...((agent && agent.org) || {}), ...org.org } };
398
425
  }
426
+ return agent;
399
427
  }
400
428
 
401
429
  /**
@@ -633,7 +661,17 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
633
661
  const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
634
662
 
635
663
  const cursor = readMessagingCursor(agentRoot);
636
- const res = await pull({ cfg, cursor, fetchImpl: opts.fetchImpl });
664
+ // Pass the agent id explicitly. It is what suppresses the agent's OWN
665
+ // outbound echoes — without it `me` is null, every echo-filter branch is
666
+ // skipped, and the agent re-ingests its own replies as fresh inbound (a
667
+ // conversation with itself). config/org.yaml does not carry an agentId, so
668
+ // env is the reliable source on a real machine.
669
+ const agentId =
670
+ opts.agentId ||
671
+ process.env.COHORT_AGENT_ID ||
672
+ (cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
673
+ undefined;
674
+ const res = await pull({ cfg, agentId, cursor, fetchImpl: opts.fetchImpl });
637
675
  const events = (res && Array.isArray(res.events)) ? res.events : [];
638
676
 
639
677
  let routed = 0;
@@ -33,6 +33,29 @@ const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
33
33
  process.env.AGENT_DIR = AGENT_DIR;
34
34
  process.env.AGENT_ROOT = AGENT_DIR;
35
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // 0. Environment — BEFORE anything reads a credential
38
+ // ---------------------------------------------------------------------------
39
+ // The core daemon (agent-daemon.mjs, step 4) loads .env itself, but the org
40
+ // mesh connects at step 3b' — EARLIER in this file. Under launchd the process
41
+ // environment is bare, so the mesh was reading COHORT_API_KEY before anything
42
+ // had put it there.
43
+ //
44
+ // That failed SILENTLY and looked healthy: `enabled` comes from config/org.yaml
45
+ // (which should NOT carry the token — it is version-controlled), so the mesh
46
+ // started, logged "connected", and then 401'd on every beat forever. An agent
47
+ // that never beats also never receives the kill switch.
48
+ //
49
+ // Loading .env here, before any org work, is the fix. Idempotent: dotenv does
50
+ // not override variables already set, so a launchd/plist-provided value still
51
+ // wins. Fail-open — dotenv is absent in some CI/unit contexts.
52
+ try {
53
+ const { config } = await import("dotenv");
54
+ config({ path: join(AGENT_DIR, ".env") });
55
+ } catch {
56
+ // No dotenv (CI/dev): rely on the ambient environment.
57
+ }
58
+
36
59
  // Never die silently (P0-6): a stray unhandled rejection / uncaught exception is
37
60
  // logged + counted, not a silent process death. Rejections keep the daemon
38
61
  // running; an uncaught exception logs+flushes then lets launchd restart us.
@@ -684,6 +684,52 @@ function resolveSlackChannel(item) {
684
684
  *
685
685
  * @returns {{ sent: boolean, text: string, channel: string }} result
686
686
  */
687
+
688
+ /**
689
+ * Send a reply back onto Cohort (the org's own messaging app).
690
+ *
691
+ * The responder dispatched on `item.service` and only ever knew "slack" and
692
+ * "gmail", so a `cohort` item fell through to `{ sent: false }`: the agent
693
+ * COMPOSED a reply and dropped it on the floor, logging "not_sent". Observed in
694
+ * production — an agent quietly did that to a human's DMs three times in a row.
695
+ *
696
+ * Routing is by CHANNEL, not by person: `channel_id` is the room the inbound
697
+ * arrived in, which is also where the answer belongs (a DM room for a DM, the
698
+ * space for a space). Threading is preserved when the inbound was threaded.
699
+ *
700
+ * Goes through lib/org/messaging.sendMessage rather than the raw RPC so the
701
+ * outbound send-gate, the afterSend hook and cost attribution all still run —
702
+ * the same chokepoint every other channel uses.
703
+ */
704
+ async function sendCohortReply(item, text) {
705
+ try {
706
+ const channel = item.channel_id || item.channel || "";
707
+ if (!channel) return { sent: false, via: null, error: "no channel_id on item" };
708
+ const { sendMessage } = await import("../../lib/org/messaging.mjs");
709
+ const { loadOrgConfig } = await import("../../lib/org/client.mjs");
710
+ const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
711
+ const frame = await sendMessage(
712
+ {
713
+ channel,
714
+ body: text,
715
+ // Stable per (message, kind) so a retry dedupes server-side instead of
716
+ // double-posting into a real room.
717
+ idempotencyId: `reply-${item.message_id || item.id || Date.now()}`,
718
+ ...(item.thread_id ? { threadId: item.thread_id } : {}),
719
+ },
720
+ { cfg: loadOrgConfig(agentRoot), agentRoot },
721
+ );
722
+ if (frame && frame.ok) return { sent: true, via: "cohort", channel };
723
+ return {
724
+ sent: false,
725
+ via: null,
726
+ error: (frame && frame.error && frame.error.message) || "send failed",
727
+ };
728
+ } catch (err) {
729
+ return { sent: false, via: null, error: err && err.message };
730
+ }
731
+ }
732
+
687
733
  export async function sendQuickResponse(item, classResult) {
688
734
  const startTime = Date.now();
689
735
 
@@ -745,6 +791,13 @@ export async function sendQuickResponse(item, classResult) {
745
791
  emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "gmail", via: gmailResult.via } });
746
792
  logInteraction(item, text);
747
793
  }
794
+ } else if (item.service === "cohort") {
795
+ const orgResult = await sendCohortReply(item, text);
796
+ sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.channel ? { channel: orgResult.channel } : {}), ...(orgResult.error ? { error: orgResult.error } : {}) };
797
+ if (orgResult.sent) {
798
+ emitEvent({ type: EVENT_TYPES.SENT, trace_id: item.trace_id || undefined, attrs: { service: "cohort", channel: orgResult.channel, via: "quick_reply" } });
799
+ logInteraction(item, text);
800
+ }
748
801
  }
749
802
 
750
803
  const duration = Date.now() - startTime;
@@ -802,6 +855,9 @@ export async function sendHoldingMessage(item, classResult) {
802
855
  } else if (item.service === "gmail") {
803
856
  const gmailResult = await sendGmailResponse(item, text);
804
857
  sendResult = { sent: gmailResult.sent, via: gmailResult.via, ...(gmailResult.draft_path ? { draft_path: gmailResult.draft_path } : {}), ...(gmailResult.to ? { to: gmailResult.to } : {}) };
858
+ } else if (item.service === "cohort") {
859
+ const orgResult = await sendCohortReply(item, text);
860
+ sendResult = { sent: orgResult.sent, via: orgResult.via, ...(orgResult.error ? { error: orgResult.error } : {}) };
805
861
  }
806
862
 
807
863
  const duration = Date.now() - startTime;
@@ -57,6 +57,19 @@ export function parseInboxItemYaml(body) {
57
57
  const m = body.match(new RegExp(`^\\s{2}${key}:\\s*(\\S+)`, "m"));
58
58
  return /true/i.test((m || [])[1] || "");
59
59
  };
60
+ // TRI-STATE. The confidentiality fields must distinguish "absent" from
61
+ // "false": `is_private: false` is a positive assertion that the source is
62
+ // public, and the daemon's fail-closed outcome gate
63
+ // (scripts/daemon/session-outcomes.mjs) admits on it. Decoding a missing line
64
+ // as `false` — which `bool()` does — would forge that assertion for every
65
+ // item ever written, which is exactly the leak the gate exists to prevent.
66
+ const triBool = (key) => {
67
+ const m = body.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"));
68
+ if (!m) return undefined;
69
+ if (/^true$/i.test(m[1])) return true;
70
+ if (/^false$/i.test(m[1])) return false;
71
+ return undefined;
72
+ };
60
73
 
61
74
  // Block scalar `content: |` — capture the indented block.
62
75
  const blockScalar = (key) => {
@@ -66,7 +79,11 @@ export function parseInboxItemYaml(body) {
66
79
  return m[1].replace(/^ {2}/gm, "").replace(/\n+$/, "");
67
80
  };
68
81
 
69
- return {
82
+ const channelType = scalar("channel_type");
83
+ const isPrivate = triBool("is_private");
84
+ const isDm = triBool("is_dm");
85
+
86
+ const item = {
70
87
  id: scalar("id"),
71
88
  service: scalar("service"),
72
89
  channel: scalar("channel"),
@@ -88,6 +105,14 @@ export function parseInboxItemYaml(body) {
88
105
  },
89
106
  raw_ref: scalar("raw_ref"),
90
107
  };
108
+
109
+ // Only attached when the writer actually knew (see triBool) — an item with no
110
+ // privacy signal must stay signal-less so the outcome gate fails closed.
111
+ if (channelType) item.channel_type = channelType;
112
+ if (isPrivate !== undefined) item.is_private = isPrivate;
113
+ if (isDm !== undefined) item.is_dm = isDm;
114
+
115
+ return item;
91
116
  }
92
117
 
93
118
  /**