@open-everywhere/provider-slack 0.6.0 → 0.8.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 (141) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +15 -1
  3. package/agent/channels/slack-agent-session.ts +249 -0
  4. package/agent/channels/slack-attachments.ts +20 -11
  5. package/agent/channels/slack-bot-ask.ts +101 -0
  6. package/agent/channels/slack-commands.ts +178 -17
  7. package/agent/channels/slack-context.ts +0 -5
  8. package/agent/channels/slack-events.ts +110 -33
  9. package/agent/channels/slack-follow-ups.ts +148 -0
  10. package/agent/channels/slack-friendly-names.ts +55 -0
  11. package/agent/channels/slack-front.ts +262 -27
  12. package/agent/channels/slack-home.ts +9 -3
  13. package/agent/channels/slack-identity.ts +9 -1
  14. package/agent/channels/slack-memory-canvas.ts +197 -54
  15. package/agent/channels/slack-origin-codec.ts +9 -0
  16. package/agent/channels/slack-post-arm.ts +68 -11
  17. package/agent/channels/slack-read.ts +27 -2
  18. package/agent/channels/slack-reply-blocks.ts +180 -0
  19. package/agent/channels/slack-reply-feedback.ts +75 -0
  20. package/agent/channels/slack-shortcut.ts +1 -1
  21. package/agent/channels/slack-voice-clips.ts +82 -0
  22. package/agent/channels/slack-wake-backscroll.ts +0 -5
  23. package/agent/channels/slack-web-api.ts +4 -2
  24. package/agent/channels/slack.ts +81 -19
  25. package/agent/docs-generated.ts +1282 -2
  26. package/agent/ops.ts +14 -21
  27. package/agent/register.ts +25 -0
  28. package/agent/setup/credentials.ts +17 -0
  29. package/agent/setup/page.ts +6 -3
  30. package/agent/surface-parity.ts +2 -0
  31. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
  32. package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
  33. package/dist/agent/app-manifest.d.ts.map +1 -1
  34. package/dist/agent/app-manifest.js +15 -1
  35. package/dist/agent/app-manifest.js.map +1 -1
  36. package/dist/agent/channels/slack-agent-session.d.ts +81 -0
  37. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  38. package/dist/agent/channels/slack-agent-session.js +198 -0
  39. package/dist/agent/channels/slack-agent-session.js.map +1 -0
  40. package/dist/agent/channels/slack-attachments.d.ts +11 -0
  41. package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
  42. package/dist/agent/channels/slack-attachments.js +21 -14
  43. package/dist/agent/channels/slack-attachments.js.map +1 -1
  44. package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
  45. package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
  46. package/dist/agent/channels/slack-bot-ask.js +91 -0
  47. package/dist/agent/channels/slack-bot-ask.js.map +1 -0
  48. package/dist/agent/channels/slack-commands.d.ts +113 -10
  49. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  50. package/dist/agent/channels/slack-commands.js +151 -18
  51. package/dist/agent/channels/slack-commands.js.map +1 -1
  52. package/dist/agent/channels/slack-context.d.ts +1 -2
  53. package/dist/agent/channels/slack-context.d.ts.map +1 -1
  54. package/dist/agent/channels/slack-context.js +0 -4
  55. package/dist/agent/channels/slack-context.js.map +1 -1
  56. package/dist/agent/channels/slack-events.d.ts +47 -97
  57. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  58. package/dist/agent/channels/slack-events.js +90 -22
  59. package/dist/agent/channels/slack-events.js.map +1 -1
  60. package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
  61. package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
  62. package/dist/agent/channels/slack-follow-ups.js +129 -0
  63. package/dist/agent/channels/slack-follow-ups.js.map +1 -0
  64. package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
  65. package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
  66. package/dist/agent/channels/slack-friendly-names.js +45 -0
  67. package/dist/agent/channels/slack-friendly-names.js.map +1 -0
  68. package/dist/agent/channels/slack-front.d.ts +38 -46
  69. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  70. package/dist/agent/channels/slack-front.js +230 -23
  71. package/dist/agent/channels/slack-front.js.map +1 -1
  72. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  73. package/dist/agent/channels/slack-home.js +10 -3
  74. package/dist/agent/channels/slack-home.js.map +1 -1
  75. package/dist/agent/channels/slack-identity.d.ts +7 -21
  76. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  77. package/dist/agent/channels/slack-identity.js +3 -1
  78. package/dist/agent/channels/slack-identity.js.map +1 -1
  79. package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
  80. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  81. package/dist/agent/channels/slack-memory-canvas.js +166 -55
  82. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  83. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  84. package/dist/agent/channels/slack-origin-codec.js +10 -0
  85. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  86. package/dist/agent/channels/slack-post-arm.d.ts +21 -1
  87. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  88. package/dist/agent/channels/slack-post-arm.js +57 -10
  89. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  90. package/dist/agent/channels/slack-read.d.ts +11 -1
  91. package/dist/agent/channels/slack-read.d.ts.map +1 -1
  92. package/dist/agent/channels/slack-read.js +27 -3
  93. package/dist/agent/channels/slack-read.js.map +1 -1
  94. package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
  95. package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
  96. package/dist/agent/channels/slack-reply-blocks.js +172 -0
  97. package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
  98. package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
  99. package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
  100. package/dist/agent/channels/slack-reply-feedback.js +63 -0
  101. package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
  102. package/dist/agent/channels/slack-shortcut.d.ts +10 -0
  103. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
  104. package/dist/agent/channels/slack-shortcut.js +1 -1
  105. package/dist/agent/channels/slack-shortcut.js.map +1 -1
  106. package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
  107. package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
  108. package/dist/agent/channels/slack-voice-clips.js +80 -0
  109. package/dist/agent/channels/slack-voice-clips.js.map +1 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
  111. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  112. package/dist/agent/channels/slack-wake-backscroll.js +0 -4
  113. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
  115. package/dist/agent/channels/slack-web-api.js +4 -2
  116. package/dist/agent/channels/slack-web-api.js.map +1 -1
  117. package/dist/agent/channels/slack.d.ts +22 -1
  118. package/dist/agent/channels/slack.d.ts.map +1 -1
  119. package/dist/agent/channels/slack.js +70 -16
  120. package/dist/agent/channels/slack.js.map +1 -1
  121. package/dist/agent/docs-generated.d.ts.map +1 -1
  122. package/dist/agent/docs-generated.js +1282 -2
  123. package/dist/agent/docs-generated.js.map +1 -1
  124. package/dist/agent/ops.d.ts +1 -1
  125. package/dist/agent/ops.d.ts.map +1 -1
  126. package/dist/agent/ops.js +15 -21
  127. package/dist/agent/ops.js.map +1 -1
  128. package/dist/agent/register.d.ts.map +1 -1
  129. package/dist/agent/register.js +22 -1
  130. package/dist/agent/register.js.map +1 -1
  131. package/dist/agent/setup/credentials.d.ts +12 -0
  132. package/dist/agent/setup/credentials.d.ts.map +1 -1
  133. package/dist/agent/setup/credentials.js +17 -0
  134. package/dist/agent/setup/credentials.js.map +1 -1
  135. package/dist/agent/setup/page.d.ts.map +1 -1
  136. package/dist/agent/setup/page.js +6 -3
  137. package/dist/agent/setup/page.js.map +1 -1
  138. package/dist/agent/surface-parity.d.ts.map +1 -1
  139. package/dist/agent/surface-parity.js +2 -0
  140. package/dist/agent/surface-parity.js.map +1 -1
  141. package/package.json +40 -3
@@ -36,18 +36,23 @@ import { defineChannel, POST } from "eve/channels";
36
36
  import { verifySlackSignature } from "./slack-front.js";
37
37
  import { slackSigningSecret } from "../setup/credentials.js";
38
38
  import {
39
+ commandFailureText,
39
40
  bindFrom,
40
41
  runOeCommand,
41
42
  oeSlash,
42
43
  parseSlackCommandText,
43
44
  resolvePartition,
45
+ auditSurfaceModeFrom,
44
46
  getOperationalStore,
45
47
  admittedTenantsFromConfig,
46
48
  unadmittedTenantLog,
47
49
  UNADMITTED_TENANT_DROP_REASON,
48
50
  type BindableContext,
51
+ type SurfaceBinding,
49
52
  } from "@open-everywhere/core/agent/adapter-api.js";
50
53
  import { slackTenantAdmission } from "./slack-tenant-fence.js";
54
+ import { slackAuthorIsMember, slackChannelFactsFor } from "./slack-events.js";
55
+ import { slackMode } from "./slack-identity.js";
51
56
 
52
57
  function log(msg: string): void {
53
58
  console.log(`[open-everywhere:slack-commands] ${msg}`);
@@ -63,6 +68,21 @@ function formField(params: URLSearchParams, name: string): string | undefined {
63
68
  return v && v.length > 0 ? v : undefined;
64
69
  }
65
70
 
71
+ /** What the route learned about a command's trust before it binds. `dmPartnerTrust` is the invoker's own
72
+ * verdict in a 1:1 DM. `room` is a channel's `conversations.info` verdict, present whenever Slack answered
73
+ * it, whoever typed the command. `invokerMember` is present only when `users.info` CONFIRMED the invoker a
74
+ * member (see {@link slashRoomVerdict}). The person's binding stamps `room` only beside `invokerMember`;
75
+ * the room's binding stamps it alone (OPE-2588). */
76
+ export interface SlashTrustFacts {
77
+ readonly dmPartnerTrust?: "internal" | "external";
78
+ readonly room?: { readonly extShared: boolean };
79
+ readonly invokerMember?: true;
80
+ }
81
+
82
+ /** Which verdict a slash binding resolves: the PERSON's (the room's verdict folded with the invoker
83
+ * check, which every member gate reads) or the ROOM's alone (which the room's budget reads). */
84
+ export type SlashTrustLeg = "person" | "room";
85
+
66
86
  /** Build the BindableContext the identity seam (bindFrom/bindSlack) expects from Slack's
67
87
  * slash-command form fields. Slack's slash-command payload carries no `channel_type` /
68
88
  * `is_private` bit (unlike an app_mention/message event) — `bindSlack`'s existing fallbacks
@@ -75,16 +95,36 @@ function formField(params: URLSearchParams, name: string): string | undefined {
75
95
  * reads — so a Grid declared once now answers here the same way it answers at every other door,
76
96
  * instead of resolving as though the line were absent.
77
97
  *
78
- * 🔴 STATE ITS REACH EXACTLY, because this door is narrower than the wake doors. For a CHANNEL
79
- * command it does NOT change `binding.trust`: this payload carries no `conversations.info` verdict,
80
- * so `slackTrust(undefined, undefined, "channel")` fails closed to `external`, and `bindFrom` takes
81
- * that raw verdict ahead of any vouch. So a channel `/oe` stays `trust: "external"` with the rung
82
- * and without it, and the budget scope `domainNameForScope` picks is unchanged. The rung reaches
83
- * `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), where the mapper leaves trust
84
- * undefined and the vouch is what answers. Spread conditionally, like every other door: a
85
- * single-workspace install stamps nothing. */
86
- export function bindableForSlashCommand(params: URLSearchParams): BindableContext {
98
+ * 🔴 STATE ITS REACH EXACTLY. For a CHANNEL command the rung does NOT change `binding.trust`: the
99
+ * room's own verdict answers there (see below), and `bindFrom` takes that raw verdict ahead of any
100
+ * vouch. The rung reaches `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), and only
101
+ * when the invoker probe gave no verdict: the mapper then leaves trust undefined and the vouch
102
+ * answers (see below). Spread conditionally, like every other door: a single-workspace install
103
+ * stamps nothing.
104
+ *
105
+ * 🔴 A CHANNEL COMMAND CARRIES THE ROOM'S OWN VERDICT (OPE-2579). Slack's slash payload carries no
106
+ * sharing bit, so before this every channel `/oe` bound `slackTrust(undefined, undefined,
107
+ * "channel")`, which fails closed to `external`. A member in an internal channel was then refused
108
+ * `/oe suggest on` as an outsider. The route now passes {@link slashRoomVerdict}'s answer as
109
+ * `is_ext_shared` + `trust_checked`, the two attributes a channel message stamps from the same
110
+ * TTL-cached `conversations.info`. So a channel command reads the room's `internal` or `external` as a
111
+ * message in that room does. On the `person` leg (the default) the verdict is stamped only for an
112
+ * invoker `users.info` confirms a member. On the `room` leg it is stamped for anybody, because that
113
+ * leg answers the room's budget, not a member gate (OPE-2588, {@link bindSlashCommand}). No verdict
114
+ * stamps nothing, and the command stays fail-closed `external`.
115
+ *
116
+ * 🔴 A DM COMMAND CARRIES THE INVOKER'S OWN VERDICT (OPE-2579). In a 1:1 DM the invoker IS the
117
+ * partner, and the route passes {@link slashInvokerDmTrust}'s answer as `dm_partner_trust`, the
118
+ * attribute the chat DM leg stamps from the same `users.info` probe. Before this, a workspace vouch
119
+ * alone answered a DM command, so a guest or a Connect stranger in their DM with the app read
120
+ * `internal` here while the same person read `external` in a DM turn. A verdict outranks the vouch
121
+ * in both directions, exactly as in a DM turn: a guest reads `external` under a workspace vouch, and
122
+ * a confirmed member reads `internal` with no declaration at all. No verdict leaves the attribute
123
+ * unset and the binding resolves as before. This reaches every `/oe` command typed in a DM, not
124
+ * only `watch`. */
125
+ export function bindableForSlashCommand(params: URLSearchParams, invoker?: SlashTrustFacts, leg: SlashTrustLeg = "person"): BindableContext {
87
126
  const enterpriseId = formField(params, "enterprise_id");
127
+ const room = invoker?.room && (leg === "room" || invoker.invokerMember === true) ? invoker.room : undefined;
88
128
  return {
89
129
  channel: { kind: "slack" },
90
130
  session: {
@@ -94,6 +134,8 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
94
134
  team_id: formField(params, "team_id") ?? "",
95
135
  channel_id: formField(params, "channel_id") ?? "",
96
136
  ...(enterpriseId ? { enterprise_id: enterpriseId } : {}),
137
+ ...(invoker?.dmPartnerTrust ? { dm_partner_trust: invoker.dmPartnerTrust } : {}),
138
+ ...(room ? { is_ext_shared: String(room.extShared), trust_checked: "true" } : {}),
97
139
  },
98
140
  principalId: formField(params, "user_id") ?? "",
99
141
  principalType: "slack-user",
@@ -103,6 +145,120 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
103
145
  };
104
146
  }
105
147
 
148
+ /** How long each trust read (the invoker probe, the room read) may take before the route answers without
149
+ * it. Slack wants its ack inside three seconds, and `slackForm`'s 429 backoff can wait longer than that. */
150
+ const INVOKER_PROBE_MS = 1_500;
151
+
152
+ /**
153
+ * The invoker's own membership verdict for a 1:1 DM command, in `dm_partner_trust`'s vocabulary, or
154
+ * `{}` when there is none to give (OPE-2579). It is the chat DM leg's reading of the same probe
155
+ * (slack-front.ts `liveDmPartnerTrust`): `slackAuthorIsMember` decides, so the two doors cannot
156
+ * drift on what a guest is. A channel or group DM asks nothing here: there the ROOM's verdict is the
157
+ * binding's trust, and {@link slashRoomVerdict} reads it.
158
+ *
159
+ * 🔴 NO VERDICT IS NOT A VERDICT. No user id, no bot token, a refused `users.info`, a transport
160
+ * failure or a probe slower than {@link INVOKER_PROBE_MS} all answer `{}`, which leaves the binding
161
+ * exactly as it resolved before this probe existed. An unanswered probe never invents a guest, and
162
+ * never invents a member.
163
+ */
164
+ export async function slashInvokerDmTrust(
165
+ params: URLSearchParams,
166
+ probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
167
+ ): Promise<{ readonly dmPartnerTrust?: "internal" | "external" }> {
168
+ const userId = formField(params, "user_id");
169
+ if (!userId || slackMode(undefined, formField(params, "channel_id")) !== "dm") return {};
170
+ const member = await withinAckBudget(probe(userId, formField(params, "team_id")));
171
+ return member === undefined ? {} : { dmPartnerTrust: member ? "internal" : "external" };
172
+ }
173
+
174
+ /** The room facts {@link slashRoomVerdict} reads: `slackChannelFactsFor`'s shape, which every channel wake reads. */
175
+ export type SlashRoomFacts = (channelId: string) => Promise<{ readonly channelType?: string; readonly extShared?: boolean; readonly trustChecked?: boolean }>;
176
+
177
+ /**
178
+ * A CHANNEL command's room verdict and invoker check, or `{}` when there is no room verdict to give
179
+ * (OPE-2579). The room read is `slackChannelFactsFor`, the TTL-cached `conversations.info` every
180
+ * channel wake reads, so the command door and the message door cannot disagree about one room.
181
+ *
182
+ * 🔴 THE INVOKER LEG, AND IT FAILS CLOSED. A channel message keeps the room's verdict on the auth and marks
183
+ * a guest author on the side (slack-events.ts `slackWakeAuthorFacts`), because one guest's message must not
184
+ * demote the channel's memory for everybody. A command has no session to protect: its binding exists for
185
+ * one answer, and `/oe suggest on` and `/oe watch more|less` read `binding.trust` as "is this person a
186
+ * member". So the route folds the author leg into the binding's `trust`, the composition Discord and
187
+ * Teams make: `invokerMember` is set only when `users.info` CONFIRMS the invoker a member, and the
188
+ * person's binding stamps the room's verdict only beside it. A guest, a Slack Connect stranger, and a
189
+ * person the probe could not check (no `users:read`, a refusal, a timeout) all leave `trust` fail-closed
190
+ * `external`. The message door fails OPEN on an unanswered probe because it marks a turn. Here `trust`
191
+ * IS the member gate, and a channel has no declaration to fall back to the way a DM command does.
192
+ *
193
+ * 🔴 THE ROOM'S VERDICT IS RETURNED FOR EVERY INVOKER (OPE-2588). `/oe budget` names the room's budget,
194
+ * and a channel's turns are charged under the room's domain, whoever typed them. So {@link bindSlashCommand}
195
+ * binds the room's verdict alone into `roomTrust`, and the budget reads that. A guest's `trust` is
196
+ * unchanged, and `/oe budget` reads both to refuse a guest in an internal channel (commands/core.ts
197
+ * `budgetWithheldFrom`). `/oe cover on` refuses outside a DM before it reads trust.
198
+ *
199
+ * 🔴 ONLY A ROOM IN THE CHANNEL MODE IS STAMPED. A 1:1 DM has its own leg ({@link slashInvokerDmTrust}).
200
+ * A group DM, or a room whose type Slack did not report, stamps nothing, so its binding is unchanged.
201
+ * Stamping a sharing verdict on a group DM the binding still calls a channel would mix two modes' rules.
202
+ *
203
+ * 🔴 NO VERDICT IS NOT A VERDICT. A failed or refused `conversations.info`, a response without the check,
204
+ * or a read slower than {@link INVOKER_PROBE_MS} answers `{}`. The command then binds `external`, as
205
+ * before this read existed. The room read and the invoker probe run in parallel inside one ack budget.
206
+ */
207
+ export async function slashRoomVerdict(
208
+ params: URLSearchParams,
209
+ roomFacts: SlashRoomFacts = slackChannelFactsFor,
210
+ probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
211
+ ): Promise<SlashTrustFacts> {
212
+ const channelId = formField(params, "channel_id");
213
+ if (!channelId || slackMode(undefined, channelId) === "dm") return {};
214
+ const userId = formField(params, "user_id");
215
+ const [facts, member] = await Promise.all([
216
+ withinAckBudget(roomFacts(channelId)),
217
+ userId ? withinAckBudget(probe(userId, formField(params, "team_id"))) : Promise.resolve(undefined),
218
+ ]);
219
+ if (!facts || facts.trustChecked !== true || typeof facts.extShared !== "boolean") return {};
220
+ if (facts.channelType !== "channel" && facts.channelType !== "group") return {};
221
+ return { room: { extShared: facts.extShared }, ...(member === true ? { invokerMember: true as const } : {}) };
222
+ }
223
+
224
+ /** Both legs, for whichever kind of room the command came from. Never throws. */
225
+ export async function slashTrustFacts(params: URLSearchParams): Promise<SlashTrustFacts> {
226
+ return slackMode(undefined, formField(params, "channel_id")) === "dm" ? slashInvokerDmTrust(params) : slashRoomVerdict(params);
227
+ }
228
+
229
+ /**
230
+ * The slash command's binding, with the room's trust and the person's trust kept apart (OPE-2588).
231
+ *
232
+ * `trust` is the PERSON leg: the member gate `/oe suggest on`, `/oe watch more|less` and `/oe cover on`
233
+ * read. `roomTrust` is the ROOM leg: the same `bindFrom` walk over the room's verdict alone, so the
234
+ * declarations and the fail-closed default apply to it unchanged. `/oe budget` and the caps behind
235
+ * `raise` and `clear` read it through `roomTrustOf`. Before this the binding carried one value, so a
236
+ * guest in an internal channel read the external domain's budget while that channel's turns were
237
+ * charged under the internal domain. Now `/oe budget` refuses that guest, because a guest must not read
238
+ * an internal room's caps and spend (Tommy's ruling on #1431).
239
+ *
240
+ * In a DM both legs bind the same context, so the two values are equal. Every other field comes from
241
+ * the person's binding. The two bindings differ only in the two trust attributes, which move no key.
242
+ */
243
+ export function bindSlashCommand(params: URLSearchParams, facts: SlashTrustFacts): SurfaceBinding {
244
+ const person = bindFrom(bindableForSlashCommand(params, facts, "person"));
245
+ const room = bindFrom(bindableForSlashCommand(params, facts, "room"));
246
+ return { ...person, roomTrust: room.trust };
247
+ }
248
+
249
+ /** `p`'s answer, or `undefined` when it throws or takes longer than {@link INVOKER_PROBE_MS}. */
250
+ async function withinAckBudget<T>(p: Promise<T>): Promise<T | undefined> {
251
+ let timer: ReturnType<typeof setTimeout> | undefined;
252
+ const timeout = new Promise<undefined>((resolve) => {
253
+ timer = setTimeout(() => resolve(undefined), INVOKER_PROBE_MS);
254
+ });
255
+ try {
256
+ return await Promise.race([p.catch(() => undefined), timeout]);
257
+ } finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+
106
262
  /**
107
263
  * Make a refused slash command OBSERVABLE, the same way `slack.ts`'s `auditWakeDropped` makes a
108
264
  * refused wake observable: the webhook function's console output never reaches `vercel logs`, so
@@ -122,16 +278,18 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
122
278
  */
123
279
  async function auditCommandDropped(teamId: string | undefined, channelId: string | undefined, reason: string, enterpriseId?: string): Promise<void> {
124
280
  try {
125
- const p = resolvePartition({
281
+ const bindable = {
126
282
  channel: { kind: "slack" },
127
283
  session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
128
- } as BindableContext);
284
+ } as BindableContext;
285
+ const p = resolvePartition(bindable);
129
286
  await getOperationalStore().appendAudit({
130
287
  ts: new Date().toISOString(),
131
288
  org: p.org,
132
289
  platform: p.platform,
133
290
  workspace: p.workspace,
134
291
  surface: p.surface,
292
+ ...auditSurfaceModeFrom(bindable),
135
293
  action: "surface.wake_dropped",
136
294
  detail: { reason, door: "slash-command", channel: channelId ?? null, team: teamId ?? null, enterprise: enterpriseId ?? null },
137
295
  });
@@ -141,6 +299,9 @@ async function auditCommandDropped(teamId: string | undefined, channelId: string
141
299
  }
142
300
 
143
301
  function ephemeralResponse(text: string): Response {
302
+ // OPE-2490: an empty reply means the command's card posted in this channel and IS the answer. An
303
+ // empty 200 acknowledges the command, and Slack then shows nothing beside the card.
304
+ if (text.length === 0) return new Response(null, { status: 200 });
144
305
  return new Response(JSON.stringify({ response_type: "ephemeral", text }), {
145
306
  status: 200,
146
307
  headers: { "content-type": "application/json" },
@@ -218,17 +379,17 @@ export default defineChannel({
218
379
  }
219
380
 
220
381
  const cmd = parseSlackCommandText(formField(params, "text"));
221
- const binding = bindFrom(bindableForSlashCommand(params));
382
+ const binding = bindSlashCommand(params, await slashTrustFacts(params));
222
383
  try {
223
384
  const reply = await runOeCommand(cmd, binding, { attachSession });
224
385
  return ephemeralResponse(reply);
225
386
  } catch (err) {
226
- // runOeCommand's budget-raise/clear branches can throw a user-facing validation error
227
- // (e.g. proposing from a DM with no explicit scope) — surface it verbatim. Never let a
228
- // thrown error escape as a 500; Slack's slash-command UX for that is an ugly
229
- // "operation_timeout"/dispatch_failed toast.
387
+ // A PersonFacingError is already worded for the person — it renders as-is. Anything else
388
+ // (a store or network failure, a planner's model-facing refusal) renders the generic line,
389
+ // and its message goes to the log only (OPE-2486). Never let a thrown error escape as a
390
+ // 500; Slack's slash-command UX for that is an ugly "operation_timeout"/dispatch_failed toast.
230
391
  log(`"${expected}" command failed softly: ${String(err)}`);
231
- return ephemeralResponse(`Couldn't complete that: ${err instanceof Error ? err.message : String(err)}`);
392
+ return ephemeralResponse(commandFailureText(err));
232
393
  }
233
394
  }),
234
395
  ],
@@ -38,11 +38,6 @@ import { loadThreadContextMessages } from "eve/channels/slack";
38
38
  import type { SlackMessage } from "eve/channels/slack";
39
39
  import { MAX_MESSAGES, renderContext, type ContextLine } from "@open-everywhere/core/agent/adapter-api.js";
40
40
 
41
- // Re-export the shared render + line shape so slack-wake-backscroll.ts (which imports them from
42
- // here) keeps compiling unchanged after the extraction, and any future Slack consumer has one
43
- // import site. The mapping (`linesFromMessages`) is defined below — it's Slack-message-shaped.
44
- export { renderContext, type ContextLine };
45
-
46
41
  // When the rendered slice fills MAX_MESSAGES exactly, there's very likely more history above
47
42
  // it (both fetchers below request MAX_MESSAGES-worth from Slack, so filling the cap usually
48
43
  // means older messages were left out) — tell the model this is a bounded slice, not the whole
@@ -23,6 +23,7 @@ import { slackBotToken } from "../setup/credentials.js";
23
23
  import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
24
24
  import {
25
25
  resolvePartition,
26
+ auditSurfaceModeFrom,
26
27
  cancelAnchoredTurn,
27
28
  turnAnchorKey,
28
29
  STOP_HAND_SLACK_NAME,
@@ -113,6 +114,7 @@ import {
113
114
  dmTrustTtlMs,
114
115
  engagedFollowUpContract,
115
116
  dispatchNativeWakeWithRetry,
117
+ coverForWake,
116
118
  } from "@open-everywhere/core/agent/adapter-api.js";
117
119
  import {
118
120
  slackMode,
@@ -129,9 +131,8 @@ import { holdRouteWork } from "./slack-route-hold.js";
129
131
  import { fetchWakeBackscrollLines, fetchWakeEnvelopeBackscroll, RAW_FETCH_REACHBACK_HOURS, WAKE_BACKSCROLL_MAX, type WakeBackscrollLines } from "./slack-wake-backscroll.js";
130
132
  // The silence vocabulary (NO_REPLY_SENTINEL / PREFER_NO_REPLY_TOOL) MOVED to normalized-wake.ts —
131
133
  // the shared wake renderer that emits it — so no per-surface dialect can drift its own copy.
132
- // PREFER_NO_REPLY_TOOL is re-exported here for existing importers; provider-neutral callers reach
133
- // NO_REPLY_SENTINEL through turn-delivery.ts (OPE-114 Stage 0), never through this provider file.
134
- export { PREFER_NO_REPLY_TOOL };
134
+ // Provider-neutral callers reach NO_REPLY_SENTINEL through turn-delivery.ts (OPE-114 Stage 0), never
135
+ // through this provider file.
135
136
  import { registerSlackAttachment } from "./slack-attachments.js";
136
137
  // Provider-neutral turn/delivery helpers lifted to turn-delivery.ts (OPE-114 Stage 0). The
137
138
  // Slack code below still consumes these three (turnEffectsFor builds on FinalMessageAction,
@@ -165,6 +166,7 @@ import {
165
166
  type ReactionIngestOutcome,
166
167
  type ReactionVerb,
167
168
  } from "@open-everywhere/core/agent/adapter-api.js";
169
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
168
170
 
169
171
  /**
170
172
  * Minimal Slack `event_callback` envelope shape (the subset the parser reads). Mirrors
@@ -260,6 +262,12 @@ export interface ParsedMessageEvent {
260
262
  * wake envelope's `<previous-body>` (wake-envelope.ts) so a judged turn can diff old↔new.
261
263
  */
262
264
  readonly previousText?: string;
265
+ /**
266
+ * TRUE ⇒ a `message_changed` edit whose words and files are exactly the pre-edit ones
267
+ * ({@link isUnchangedSlackEdit}). Absent on every other event, and on an edit that carries no
268
+ * `previous_message` to compare against, so an unknown edit reads as a real one.
269
+ */
270
+ readonly unchangedEdit?: true;
263
271
  /** True when the text @mentions the bot — those go through the normal mention path. */
264
272
  readonly isAppMention: boolean;
265
273
  /** Image/file attachment REFERENCES parsed off Slack's `files[]` (docs Part B) — surfaced in
@@ -724,6 +732,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
724
732
  for (const a of attachments) {
725
733
  if (a.kind === "image" && a.id && a.url) registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId });
726
734
  }
735
+ const unchangedEdit = previousMessage !== undefined && isUnchangedSlackEdit(inner, previousMessage);
727
736
  return {
728
737
  channelId,
729
738
  ts,
@@ -737,6 +746,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
737
746
  text,
738
747
  authoredText,
739
748
  previousText: typeof previousMessage?.text === "string" ? previousMessage.text : undefined,
749
+ ...(unchangedEdit ? { unchangedEdit: true as const } : {}),
740
750
  // `app_mention` arrives as its own event type; a `message` whose text contains the bot
741
751
  // ref is also a mention. We don't know our own bot id here, so the event-type check is
742
752
  // the reliable signal; the route also filters app_mention so the mention path owns it.
@@ -749,6 +759,49 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
749
759
  };
750
760
  }
751
761
 
762
+ /**
763
+ * Does a `message_changed` edit leave the message's WORDS and FILES as they were? Pure.
764
+ *
765
+ * 🔴 WHY THIS EXISTS (OPE-2577). Slack raises `message_changed` for more than a person's edit. It
766
+ * raises one each time the message's METADATA changes: a link unfurl, and, in the person's DM with
767
+ * the app, each `agents.sessions.setStatus` call that OE itself makes on the session's root message
768
+ * (slack-agent-session.ts: the title at the door, `processing` at turn start, `active` or
769
+ * `suspended` at the park). The event is hidden: `conversations.history` never lists it, and its
770
+ * `ts` is a fresh notice stamp. Its inner message is the person's own message, with the person's
771
+ * own author. So before this predicate, the DM door woke the seat on every one of them, as a NEW
772
+ * inbound wake. Ship #252 measured it: one DM drew three more wakes within 15 seconds, the
773
+ * reply-freshness gate refused the answer to the person's real wake as `stale-conversation`, and the
774
+ * answer landed under a later edit wake. A voice clip was lost, because an edit is never heard again
775
+ * (slack-voice-clips.ts).
776
+ *
777
+ * An edit that changes no words and no files says nothing new, so no door wakes on it. A REAL edit
778
+ * still wakes: any difference in the typed text, in the quoted-message text Slack parks in
779
+ * `attachments[]` ({@link resolveSlackInboundText}), or in a file's id or its tombstone mark (a deleted file
780
+ * stays as `mode: "tombstone"`) makes this false.
781
+ */
782
+ export function isUnchangedSlackEdit(inner: Record<string, unknown>, previous: Record<string, unknown>): boolean {
783
+ const textOf = (m: Record<string, unknown>): string => resolveSlackInboundText(typeof m.text === "string" ? m.text : "", m);
784
+ if (typeof previous.text !== "string") return false; // no pre-edit text to compare ⇒ treat it as a real edit
785
+ if (textOf(inner) !== textOf(previous)) return false;
786
+ // Each file's identity, plus a tombstone mark: Slack keeps a deleted file's `id` on the message and
787
+ // marks it `mode: "tombstone"`, so a removal changes the key even when the id set does not. Only that
788
+ // one mode counts, so a field that one copy carries and the other omits cannot turn a metadata-only
789
+ // edit into a real one.
790
+ const fileKeys = (m: Record<string, unknown>): string =>
791
+ (Array.isArray(m.files) ? m.files : [])
792
+ .map((f) => {
793
+ const o = typeof f === "object" && f !== null ? (f as Record<string, unknown>) : {};
794
+ const key = [o["id"], o["url_private"], o["name"]].find((v) => typeof v === "string") ?? "";
795
+ return `${String(key)}${o["mode"] === "tombstone" ? ":tombstone" : ""}`;
796
+ })
797
+ .sort()
798
+ .join("\n");
799
+ return fileKeys(inner) === fileKeys(previous);
800
+ }
801
+
802
+ /** The log reason both Slack wake doors give when they drop an {@link isUnchangedSlackEdit} edit. */
803
+ export const UNCHANGED_EDIT_DROP_REASON = "an edit that changed no words and no files (a metadata-only message_changed) — not news";
804
+
752
805
  // --- judged replies: the wake instruction + the silence sentinel -------------
753
806
  //
754
807
  // An engaged thread forwards EVERY human reply to the session (that's what engagement
@@ -1236,7 +1289,7 @@ function seatWakeAddressing(opts: SlackWakeOpts): "direct-mention" | "name-drop"
1236
1289
  * surface", which an mpim is — inventing a fourth value for it would extrapolate a Tag-derived
1237
1290
  * vocabulary with no evidence behind the new entry.
1238
1291
  */
1239
- export function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake {
1292
+ function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake {
1240
1293
  return slackJudgedWake(ev, "direct-mention", opts, "dm");
1241
1294
  }
1242
1295
 
@@ -1296,7 +1349,7 @@ export interface MessageRouteDeps {
1296
1349
  */
1297
1350
  readonly confirmInternal?: (channelId: string) => Promise<boolean>;
1298
1351
  /** Append a durable audit row. Optional; best-effort. */
1299
- readonly audit?: (action: string, detail: Record<string, unknown>) => Promise<void>;
1352
+ readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
1300
1353
  /** Resolve the trigger author's display name for the envelope (fail-soft). Optional so
1301
1354
  * existing test doubles keep compiling AND so a wake never depends on a name lookup — absent
1302
1355
  * or throwing ⇒ the raw id renders. Live wiring points this at {@link fetchUserProfileNames}. */
@@ -1319,7 +1372,7 @@ export function bindableFor(ev: ParsedMessageEvent): BindableContext {
1319
1372
  * them off — the interactivity CLICK routes (OPE-1680), which carry a signed payload rather than a
1320
1373
  * message. ONE builder, so a row written from a click keys to the same partition a message from the
1321
1374
  * same room would. */
1322
- function bindableForSlackIds(ids: {
1375
+ export function bindableForSlackIds(ids: {
1323
1376
  readonly teamId?: string | undefined;
1324
1377
  readonly channelId: string;
1325
1378
  readonly channelType?: string | undefined;
@@ -1513,15 +1566,16 @@ function wakeState(ev: ParsedMessageEvent, threadTs: string, extra?: Record<stri
1513
1566
 
1514
1567
  /** Best-effort durable audit row from a route dep (`engagement.stop` / etc.). Resolves the
1515
1568
  * partition from the surface key so the row is org/surface-scoped like every other audit. */
1516
- async function appendRouteAudit(surfaceKey: string, action: string, detail: Record<string, unknown>): Promise<void> {
1569
+ async function appendRouteAudit(surfaceKey: string, action: string, detail: AuditDetail): Promise<void> {
1517
1570
  try {
1518
1571
  // surfaceKey is `platform/workspace/surface` — split to the partition fields the audit row needs.
1519
1572
  const [platform = "slack", workspace = "", surface = ""] = surfaceKey.split("/");
1520
- const org = resolvePartition({
1573
+ const bindable = {
1521
1574
  channel: { kind: platform },
1522
1575
  session: { auth: { current: { attributes: { team_id: workspace, channel_id: surface }, principalId: "", principalType: "slack-user" } } },
1523
- } as Parameters<typeof resolvePartition>[0]).org;
1524
- await getOperationalStore().appendAudit({ ts: new Date().toISOString(), org, platform, workspace, surface, action, detail });
1576
+ } as Parameters<typeof resolvePartition>[0];
1577
+ const org = resolvePartition(bindable).org;
1578
+ await getOperationalStore().appendAudit({ ts: new Date().toISOString(), org, platform, workspace, surface, ...auditSurfaceModeFrom(bindable), action, detail });
1525
1579
  } catch (err) {
1526
1580
  console.warn(`[open-everywhere:slack] route audit write failed: ${String(err)}`);
1527
1581
  }
@@ -1556,7 +1610,7 @@ async function appendRouteAudit(surfaceKey: string, action: string, detail: Reco
1556
1610
  * new thread still gets told why it's being ignored. Fail-open by contract — a dedupe-store
1557
1611
  * outage posts anyway.
1558
1612
  */
1559
- export async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision> {
1613
+ async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision> {
1560
1614
  return slackSpendGate({
1561
1615
  bindable: bindableFor(ev),
1562
1616
  mode: slackMode(ev.channelType, ev.channelId),
@@ -1607,7 +1661,7 @@ export async function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, t
1607
1661
  * changes the decision. The route never WAITS on the audit sink — it holds the write open instead,
1608
1662
  * so the row lands after the 200 rather than racing the freeze that follows it.
1609
1663
  */
1610
- function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail: Record<string, unknown> = {}): void {
1664
+ function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, detail: AuditDetail = {}): void {
1611
1665
  // HELD ON THE ROUTE, not fire-and-forget (OPE-1648). Every engagement route runs inside the front
1612
1666
  // route's `waitUntil`, but a `void`-ed write is OUTSIDE the promise that holds it — so the row
1613
1667
  // raced the freeze that follows the 200 exactly as the mention door's did.
@@ -1617,7 +1671,7 @@ function auditEngagementWakeDropped(ev: ParsedMessageEvent, reason: string, deta
1617
1671
  /** The awaitable half of {@link auditEngagementWakeDropped}. Never throws. The DM door hands the
1618
1672
  * promise to `waitUntil` directly, because that route returns a 200 immediately and the serverless
1619
1673
  * invocation can freeze before an unawaited write lands. */
1620
- async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail: Record<string, unknown>): Promise<void> {
1674
+ async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, detail: AuditDetail): Promise<void> {
1621
1675
  await appendWakeRow(ev, "surface.wake_dropped", reason, detail);
1622
1676
  }
1623
1677
 
@@ -1625,7 +1679,7 @@ async function appendWakeDroppedRow(ev: ParsedMessageEvent, reason: string, deta
1625
1679
  * DM door's admission row below. It resolves the event's own partition, so a row is always keyed to
1626
1680
  * the surface it happened on, and it swallows every failure: an observability write must never
1627
1681
  * block a route or change its decision. */
1628
- async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail: Record<string, unknown>): Promise<void> {
1682
+ async function appendWakeRow(ev: ParsedMessageEvent, action: string, reason: string, detail: AuditDetail): Promise<void> {
1629
1683
  await appendWakeRowForIds({ teamId: ev.teamId, channelId: ev.channelId, channelType: ev.channelType, userId: ev.userId }, ev.ts, action, reason, detail);
1630
1684
  }
1631
1685
 
@@ -1637,16 +1691,18 @@ async function appendWakeRowForIds(
1637
1691
  ts: string | null,
1638
1692
  action: string,
1639
1693
  reason: string,
1640
- detail: Record<string, unknown>,
1694
+ detail: AuditDetail,
1641
1695
  ): Promise<void> {
1642
1696
  try {
1643
- const p = resolvePartition(bindableForSlackIds(ids));
1697
+ const bindable = bindableForSlackIds(ids);
1698
+ const p = resolvePartition(bindable);
1644
1699
  await getOperationalStore().appendAudit({
1645
1700
  ts: new Date().toISOString(),
1646
1701
  org: p.org,
1647
1702
  platform: p.platform,
1648
1703
  workspace: p.workspace,
1649
1704
  surface: p.surface,
1705
+ ...auditSurfaceModeFrom(bindable),
1650
1706
  action,
1651
1707
  detail: { reason, channel: ids.channelId, ts, ...detail },
1652
1708
  });
@@ -1955,6 +2011,9 @@ export async function auditDmDriverAdmitted(ev: ParsedMessageEvent, dmKind: "im"
1955
2011
  dmKind,
1956
2012
  botId: ev.botId ?? null,
1957
2013
  team: ev.teamId ?? null,
2014
+ // The event's subtype, so a reader can tell a typed message from an edit or an upload without
2015
+ // a second source (OPE-2577 had to infer it from the notice-shaped `ts`).
2016
+ subtype: ev.subtype ?? null,
1958
2017
  });
1959
2018
  }
1960
2019
 
@@ -2043,10 +2102,6 @@ function authForEvent(ev: ParsedMessageEvent, facts?: ChannelFacts): OpenEverywh
2043
2102
  });
2044
2103
  }
2045
2104
 
2046
- // Keep mode resolution referenced (the wake scopes to a channel, never mpim/dm here) so a
2047
- // future spike that needs it doesn't re-derive — and to assert the import stays live.
2048
- export const _engagementWakeMode = (ev: ParsedMessageEvent) => slackMode(ev.channelType, ev.channelId);
2049
-
2050
2105
  // --- name-drop wake: judged replies to unmentioned TOP-LEVEL messages ---------
2051
2106
  //
2052
2107
  // Claude Tag's documented behaviour ("a top-level message without a mention may still
@@ -2154,7 +2209,7 @@ export function shouldNameDropWake(input: NameDropInput): ReplyDecision {
2154
2209
  * disqualifiers would go stale the first time somebody adds one to the policy above, and the arm
2155
2210
  * would then reach a message the policy had just started dropping.
2156
2211
  */
2157
- export function nameDropAbstained(input: NameDropInput): boolean {
2212
+ function nameDropAbstained(input: NameDropInput): boolean {
2158
2213
  if (input.textMatchesName) return false;
2159
2214
  return shouldNameDropWake({ ...input, textMatchesName: true }).deliver;
2160
2215
  }
@@ -2717,11 +2772,6 @@ export function botDisplayName(): Promise<string | undefined> {
2717
2772
  return botDisplayNamePromise;
2718
2773
  }
2719
2774
 
2720
- /** Reset the cached display name (tests only). */
2721
- export function _resetBotDisplayName(): void {
2722
- botDisplayNamePromise = undefined;
2723
- }
2724
-
2725
2775
  // Positively confirm a channel is internal via conversations.info, TTL-cached (channels
2726
2776
  // change shared-ness rarely; the API is rate-limited). Any failure → { internal: false }
2727
2777
  // (fail-closed). The SAME response also carries the channel's descriptive metadata
@@ -3234,7 +3284,7 @@ function externalPolicyDropReason(verdict: SlackExternalVerdict): string {
3234
3284
  * On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
3235
3285
  * parallel, and the render budget caps the hydration at ten lines.
3236
3286
  */
3237
- export async function withholdNameDropContext(
3287
+ async function withholdNameDropContext(
3238
3288
  nw: NormalizedWake,
3239
3289
  policy: ExternalPolicy,
3240
3290
  teamId: string | undefined,
@@ -3552,17 +3602,20 @@ async function fetchChannelTranscript(channelId: string): Promise<string | undef
3552
3602
  // `handle` = the person's SELF-SET display name (Tag treats the handle as the better human-address
3553
3603
  // signal). Both are UNTRUSTED, attacker-controllable profile content — the wake renderer XML-escapes
3554
3604
  // them wherever they land in the envelope frame.
3555
- const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string } | undefined>>();
3556
- export function fetchUserProfileNames(userId: string): Promise<{ name?: string; handle?: string } | undefined> {
3605
+ const userProfileCache = new Map<string, Promise<{ name?: string; handle?: string; email?: string } | undefined>>();
3606
+ export function fetchUserProfileNames(userId: string): Promise<{ name?: string; handle?: string; email?: string } | undefined> {
3557
3607
  let p = userProfileCache.get(userId);
3558
3608
  if (!p) {
3559
3609
  p = (async () => {
3560
3610
  const info = await slackForm("users.info", `user=${encodeURIComponent(userId)}`);
3561
3611
  if (info?.ok !== true) return undefined;
3562
- const user = info.user as { profile?: { display_name?: string; real_name?: string }; real_name?: string; name?: string } | undefined;
3612
+ const user = info.user as { profile?: { display_name?: string; real_name?: string; email?: string }; real_name?: string; name?: string } | undefined;
3563
3613
  const handle = user?.profile?.display_name?.trim() || undefined;
3564
3614
  const name = user?.profile?.real_name?.trim() || user?.real_name?.trim() || user?.name?.trim() || undefined;
3565
- return { name, handle };
3615
+ // The workspace-verified address, present only under the `users:read.email` scope (OPE-2482): the
3616
+ // identifier a person can check beside a self-set name.
3617
+ const email = user?.profile?.email?.trim() || undefined;
3618
+ return { name, handle, ...(email ? { email } : {}) };
3566
3619
  })();
3567
3620
  userProfileCache.set(userId, p);
3568
3621
  }
@@ -4050,7 +4103,7 @@ export interface SupervisorRouteDeps {
4050
4103
  window?: UnifiedWindowWake;
4051
4104
  }) => Promise<void>;
4052
4105
  /** Append a durable audit row (e.g. `engagement.stop`). Optional; best-effort. */
4053
- readonly audit?: (action: string, detail: Record<string, unknown>) => Promise<void>;
4106
+ readonly audit?: (action: string, detail: AuditDetail) => Promise<void>;
4054
4107
  /**
4055
4108
  * ENGAGE the trigger's thread as a PARTICIPATING surface (task #100 — window-riding stop parity):
4056
4109
  * called ONLY on an ADDRESSED `directMention` so a mention diverted into the window enrolls its
@@ -4107,6 +4160,11 @@ export interface SupervisorRouteDeps {
4107
4160
  * route's own singleWindow + internal facts — a directMention flag on a non-single-window or
4108
4161
  * non-internal wake degrades safely to the plain channel-watch treatment. Unset ⇒ byte-identical.
4109
4162
  */
4163
+ /** The Slack user ids a message mentions (`<@U…>` or `<@U…|name>`), in the order it names them. Pure. */
4164
+ export function slackMentionedUserIds(text: string | undefined): string[] {
4165
+ return [...(text ?? "").matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((m) => m[1]!);
4166
+ }
4167
+
4110
4168
  export async function routeSupervisorEvent(
4111
4169
  ev: ParsedMessageEvent,
4112
4170
  deps: SupervisorRouteDeps,
@@ -4506,7 +4564,7 @@ export async function routeSupervisorEvent(
4506
4564
  // `priorContext` map above) and the hydrated lines are decided by one transform, and it returns
4507
4565
  // the wake BY REFERENCE on the `taint` opt-out grain, so nothing moves for a deployment that
4508
4566
  // opted out. Run only on the name-drop tier, which is the only tier that carries the slot.
4509
- const wake = slackChannelWatchWake(ev, {
4567
+ const watchWake = slackChannelWatchWake(ev, {
4510
4568
  mode: "supervisor",
4511
4569
  trust: wakeTrust,
4512
4570
  engagementTier,
@@ -4543,6 +4601,25 @@ export async function routeSupervisorEvent(
4543
4601
  ? { contextLines: nameDropRendered, contextSource: nameDropContext?.source, readMoreHint: nameDropContext?.readMoreHint }
4544
4602
  : {}),
4545
4603
  });
4604
+ // 🔴 OUT-OF-OFFICE COVER (OPE-2513). A message that mentions a person with live cover gets OE's cover note on
4605
+ // THIS wake, and nothing else changes: the binding, the auth bag and the room's memory stay the room's, so the
4606
+ // answer can use only what this room already knows. No window, no wake id, no cover (cover/wake.ts).
4607
+ const cover = await coverForWake({
4608
+ org: p.org,
4609
+ platform: "slack",
4610
+ workspace: p.workspace,
4611
+ surface: p.surface,
4612
+ surfaceKey: p.surfaceKey,
4613
+ channelId: ev.channelId,
4614
+ roomPublic: ev.channelType === "channel",
4615
+ wakeId: window?.wakeId,
4616
+ mentionedIds: slackMentionedUserIds(ev.authoredText),
4617
+ ...(ev.userId ? { askerId: ev.userId } : {}),
4618
+ ...(trigger.authorName ? { askerName: trigger.authorName } : {}),
4619
+ text: ev.authoredText,
4620
+ now: new Date(atIso),
4621
+ }).catch(() => ({ covered: [] }) as { systemNote?: string });
4622
+ const wake = cover.systemNote ? { ...watchWake, systemNote: watchWake.systemNote ? `${watchWake.systemNote}\n${cover.systemNote}` : cover.systemNote } : watchWake;
4546
4623
  const rendered = renderWake(
4547
4624
  nameDropTier ? await withholdNameDropContext(wake, externalPolicy, ev.teamId, deps.authorIsMember) : wake,
4548
4625
  deps.now,