@cohortapp/agent-sdk 2.11.0 → 2.11.1

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.
@@ -182,6 +182,18 @@ export function eventToInboxItem(ev) {
182
182
  if (typeof ev.is_private === "boolean") item.is_private = ev.is_private;
183
183
  if (typeof ev.is_dm === "boolean") item.is_dm = ev.is_dm;
184
184
 
185
+ // THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / PRIVATE / …). Cohort carries this
186
+ // on its org event feed (lib/org/messaging.toMessageEvent), and it is the ONLY
187
+ // reliable is_dm signal for Cohort, whose channel ids are cuids with no
188
+ // Slack-style "D…"/"dm/" prefix for the daemon's `enrichItem` to key off. The
189
+ // signal was being DROPPED here (and again by the YAML writer/reader), so a
190
+ // Cohort 1:1 DM arrived at the election gate looking like an ambient group
191
+ // message and was suppressed. Serialised only when present, so every non-Cohort
192
+ // producer keeps a byte-identical inbox item (see the byte-compat fixture).
193
+ if (typeof ev.channel_kind === "string" && ev.channel_kind) {
194
+ item.channel_kind = sanitizeYamlField(ev.channel_kind);
195
+ }
196
+
185
197
  // Only stamp `kind` when it's not the default. Existing message items must
186
198
  // remain byte-identical to what the Slack socket-mode writer produced before
187
199
  // this seam existed (it never wrote a `kind` field).
@@ -59,6 +59,39 @@ test("eventToInboxItem: channel_cc kind is stamped", () => {
59
59
  assert.equal(item.kind, "channel_cc");
60
60
  });
61
61
 
62
+ test("eventToInboxItem: channel_kind is carried through when present, omitted when absent", () => {
63
+ // Present → serialised (Cohort's only reliable is_dm signal), and the poll-lane
64
+ // parser reads it back, so enrichItem actually receives it.
65
+ const withKind = eventToInboxItem({
66
+ channel: "cohort",
67
+ message_id: "m1",
68
+ channel_kind: "DM",
69
+ from: { id: "human-1", name: "Human" },
70
+ text: "hi",
71
+ timestamp: "2026-01-01T00:00:00.000Z",
72
+ });
73
+ assert.equal(withKind.channel_kind, "DM");
74
+
75
+ // Absent → omitted entirely, so a non-Cohort item stays byte-identical.
76
+ const withoutKind = eventToInboxItem({
77
+ channel: "slack",
78
+ message_id: "m2",
79
+ from: { id: "U1", name: "casey" },
80
+ text: "hi",
81
+ timestamp: "2026-01-01T00:00:00.000Z",
82
+ });
83
+ assert.equal("channel_kind" in withoutKind, false);
84
+ });
85
+
86
+ test("parseInboxItemYaml: channel_kind survives the YAML round-trip (present/absent)", () => {
87
+ const withKind = parseInboxItemYaml(
88
+ 'id: "m1"\nservice: "cohort"\nchannel_id: "cuid_abc"\nchannel_kind: "GROUP_DM"\ncontent: |\n hi\n',
89
+ );
90
+ assert.equal(withKind.channel_kind, "GROUP_DM");
91
+ const withoutKind = parseInboxItemYaml('id: "m2"\nservice: "slack"\ncontent: |\n hi\n');
92
+ assert.equal("channel_kind" in withoutKind, false, "absent line → no channel_kind key");
93
+ });
94
+
62
95
  // ---------------------------------------------------------------------------
63
96
  // round-trip event ↔ item
64
97
  // ---------------------------------------------------------------------------
@@ -392,8 +392,19 @@ export function decide(o = {}) {
392
392
  if (o.history && base.actor) {
393
393
  const hits = o.history.actorActivity(base.actor);
394
394
  if (hits >= floodLimit) {
395
- why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
396
- return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
395
+ // A DIRECTED 1:1 DM is EXEMPT. The flood counter tallies ALL of an actor's
396
+ // activity in the window — including their own doc/board/decision events,
397
+ // which fire far more often than they message — so a person's genuine 1:1
398
+ // message would be downgraded to "batch the reply" merely because their
399
+ // unrelated document edits tripped the limit. A 1:1 DM is the one surface
400
+ // where the human is unambiguously talking TO the seat and watching for a
401
+ // reply, so it stays react-now; every other surface still batches.
402
+ if (base.surface === "dm") {
403
+ why.push(`${base.actor} is over the flood limit (${hits}/${floodLimit}), but this is a directed 1:1 DM → not batched`);
404
+ } else {
405
+ why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
406
+ return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
407
+ }
397
408
  }
398
409
  }
399
410
 
@@ -256,15 +256,32 @@ test("IGNORE: a malformed event never crashes the ladder", () => {
256
256
  // ───────────────────────────────────────────────────────────────────────────
257
257
 
258
258
  test("DOWNGRADE not silence: a flooding actor gets batched, not muted", () => {
259
- const d = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT }) });
259
+ // Assert on a NON-DM surface (an @mention): a directed 1:1 DM is now EXEMPT
260
+ // from actor-flood (see the next test), so it can no longer stand in for the
261
+ // general batching behaviour.
262
+ const floodMention = (over) => run({
263
+ verdict: yes("mention", "mention"),
264
+ candidate: { family: "messaging", kind: "send", surfaces: ["mention"] },
265
+ ...over,
266
+ });
267
+ const d = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT }) });
260
268
  assert.equal(d.disposition, "schedule");
261
269
  assert.equal(d.reason, "actor_flood");
262
270
  assert.equal(d.batched, true);
263
271
  // one under the limit still answers turn-by-turn
264
- const under = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT - 1 }) });
272
+ const under = floodMention({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT - 1 }) });
265
273
  assert.equal(under.disposition, "react_now");
266
274
  });
267
275
 
276
+ test("a directed 1:1 DM is EXEMPT from actor-flood batching", () => {
277
+ // The flood counter tallies ALL of an actor's activity (their doc/board edits
278
+ // included), so a genuine 1:1 message must not be batched just because the same
279
+ // person tripped the limit elsewhere. Even far over the limit, a DM reacts now.
280
+ const d = run({ history: freshHistory({ actorActivity: () => DEFAULT_ACTOR_FLOOD_LIMIT * 5 }) });
281
+ assert.equal(d.disposition, "react_now");
282
+ assert.equal(d.reason, "directed_now");
283
+ });
284
+
268
285
  test("DOWNGRADE not silence: an exhausted budget queues the reply", () => {
269
286
  for (const runtime of [{ dailyCapExhausted: true }, { remainingCents: 0 }]) {
270
287
  const d = run({ runtime });
@@ -157,7 +157,10 @@ test("a burst from one actor is batched rather than answered turn-by-turn", asyn
157
157
  const fx = fakeEffects();
158
158
  const items = [];
159
159
  for (let i = 0; i < 6; i += 1) {
160
- items.push(dm({ id: `cohort-msg_${i}`, raw_ref: `cohort:dm:msg_${i}:${100 + i}`, thread_id: `t_${i}`, channel_id: `c_${i}` }));
160
+ // A burst of @mentions (NOT DMs): a directed 1:1 DM is now exempt from
161
+ // actor-flood, so flood batching is exercised on a channel surface where
162
+ // it still applies. `is_dm:false` + mentions_agent → surface "mention".
163
+ items.push(dm({ id: `cohort-msg_${i}`, raw_ref: `cohort:mention:msg_${i}:${100 + i}`, thread_id: `t_${i}`, channel_id: `c_${i}`, is_dm: false }));
161
164
  }
162
165
  const { results } = await run(items, { agentRoot: root, effects: fx, actorFloodLimit: 3 });
163
166
  const dispositions = results.map((r) => r.decision.disposition);
@@ -859,6 +859,11 @@ export function toMessageEvent(ev, me) {
859
859
  kind: isCall ? "call" : "message",
860
860
  subject: isCall ? "Cohort call invite" : "Cohort message",
861
861
  channel_id: channelId,
862
+ // The org event feed carries channelKind (DM/GROUP_DM/PUBLIC/…); surface it
863
+ // so the daemon's is_dm detection works for Cohort (whose channel ids are
864
+ // cuids, not Slack-style "D…"/"dm/…" prefixes). Without this a Cohort DM was
865
+ // misread as a group message and wrongly routed through the election gate.
866
+ channel_kind: String(p.channelKind || p.channel_kind || "").toUpperCase(),
862
867
  channel_label: String(p.channelName || channelId || "cohort"),
863
868
  sender: String(p.fromName || fromId),
864
869
  priority_signals: {
@@ -248,6 +248,13 @@ test("toMessageEvent: projects a messaging event onto the MessageEvent contract"
248
248
  assert.equal(ev.ingest_source, "cohort");
249
249
  });
250
250
 
251
+ test("toMessageEvent: projects channelKind so the daemon can tell a DM from a GROUP_DM", () => {
252
+ const dm = toMessageEvent({ family: "messaging", seq: 8, payload: { id: "m1", channel: "dm-x", channelKind: "DM", from: "human-1", body: "hi" } }, "me");
253
+ assert.equal(dm.channel_kind, "DM", "a 1:1 DM carries channel_kind DM (→ is_dm true downstream)");
254
+ const group = toMessageEvent({ family: "messaging", seq: 9, payload: { id: "m2", channel: "gdm-x", channelKind: "group_dm", from: "human-1", body: "hi all" } }, "me");
255
+ assert.equal(group.channel_kind, "GROUP_DM", "a GROUP_DM is upper-cased and NOT a 1:1 DM downstream");
256
+ });
257
+
251
258
  test("pullInbound: filters to directed events and advances the cursor to max seq", async () => {
252
259
  const f = fakeFetch(() => ({
253
260
  body: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.0",
3
+ "version": "2.11.1",
4
4
  "description": "Cohort Agent SDK — 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": {
@@ -69,6 +69,7 @@ import {
69
69
  sweepObligations,
70
70
  openObligations,
71
71
  escalate,
72
+ closeObligation,
72
73
  } from "./assurance.mjs";
73
74
  import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
74
75
  import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
@@ -326,7 +327,29 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
326
327
  export function enrichItem(item, service) {
327
328
  const channelStr = (item.channel || "").toLowerCase();
328
329
  const channelId = item.channel_id || "";
329
- const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
330
+ // Cohort carries the channel kind explicitly (DM/GROUP_DM/…); its channel ids
331
+ // are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never fire. When
332
+ // the kind is known it is AUTHORITATIVE:
333
+ // - DM → a 1:1 DM: is_dm true, so it skips the group election gate.
334
+ // - GROUP_DM → a multi-participant channel: is_dm FALSE, so an UNDIRECTED
335
+ // message goes through the election exactly like any other
336
+ // channel (only ONE seat should answer an ambient group DM).
337
+ // - any other explicit kind (PUBLIC/PRIVATE/…) → not a 1:1 DM.
338
+ // When the kind is ABSENT we do NOT immediately reach for the prefix
339
+ // heuristics: the DEFAULT (wide) inbound reader — `lib/org/inbound/project.mjs`
340
+ // toMessageEvent — projects a Cohort 1:1 DM with `is_dm:true`/`channel_type:'dm'`
341
+ // but does NOT emit `channel_kind`. That boolean survives the whole
342
+ // eventToInboxItem → writeInboxItem → parseInboxItemYaml chain, so HONOR it
343
+ // before falling back. Overwriting it with the cuid-blind prefix heuristics
344
+ // (which never match a Cohort id) is exactly what sent a genuine 1:1 DM through
345
+ // the group election gate and suppressed it. Precedence: explicit kind →
346
+ // pre-existing boolean is_dm → Slack-style id/label prefix heuristics.
347
+ const channelKind = String(item.channel_kind || "").toUpperCase();
348
+ const isDm = channelKind
349
+ ? channelKind === "DM"
350
+ : typeof item.is_dm === "boolean"
351
+ ? item.is_dm
352
+ : channelStr.startsWith("dm/") || channelId.startsWith("D");
330
353
  const myFirstName = loadAgent().firstName || "Agent";
331
354
  const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
332
355
  const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
@@ -697,7 +720,44 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
697
720
  });
698
721
  return { ok: true, path: "quick_reply", reason: classResult.model };
699
722
  }
700
- // If quick reply failed to send or was blocked by validation, fall through to dispatch a full session
723
+ // PERMANENT SEND FAILURE — NEVER FALL THROUGH. A permanent failure
724
+ // (FORBIDDEN_SCOPE from the send-gate, NOT_FOUND/BAD_REQUEST from hq) means
725
+ // retrying changes nothing AND that a spawned session would fail to post the
726
+ // very same way. Worse, that session runs AUTONOMOUSLY: denied its intended
727
+ // reply, it improvises and posts UNRELATED work into a channel this seat was
728
+ // just told it may not write to. So we stop here — open the durable debt so
729
+ // the ask is not lost, write a needs-attention escalation an operator can
730
+ // sweep, close the obligation as failed so the assurance sweep does not loop
731
+ // on an impossible send, and retire the item so the poll lane stops
732
+ // re-delivering it. The requester is not told (the only channel we had was
733
+ // the forbidden one); the escalation is the trace that a human must act.
734
+ if (result.permanent) {
735
+ const cause = result.code || result.error || "permanent send failure";
736
+ console.error(`[daemon] Quick reply PERMANENTLY failed for ${item.sender} (${cause}) — NOT spawning a session; opening obligation + escalating`);
737
+ let obligationKey = itemId;
738
+ try {
739
+ const opened = await openAndAcknowledge({
740
+ item, classResult, service, traceId: trace_id, ack: false,
741
+ });
742
+ if (opened && opened.key) obligationKey = opened.key;
743
+ } catch (err) {
744
+ console.error(`[daemon] openAndAcknowledge threw for ${itemId} on permanent send failure: ${err.message}`);
745
+ }
746
+ escalate(
747
+ { key: obligationKey, sender: item.sender, service,
748
+ channel: item.channel_id || item.channel || null,
749
+ summary: classResult && classResult.summary, openedAt: Date.now(),
750
+ attempts: 1, sessionId: null, traceId: trace_id, lastError: cause,
751
+ item: { content: item.content } },
752
+ { failure: { label: cause }, told: false },
753
+ );
754
+ try { closeObligation(obligationKey, { outcome: "failed", note: cause }); } catch { /* best-effort */ }
755
+ counters.bump("send.permanent_failure", { service, code: result.code || "unknown" });
756
+ emitEvent({ type: EVENT_TYPES.ERROR, trace_id, attrs: { item_id: itemId, service, stage: "quick_reply_permanent", error: cause } });
757
+ markProcessed(item, service);
758
+ return { ok: true, path: "quick_reply_permanent", reason: cause };
759
+ }
760
+ // If quick reply failed transiently or was blocked by validation, fall through to dispatch a full session
701
761
  const reason = result.blocked ? `validation blocked: ${result.issues?.map(i => i.rule).join(", ")}` : "send failed";
702
762
  console.warn(`[daemon] Quick reply not sent (${reason}), falling through to session dispatch`);
703
763
  }
@@ -713,3 +713,120 @@ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (cla
713
713
  assert.equal(down.dispatched, true, "the session is still dispatched");
714
714
  assert.equal(res.path, "session", "answerItem took the session path");
715
715
  });
716
+
717
+ // ===========================================================================
718
+ // is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
719
+ // ===========================================================================
720
+
721
+ test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
722
+ // A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
723
+ // go through it. Cohort ids are cuids, so the kind is the only reliable signal.
724
+ const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
725
+ assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
726
+ const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
727
+ assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
728
+ const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
729
+ assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
730
+ // No kind → fall back to the Slack-style id/label prefix heuristics.
731
+ assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
732
+ assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
733
+ });
734
+
735
+ test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
736
+ // REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
737
+ // whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
738
+ // with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
739
+ // non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
740
+ // channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
741
+ // fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
742
+ // channel_kind or those (never-matching) heuristics and CLOBBERED the true
743
+ // is_dm to false — routing a genuine 1:1 DM through the group election gate,
744
+ // which then suppressed it. This drives the real chain end to end:
745
+ // toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
746
+ // and asserts is_dm survives as true.
747
+ const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
748
+ const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
749
+ const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
750
+ const { writeInboxItem } = await import("../poller/utils.mjs");
751
+ const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
752
+
753
+ const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
754
+ const cand = classifyEvent({
755
+ seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
756
+ family: "messaging", kind: "send", entity_id: "m501",
757
+ payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
758
+ });
759
+ const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
760
+ const mev = toMessageEvent({
761
+ candidate: cand,
762
+ verdict: { surface: "dm", reason: "dm", directed: true },
763
+ hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
764
+ me: "M-me",
765
+ facts,
766
+ });
767
+
768
+ // The premise the regression rests on: the wide projection marks the DM but
769
+ // does NOT emit channel_kind.
770
+ assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
771
+ assert.equal(mev.channel_type, "dm");
772
+ assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
773
+
774
+ // eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
775
+ const item = eventToInboxItem(mev);
776
+ assert.equal(item.is_dm, true);
777
+ assert.equal("channel_kind" in item, false);
778
+
779
+ // writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
780
+ writeInboxItem("cohort", item, AGENT_DIR);
781
+ const dir = join(AGENT_DIR, "state", "inbox", "cohort");
782
+ const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
783
+ assert.ok(file, "the inbox item was written to disk");
784
+ const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
785
+ assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
786
+ assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
787
+
788
+ // enrichItem is the last hop before the election gate. It MUST honor the
789
+ // pre-existing boolean is_dm when channel_kind is absent, not clobber it.
790
+ const enriched = daemon.enrichItem(parsed, "cohort");
791
+ assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
792
+ });
793
+
794
+ // ===========================================================================
795
+ // NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
796
+ // (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
797
+ // post UNRELATED work into a channel the seat may not write to.
798
+ // ===========================================================================
799
+
800
+ test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
801
+ resetState();
802
+ const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
803
+ let dispatched = false;
804
+ const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
805
+ classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
806
+ isQuickReply: () => true,
807
+ sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
808
+ dispatch: () => { dispatched = true; },
809
+ claudeAvailable: () => true,
810
+ });
811
+ assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
812
+ assert.equal(res.path, "quick_reply_permanent");
813
+ assert.match(res.reason, /FORBIDDEN_SCOPE/);
814
+ const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
815
+ assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
816
+ });
817
+
818
+ test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
819
+ resetState();
820
+ const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
821
+ let dispatched = false;
822
+ const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
823
+ classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
824
+ isQuickReply: () => true,
825
+ sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
826
+ sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
827
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
828
+ claudeAvailable: () => true,
829
+ });
830
+ assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
831
+ assert.equal(res.path, "session");
832
+ });
@@ -667,16 +667,32 @@ async function guardDynamicJobs({ event, agentRoot }, opts = {}) {
667
667
 
668
668
  const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
669
669
 
670
- /** Read the org-messaging inbound cursor (seq). Fail-open → 0 (from genesis). */
670
+ /**
671
+ * Read the org-messaging inbound cursor (seq).
672
+ *
673
+ * Returns `null` when there is NO cursor on disk (or it is corrupt/unreadable) —
674
+ * NOT 0. The distinction is load-bearing, exactly as it is for the push channel
675
+ * (lib/org/push.readCursor): `cursor=0` on the events read means "replay the
676
+ * ENTIRE org ledger from genesis", so a fresh install that started at 0 would
677
+ * wake on every historical message in the workspace. `null` is the caller's cue
678
+ * to SEED from the push channel head instead (see guardMessagingInbound), so a
679
+ * (re)install starts at "now" and replays nothing. A real, persisted cursor
680
+ * (always > 0 in practice — see writeMessagingCursor) is returned as-is.
681
+ */
671
682
  function readMessagingCursor(agentRoot) {
672
683
  try {
673
684
  const p = join(agentRoot, MESSAGING_CURSOR_REL);
674
- if (!existsSync(p)) return 0;
685
+ if (!existsSync(p)) return null;
675
686
  const obj = JSON.parse(readFileSync(p, "utf8"));
676
687
  const c = Number(obj && obj.cursor);
677
- return Number.isFinite(c) ? c : 0;
688
+ // A non-finite / non-positive cursor is corrupt, not "from genesis" — treat it
689
+ // as absent (seed from head) rather than replaying all history. Aligned with
690
+ // lib/org/push.readCursor (`n > 0`): a persisted cursor is always > 0 (seeded
691
+ // from a head that is null-or->0, then only advanced forward), so 0 is never a
692
+ // real watermark — accepting it would replay the entire org ledger.
693
+ return Number.isFinite(c) && c > 0 ? c : null;
678
694
  } catch {
679
- return 0;
695
+ return null;
680
696
  }
681
697
  }
682
698
 
@@ -764,7 +780,34 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
764
780
  opts.toInboxImpl || (await import("../../lib/channels/inbox-item.mjs")).eventToInboxItem;
765
781
  const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
766
782
 
767
- const cursor = readMessagingCursor(agentRoot);
783
+ let cursor = readMessagingCursor(agentRoot);
784
+ if (cursor == null) {
785
+ // FIRST RUN (or a corrupt cursor): no usable inbound cursor on disk. Do NOT
786
+ // start the poll lane at genesis — `events?cursor=0` replays the ENTIRE org
787
+ // ledger and wakes the fresh agent on every historical message. Seed from
788
+ // the PUSH CHANNEL HEAD instead: the SSE lane bootstraps at head and
789
+ // persists that seq to state/org/push-cursor.json, so it is the local
790
+ // "everything before this is history" watermark. Persist it as our starting
791
+ // cursor so a crash before the first advance cannot fall back to genesis.
792
+ let head = null;
793
+ try {
794
+ head = typeof opts.pushHeadImpl === "function"
795
+ ? opts.pushHeadImpl(agentRoot)
796
+ : (await import("../../lib/org/push.mjs")).readCursor(agentRoot);
797
+ } catch { head = null; }
798
+ if (head == null) {
799
+ // Neither lane has established a head yet (truly fresh install, push not
800
+ // yet connected). Replaying the backlog is the ONE thing we must not do,
801
+ // so hold this tick — the push channel writes a head shortly and the next
802
+ // tick seeds from it. Never silent: the reason says exactly why 0 routed.
803
+ if (typeof log === "function") {
804
+ log("info", "[messaging-inbound] fresh install: no inbound cursor and no push-channel head yet — holding this tick to avoid replaying the org backlog");
805
+ }
806
+ return { ok: true, decision: "inline", cadence: event.cadence, reason: "awaiting push-channel head to seed inbound cursor (fresh install)", routed: 0 };
807
+ }
808
+ cursor = Number(head);
809
+ writeMessagingCursor(agentRoot, cursor);
810
+ }
768
811
  // Pass the agent id explicitly. It is what suppresses the agent's OWN
769
812
  // outbound echoes — without it `me` is null, every echo-filter branch is
770
813
  // skipped, and the agent re-ingests its own replies as fresh inbound (a
@@ -445,6 +445,10 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
445
445
  { event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
446
446
  {
447
447
  cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t", agentId: "me" } } },
448
+ // Fresh root → no inbound cursor. Seed from the push-channel head so the
449
+ // pull starts at "now" (not genesis). The stub stands in for
450
+ // lib/org/push.readCursor.
451
+ pushHeadImpl: () => 5,
448
452
  pullImpl: async () => ({ events: [ev1, ev2], nextCursor: 12 }),
449
453
  toInboxImpl: (e) => ({ id: e.message_id, service: "cohort", content: e.text }),
450
454
  writeImpl: (service, item) => written.push({ service, item }),
@@ -461,20 +465,67 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
461
465
  assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 12);
462
466
  });
463
467
 
468
+ test("messaging-inbound: FIRST RUN seeds the inbound cursor from the push-channel head (no backlog replay)", async () => {
469
+ const root = tmpRoot();
470
+ let pulledCursor = null;
471
+ const res = await guardMessagingInbound(
472
+ { event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
473
+ {
474
+ cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
475
+ // The push channel has advanced to seq 4200. A fresh install must start
476
+ // there, NOT at 0 (which would replay the whole org ledger).
477
+ pushHeadImpl: () => 4200,
478
+ pullImpl: async ({ cursor }) => { pulledCursor = cursor; return { events: [], nextCursor: cursor }; },
479
+ toInboxImpl: () => ({}),
480
+ writeImpl: () => { throw new Error("should not write an item"); },
481
+ },
482
+ );
483
+ assert.equal(res.ok, true);
484
+ assert.equal(res.decision, "inline");
485
+ assert.equal(pulledCursor, 4200, "the pull started at the push head, not at genesis");
486
+ const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
487
+ assert.ok(existsSync(cursorFile), "the seeded head is persisted so a crash cannot fall back to genesis");
488
+ assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 4200);
489
+ });
490
+
491
+ test("messaging-inbound: FIRST RUN with no push head yet HOLDS the tick (never replays from genesis)", async () => {
492
+ const root = tmpRoot();
493
+ const res = await guardMessagingInbound(
494
+ { event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
495
+ {
496
+ cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
497
+ pushHeadImpl: () => null, // push channel has not established a head yet
498
+ pullImpl: async () => { throw new Error("must not pull before a head exists"); },
499
+ toInboxImpl: () => ({}),
500
+ writeImpl: () => { throw new Error("must not write"); },
501
+ },
502
+ );
503
+ assert.equal(res.ok, true);
504
+ assert.equal(res.decision, "inline");
505
+ assert.equal(res.routed, 0);
506
+ assert.match(res.reason, /awaiting push-channel head/);
507
+ assert.ok(!existsSync(join(root, "state", "messaging", "inbound-cursor.json")), "no cursor seeded while we wait for a head");
508
+ });
509
+
464
510
  test("messaging-inbound: no new directed items → inline, routed 0, cursor unchanged", async () => {
465
511
  const root = tmpRoot();
512
+ // A cursor already exists (not a first run), so seeding is skipped and the pull
513
+ // starts from the persisted seq. Nothing new → the cursor must not move.
514
+ const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
515
+ mkdirSync(join(root, "state", "messaging"), { recursive: true });
516
+ writeFileSync(cursorFile, JSON.stringify({ cursor: 9, updatedAt: "2026-01-01T00:00:00Z" }));
466
517
  const res = await guardMessagingInbound(
467
518
  { event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
468
519
  {
469
520
  cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
470
- pullImpl: async ({ cursor }) => ({ events: [], nextCursor: cursor }),
521
+ pullImpl: async ({ cursor }) => { assert.equal(cursor, 9, "pull starts from the persisted cursor"); return { events: [], nextCursor: cursor }; },
471
522
  toInboxImpl: () => ({}),
472
523
  writeImpl: () => { throw new Error("should not write"); },
473
524
  },
474
525
  );
475
526
  assert.equal(res.decision, "inline");
476
527
  assert.equal(res.routed, 0);
477
- assert.ok(!existsSync(join(root, "state", "messaging", "inbound-cursor.json")), "no cursor write when nothing advanced");
528
+ assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 9, "cursor unchanged when nothing advanced");
478
529
  });
479
530
 
480
531
  test("messaging-inbound: org not configured → inline no-op (fail-open)", async () => {
@@ -496,6 +547,7 @@ test("messaging-inbound: MESSAGING_INBOUND_ESCALATE=1 escalates when items arriv
496
547
  { event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
497
548
  {
498
549
  cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
550
+ pushHeadImpl: () => 1,
499
551
  pullImpl: async () => ({ events: [{ message_id: "m1", text: "@me" }], nextCursor: 1 }),
500
552
  toInboxImpl: (e) => ({ id: e.message_id }),
501
553
  writeImpl: () => {},
@@ -514,6 +566,7 @@ test("messaging-inbound: fully fail-open (pull throws → inline, never throws)"
514
566
  { event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
515
567
  {
516
568
  cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
569
+ pushHeadImpl: () => 3,
517
570
  pullImpl: async () => { throw new Error("network down"); },
518
571
  },
519
572
  );
@@ -737,6 +790,7 @@ test("messaging-inbound (JOINT 3): the DEFAULT reader is wide — no family filt
737
790
  {
738
791
  cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
739
792
  agentId: ME,
793
+ pushHeadImpl: () => 1, // fresh root: seed past genesis so the ledger read runs
740
794
  fetchImpl,
741
795
  toInboxImpl: (e) => ({ id: e.id, service: "cohort", kind: e.kind, content: e.text }),
742
796
  writeImpl: (service, item) => written.push({ service, item }),
@@ -765,6 +819,7 @@ test("messaging-inbound (JOINT 3): MESSAGING_INBOUND_WIDE=0 falls back to the na
765
819
  {
766
820
  cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
767
821
  agentId: "member-me",
822
+ pushHeadImpl: () => 1, // fresh root: seed past genesis so the read runs
768
823
  // The narrow reader pins family=messaging,calling on its URL; assert the
769
824
  // degradation is announced rather than inferred from an empty inbox.
770
825
  fetchImpl: async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ events: [], nextCursor: 0 }), json: async () => ({ events: [], nextCursor: 0 }) }),
@@ -199,38 +199,63 @@ export function promoteDeferred(channel, agentRoot) {
199
199
  matches.push({
200
200
  file,
201
201
  timestamp: readScalar(body, "timestamp") || "",
202
+ // THE SURFACE within the channel. `channel_id` names the ROOM; a single
203
+ // room carries several independent conversations at once — a directed 1:1
204
+ // DM thread AND the actor's own doc/board/decision events that resolve to
205
+ // the same channel_id. Bundling "latest-wins across the whole channel"
206
+ // folded those unrelated surfaces into one and silently dropped
207
+ // (`.processed-bundled`) a directed DM because a newer doc-comment event
208
+ // arrived in the same room. thread_context only ever carries the SAME
209
+ // thread's history, so bundling is sound ONLY within one surface. Key on
210
+ // thread_id, else the surface's own scope_id, else "" (a bare-channel
211
+ // burst — the original same-thread case, preserved).
212
+ surface:
213
+ readScalar(body, "thread_id") ||
214
+ readScalar(body, "scope_id") ||
215
+ "",
202
216
  });
203
217
  }
204
218
  if (matches.length === 0) continue;
205
219
 
206
- // Latest-wins: lex-sort ISO timestamps, take the most recent.
207
- matches.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
208
- const latest = matches[matches.length - 1];
209
- const older = matches.slice(0, -1);
210
-
211
- // Promote the latest back to live inbox.
212
- try {
213
- const live = latest.file.replace(/\.deferred$/, "");
214
- renameSync(join(inboxDir, latest.file), join(inboxDir, live));
215
- result.promoted++;
216
- result.service = service;
217
- } catch {
218
- // If the rename failed (race with another process), leave it
219
- // as .deferred — the next session-close will retry.
220
+ // Group by surface within the channel, then bundle latest-wins WITHIN each
221
+ // group. Distinct surfaces each promote their own latest; none is dropped
222
+ // just because a newer message landed on a different surface in the room.
223
+ const groups = new Map();
224
+ for (const m of matches) {
225
+ if (!groups.has(m.surface)) groups.set(m.surface, []);
226
+ groups.get(m.surface).push(m);
220
227
  }
221
228
 
222
- // Mark older bursts as bundled — their content is already part of
223
- // the latest item's thread_context, so they don't need their own
224
- // session, but we preserve the file for audit.
225
- for (const m of older) {
229
+ for (const group of groups.values()) {
230
+ // Latest-wins: lex-sort ISO timestamps, take the most recent.
231
+ group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
232
+ const latest = group[group.length - 1];
233
+ const older = group.slice(0, -1);
234
+
235
+ // Promote the latest back to live inbox.
226
236
  try {
227
- renameSync(
228
- join(inboxDir, m.file),
229
- join(inboxDir, m.file.replace(/\.deferred$/, ".processed-bundled"))
230
- );
231
- result.bundled++;
237
+ const live = latest.file.replace(/\.deferred$/, "");
238
+ renameSync(join(inboxDir, latest.file), join(inboxDir, live));
239
+ result.promoted++;
240
+ result.service = service;
232
241
  } catch {
233
- // Best-effort; missing file is fine.
242
+ // If the rename failed (race with another process), leave it
243
+ // as .deferred — the next session-close will retry.
244
+ }
245
+
246
+ // Mark older bursts as bundled — their content is already part of
247
+ // the latest item's thread_context, so they don't need their own
248
+ // session, but we preserve the file for audit.
249
+ for (const m of older) {
250
+ try {
251
+ renameSync(
252
+ join(inboxDir, m.file),
253
+ join(inboxDir, m.file.replace(/\.deferred$/, ".processed-bundled"))
254
+ );
255
+ result.bundled++;
256
+ } catch {
257
+ // Best-effort; missing file is fine.
258
+ }
234
259
  }
235
260
  }
236
261
  }
@@ -12,11 +12,13 @@ function makeAgentRoot() {
12
12
  return root;
13
13
  }
14
14
 
15
- function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id" }) {
15
+ function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id", thread_id, scope_id }) {
16
16
  const body = [
17
17
  `id: "${id}"`,
18
18
  `service: "slack"`,
19
19
  `channel_id: "${channel_id}"`,
20
+ ...(thread_id ? [`thread_id: "${thread_id}"`] : []),
21
+ ...(scope_id ? [`scope_id: "${scope_id}"`] : []),
20
22
  `timestamp: "${timestamp}"`,
21
23
  `content: |`,
22
24
  ` body`,
@@ -118,6 +120,42 @@ test("promoteDeferred with multiple items: keeps latest, bundles rest", () => {
118
120
  }
119
121
  });
120
122
 
123
+ test("promoteDeferred bundles WITHIN a surface, not across the whole channel", () => {
124
+ // A directed 1:1 DM thread AND the actor's doc-comment events resolve to the
125
+ // same channel_id. Bundling latest-wins across the channel would drop the DM
126
+ // just because a newer doc event landed. Each surface must promote its own
127
+ // latest; nothing is bundled across surfaces.
128
+ const root = makeAgentRoot();
129
+ try {
130
+ // The directed DM (one message, thread T-DM).
131
+ writeInboxItem(root, "dm-1.yaml.deferred", {
132
+ channel_id: "room-1", thread_id: "T-DM", id: "dm-1",
133
+ timestamp: "2026-05-13T00:00:30Z",
134
+ });
135
+ // Two doc-comment events on the same room but a different surface (scope S-DOC),
136
+ // the later of which would have "won" under the old channel-wide bundling.
137
+ writeInboxItem(root, "doc-1.yaml.deferred", {
138
+ channel_id: "room-1", scope_id: "S-DOC", id: "doc-1",
139
+ timestamp: "2026-05-13T00:01:00Z",
140
+ });
141
+ writeInboxItem(root, "doc-2.yaml.deferred", {
142
+ channel_id: "room-1", scope_id: "S-DOC", id: "doc-2",
143
+ timestamp: "2026-05-13T00:02:00Z",
144
+ });
145
+ const r = promoteDeferred("room-1", root);
146
+ assert.equal(r.promoted, 2, "the DM and the latest doc event are BOTH promoted");
147
+ assert.equal(r.bundled, 1, "only the older doc event (same surface) is bundled");
148
+ const files = readdirSync(join(root, "state", "inbox", "slack")).sort();
149
+ assert.deepEqual(files, [
150
+ "dm-1.yaml", // the directed DM survives — NOT dropped
151
+ "doc-1.yaml.processed-bundled", // older doc event folded into doc-2
152
+ "doc-2.yaml", // latest doc event promoted
153
+ ]);
154
+ } finally {
155
+ rmSync(root, { recursive: true, force: true });
156
+ }
157
+ });
158
+
121
159
  test("promoteDeferred ignores items in other channels", () => {
122
160
  const root = makeAgentRoot();
123
161
  try {
@@ -901,6 +901,15 @@ export async function sendQuickResponse(item, classResult, routed = null) {
901
901
  via: delivered.via,
902
902
  ...(delivered.channel ? { channel: delivered.channel } : {}),
903
903
  ...(delivered.draft_path ? { draft_path: delivered.draft_path } : {}),
904
+ // PROPAGATE THE PERMANENCE. `deliver` marks a failure the send-gate refused
905
+ // (FORBIDDEN_SCOPE) or hq declared unroutable (NOT_FOUND/BAD_REQUEST) as
906
+ // `permanent` with the RPC `code`. Without carrying these up, the caller
907
+ // (agent-daemon.answerItem) could not tell a transient blip from an
908
+ // impossible send, so it fell through to a full autonomous session — which
909
+ // then posted UNRELATED work into a channel this seat was just told it may
910
+ // not write to. The daemon uses `permanent` to escalate instead of spawn.
911
+ ...(delivered.permanent ? { permanent: true } : {}),
912
+ ...(delivered.code ? { code: delivered.code } : {}),
904
913
  ...(delivered.error ? { error: delivered.error } : {}),
905
914
  };
906
915
 
@@ -111,6 +111,13 @@ export function parseInboxItemYaml(body) {
111
111
  if (channelType) item.channel_type = channelType;
112
112
  if (isPrivate !== undefined) item.is_private = isPrivate;
113
113
  if (isDm !== undefined) item.is_dm = isDm;
114
+ // THE CHANNEL KIND. Cohort's only reliable DM signal, written by
115
+ // scripts/poller/utils.inboxItemToYaml when present. Attached only when the
116
+ // writer emitted it so a non-Cohort item keeps its old shape; the daemon's
117
+ // enrichItem reads it to tell a 1:1 DM (skips the election) from a GROUP_DM
118
+ // (goes through the election as a multi-participant channel).
119
+ const channelKind = scalar("channel_kind");
120
+ if (channelKind) item.channel_kind = channelKind;
114
121
  // The hq chain kind, when the org projection carried one through the writer.
115
122
  // Attached only when present so a non-org item keeps exactly its old shape;
116
123
  // `lib/execution/intake.fromInboxItem` prefers it over the surface so an
@@ -239,6 +239,17 @@ raw_ref: "${item.raw_ref || ""}"
239
239
  yaml += `channel_id: "${item.channel_id}"\n`;
240
240
  }
241
241
 
242
+ // THE CHANNEL KIND (DM / GROUP_DM / PUBLIC / …). Cohort's only reliable DM
243
+ // signal (its channel ids are cuids, so the "D…"/"dm/" prefix heuristics never
244
+ // fire). Written ONLY when present so a non-Cohort item is byte-identical to
245
+ // before; the daemon's `enrichItem` reads it to route a 1:1 DM past the group
246
+ // election gate while a GROUP_DM still goes through it. Enumerated writers like
247
+ // this one drop anything not named here, which is exactly how the signal was
248
+ // lost between eventToInboxItem and parseInboxItemYaml.
249
+ if (item.channel_kind) {
250
+ yaml += `channel_kind: "${item.channel_kind}"\n`;
251
+ }
252
+
242
253
  // CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
243
254
  // type indicator (a private channel can carry a `C…` id), so downstream
244
255
  // consumers — notably the daemon's fail-closed outcome gate,