@open-everywhere/provider-slack 0.1.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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,1169 @@
1
+ // The Slack surface. Wires Slack's webhook β†’ an OpenEverywhere session, and β€” the point
2
+ // of the "Slack-binding" slice β€” populates the session auth context with the REAL
3
+ // Slack identity (team/channel/user ids + channel_type + is_private) so the
4
+ // identity seam (agent/identity/binding.ts) resolves the actual workspace, surface,
5
+ // mode (channel/dm/mpim), and visibility (public/private) instead of OE_* env
6
+ // stand-ins. Everything downstream β€” memory partition, access resolver, routine
7
+ // dispatch β€” then keys off real Slack identity with no further change.
8
+ //
9
+ // We override `onAppMention` / `onDirectMessage` only to attach our richer auth
10
+ // (the defaults derive a thinner, workspace-scoped auth). The typing indicator shows
11
+ // a random status VERB per update ("Tinkering…"), configurable per org/domain β€” see
12
+ // statusVerb() below.
13
+ //
14
+ // Credentials: omitted here, so eve falls back to SLACK_BOT_TOKEN /
15
+ // SLACK_SIGNING_SECRET (see .env.example). Swap the credential broker later without
16
+ // touching identity. Capturing channel_type/is_private is a RAW-fact job; the
17
+ // security-critical interpretation (fail-closed to private) lives in the binding.
18
+ import { slackChannel, } from "eve/channels/slack";
19
+ import { buildSlackAuth, channelFactsFromInfo, channelFactsFromRaw, forwardedSlackEnterpriseId, partnerFactsFromInfo, slackMode, } from "./slack-identity.js";
20
+ import { resolvePartition, getOperationalStore, postOnce, outboundKey, statusVerbsFor, getConfig, finalMessageActionForTurn, NO_REPLY_SENTINEL_REASON, auditNoReplySentinelObserved, isBareStop, supervisorTextDelivery, isSupervisorProseLeak, capabilitiesForPlatform, consumeCardPostedThisTurn, consumeReactedThisTurn, consumeRepliedThisTurn, dropClosingProse, markThreadEngaged, disengageThread, getThreadEngagement, bumpThreadSilentStreak, WARM_SILENT_LIMIT, SURFACE, tierId, getProvider, providerById, writeMemoryTier, surfaceProvider, upsertUnderSection, bindFrom, authPrincipalId, isAllowedSlackDriver, planFor, extractPinnedInstructions, introMessage, instructionBullet, ONBOARDED_MARKER, STATUS_REACTION, renderPinned, isPinnedStatusMessage, classifyWakeMapping, unmappedWakeCopy, UNMAPPED_WAKE_DROP_REASON, admittedTenantsFromConfig, unadmittedTenantLog, UNADMITTED_TENANT_DROP_REASON, dmTrustTtlMs, swallowRoomOp, domainRoster, } from "@open-everywhere/core/agent/adapter-api.js";
21
+ import { loadConversationContext } from "./slack-context.js";
22
+ import { slackSpendGate } from "./slack-spend-gate.js";
23
+ import { slackTenantAdmission } from "./slack-tenant-fence.js";
24
+ import { holdRouteWork } from "./slack-route-hold.js";
25
+ import { frontSlackChannel, INTERNAL_SLACK_ROUTE } from "./slack-front.js";
26
+ import { admitSlackInputResponse } from "./slack-input-response.js";
27
+ import { turnEffectsFor, botDisplayName, slackForm, isSupervisorReplyTurn, deliverSupervisorReply, dmWakeThreadOverride, replyThreadTsForState } from "./slack-events.js";
28
+ import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
29
+ // WARM_SILENT_LIMIT (the warm-thread shed threshold) now lives in channels/engagement.ts so
30
+ // Slack and Discord share ONE constant instead of each defining their own (which could drift).
31
+ // --- Status UX (Thariq Β§11): glanceable state for the team -----------------
32
+ // (a) ONE status-emoji reaction on the thread root β€” ⏳ working Β· βœ… done Β·
33
+ // ❓ needs input Β· πŸ›‘ blocked β€” only ever one at a time. (b) a self-maintained
34
+ // pinned message listing all in-flight work, fed from surface-tier memory
35
+ // (the cross-thread substrate the `update_status` tool writes).
36
+ //
37
+ // We touch only events with NO built-in default (session.completed/waiting) plus
38
+ // session.failed (where we re-post the error the default would have), and set
39
+ // "working" from the inbound handlers we already own β€” so the HITL approval UI
40
+ // (input.requested), reply rendering (message.completed), and typing all stay intact.
41
+ // --- status verbs (the fun bit) ----------------------------------------------
42
+ // "Tinkering…" / "Untangling…" instead of a flat "Thinking…"/"Working…" β€” a random
43
+ // pick per status update from the channel's effective vocabulary (framework defaults
44
+ // ← org ← role ← domain, extend-or-replace; memory/domain.ts statusVerbsFor β€” a
45
+ // domain's ROLE preset contributes its themed verbs as its own rung). Orgs add house
46
+ // verbs, a team adds its in-jokes. Cosmetic only, so ANY failure falls back to a
47
+ // plain verb β€” the status line must never take a turn down.
48
+ /** The team a channel belongs to per THIS deploy's domain config (`slack/<team>/<channel>` entries),
49
+ * or undefined when the channel is unmapped. Fail-soft: config errors read as "unknown", never
50
+ * throw into the inbound path. Used only as the last leg of onInbound's teamId heal. */
51
+ function teamIdFromDomainConfig(channelId) {
52
+ if (!channelId)
53
+ return undefined;
54
+ try {
55
+ const suffix = `/${channelId}`;
56
+ for (const d of getConfig().loadDomains()) {
57
+ for (const c of domainRoster(d)) {
58
+ if (c.startsWith("slack/") && c.endsWith(suffix)) {
59
+ const team = c.split("/")[1];
60
+ if (team)
61
+ return team;
62
+ }
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ /* config unavailable β†’ no heal */
68
+ }
69
+ return undefined;
70
+ }
71
+ function statusVerb(state) {
72
+ try {
73
+ const p = resolvePartition({
74
+ channel: { kind: "slack" },
75
+ session: { auth: { current: { attributes: { team_id: state.teamId ?? "", channel_id: state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
76
+ });
77
+ const cfg = getConfig();
78
+ const verbs = statusVerbsFor(p.surfaceKey, cfg.loadDomains(), cfg.loadRoles?.() ?? []);
79
+ return `${verbs[Math.floor(Math.random() * verbs.length)]}…`;
80
+ }
81
+ catch {
82
+ return "Working…";
83
+ }
84
+ }
85
+ // Shared by `reasoning.appended` and `actions.requested`: ALWAYS the clean status verb,
86
+ // NEVER the model's own in-progress text. We used to show the model's pre-tool-call
87
+ // preamble here (Tag-style, written for humans) β€” dropped 2026-07 (Tommy, live): that
88
+ // text streams in mid-sentence, so it could flash a truncated fragment ("I'll go away and
89
+ // look to setu…") for a moment before reverting to a verb, which read as broken. One
90
+ // helper, one behaviour, so neither call site can drift back to surfacing partial text.
91
+ async function refreshTypingStatus(channel) {
92
+ try {
93
+ await setWakeTyping(channel, statusVerb(channel.state));
94
+ }
95
+ catch {
96
+ /* typing indicator is best-effort */
97
+ }
98
+ }
99
+ // OPE-98: set / refresh / clear the assistant thinking-status on the turn's PER-WAKE thread. A DM
100
+ // seat (OPE-90) is ONE per-channel eve session spanning many assistant threads, but `channel.thread`
101
+ // is bound to the session's FROZEN `state.threadTs` (the first wake's thread), so for a wake that
102
+ // landed in a DIFFERENT thread it would set/clear the spinner on the WRONG thread β€” the OPE-98
103
+ // stuck-spinner. When there's a per-wake override (slack-events.ts dmWakeThreadOverride) we call
104
+ // `assistant.threads.setStatus` host-side on the wake thread instead; an empty status clears it.
105
+ // Every non-DM turn (and a same-thread DM wake) has no override β‡’ this is exactly
106
+ // `channel.thread.startTyping(status)`, byte-identical to pre-OPE-98.
107
+ async function setWakeTyping(channel, status) {
108
+ const override = dmWakeThreadOverride(channel.state);
109
+ const channelId = channel.state.channelId ?? undefined;
110
+ if (override && channelId) {
111
+ try {
112
+ const body = new URLSearchParams({ channel_id: channelId, thread_ts: override, status: status ?? "" });
113
+ await slackForm("assistant.threads.setStatus", body.toString());
114
+ }
115
+ catch {
116
+ /* thinking-status is best-effort */
117
+ }
118
+ return;
119
+ }
120
+ await channel.thread.startTyping(status);
121
+ }
122
+ // OPE-98 companion to setWakeTyping for POSTING text (an error notice) on the per-wake thread. With
123
+ // a DM-seat override, post host-side to the wake thread so the message doesn't land in the frozen
124
+ // session thread; otherwise eve's `channel.thread.post` (byte-identical for every non-DM path). The
125
+ // normal reply post is the `reply` tool's, which resolves its own per-wake target from the binding.
126
+ async function postWakeText(channel, text) {
127
+ const override = dmWakeThreadOverride(channel.state);
128
+ const channelId = channel.state.channelId ?? undefined;
129
+ if (override && channelId) {
130
+ const body = new URLSearchParams({ channel: channelId, text, thread_ts: override });
131
+ await slackForm("chat.postMessage", body.toString());
132
+ return;
133
+ }
134
+ await channel.thread.post(text);
135
+ }
136
+ /**
137
+ * The `chat.postMessage` form body for a channel-supervisor's host-side reply post (OPE-878).
138
+ *
139
+ * `deliverSupervisorReply`'s `post` callback bypasses the post-arm registry (slack-post-arm.ts) β€”
140
+ * it builds its own `slackForm` call rather than going through `dispatchSlackReply` β€” so it needs
141
+ * its OWN `markdownToSlackMrkdwn` conversion at the wire; that seam does not cover it. This is that
142
+ * ONE conversion point, extracted so a test can drive it without a live Slack call (the closure
143
+ * above cannot be invoked directly β€” it captures live session state).
144
+ */
145
+ export function supervisorPostFormBody(channelId, threadTs, text) {
146
+ const body = new URLSearchParams({ channel: channelId, text: markdownToSlackMrkdwn(text) });
147
+ if (threadTs)
148
+ body.set("thread_ts", threadTs);
149
+ return body.toString();
150
+ }
151
+ // --- quiet failure for UNINVITED turns --------------------------------------
152
+ // A name-drop wake barges into a conversation the agent was never asked into; until it
153
+ // actually POSTS a reply it has no standing there, so a failed turn must leave the
154
+ // surface untouched (no error post, no reaction) β€” observed live: a gateway outage
155
+ // posted raw errors into threads that would otherwise have judged NO_REPLY. The
156
+ // failure still goes to logs + the audit trail so the operator sees it. The flag rides
157
+ // the wake's `state` (slack-events.ts sets oeUninvited) and is CLEARED the moment a
158
+ // reply posts β€” a participating agent's failures go loud again.
159
+ function isUninvited(state) {
160
+ return state?.oeUninvited === true;
161
+ }
162
+ async function recordQuietFailure(state, event, message) {
163
+ console.warn(`[open-everywhere:slack] ${event} on an uninvited turn β€” suppressed on-surface (${message})`);
164
+ try {
165
+ const p = resolvePartition({
166
+ channel: { kind: "slack" },
167
+ session: { auth: { current: { attributes: { team_id: state.teamId ?? "", channel_id: state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
168
+ });
169
+ await getOperationalStore().appendAudit({
170
+ ts: new Date().toISOString(),
171
+ org: p.org,
172
+ platform: p.platform,
173
+ workspace: p.workspace,
174
+ surface: p.surface,
175
+ action: "surface.quiet_fail",
176
+ detail: { event, threadTs: state.threadTs ?? undefined, message: message.slice(0, 500) },
177
+ });
178
+ }
179
+ catch (err) {
180
+ console.warn(`[open-everywhere:slack] quiet-fail audit write failed: ${String(err)}`);
181
+ }
182
+ }
183
+ // Set the thread-root status reaction (best-effort). The bot's vocabulary is narrow by
184
+ // design (status/status.ts header): πŸ‘€ while working, πŸ›‘ on failure, and β€” the key move β€”
185
+ // "none" REMOVES rather than replaces when work ends: absence is the done signal, the
186
+ // posted reply is the result, and βœ… stays reserved for humans.
187
+ async function setStatusReaction(slack, state, threadTsOverride) {
188
+ const channel = slack.channelId;
189
+ // OPE-98: on a DM seat the status emoji was SET on the wake thread's root (onInbound uses the
190
+ // wake-bound ctx.slack) but the handlers CLEAR via the session-bound `channel.slack` (the frozen
191
+ // seat thread) β€” so the βœ• lands on the wrong message and the eyes stay stuck. `threadTsOverride`
192
+ // (dmWakeThreadOverride, undefined for every non-DM turn β‡’ byte-identical) retargets the clear
193
+ // onto the same message the set used.
194
+ const timestamp = threadTsOverride ?? slack.threadTs;
195
+ if (!channel || !timestamp)
196
+ return;
197
+ // The shared egress exit (core's channels/egress-spoof.ts, OPE-1132). This glyph is a room post,
198
+ // and it is the ONE Slack write in this bundle that does not travel through core's Slack bridge β€”
199
+ // `slack.request` is eve's own channel handle, so the bridge's gate never sees it. Gated here for
200
+ // that reason alone; every other Slack write this deployment makes is already covered upstream.
201
+ if (swallowRoomOp("a slack status reaction"))
202
+ return;
203
+ for (const [s, name] of Object.entries(STATUS_REACTION)) {
204
+ try {
205
+ await slack.request(s === state ? "reactions.add" : "reactions.remove", { channel, name, timestamp });
206
+ }
207
+ catch {
208
+ /* already-reacted / no-reaction / missing-scope are all non-fatal */
209
+ }
210
+ }
211
+ }
212
+ // Mirror surface-tier STATUS.md to a single pinned Slack message (find it via a
213
+ // stable header marker so we don't persist a message ts across sessions).
214
+ async function syncPinnedStatus(channel) {
215
+ try {
216
+ const { channelId, teamId } = channel.state;
217
+ if (!channelId)
218
+ return;
219
+ // Resolve the surface partition from the channel's own ids (SessionContext
220
+ // here lacks `channel.kind`, so feed the binding a slack-shaped stand-in).
221
+ const stub = {
222
+ channel: { kind: "slack" },
223
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
224
+ };
225
+ const p = resolvePartition(stub);
226
+ // The SAME provider `update_status` wrote through (OPE-1051) β€” a DM channel id ("D…")
227
+ // resolves `mode: "dm"` here even with no `channel_type` attribute set (slackMode's id-prefix
228
+ // fallback), so this stays the DB-pinned personal silo's provider for a DM and the
229
+ // env-selected singleton for a real channel, exactly like the write side.
230
+ const files = await surfaceProvider(bindFrom(stub)).readTier(p, SURFACE);
231
+ const text = renderPinned(files["STATUS.md"]);
232
+ const slack = channel.slack;
233
+ const list = await slack.request("pins.list", { channel: channelId });
234
+ let ts;
235
+ const items = list.items;
236
+ if (list.ok && Array.isArray(items)) {
237
+ for (const it of items) {
238
+ const m = it?.message;
239
+ if (m && typeof m.text === "string" && isPinnedStatusMessage(m.text) && typeof m.ts === "string") {
240
+ ts = m.ts;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+ if (ts) {
246
+ await slack.request("chat.update", { channel: channelId, ts, text });
247
+ }
248
+ else {
249
+ const posted = await slack.request("chat.postMessage", { channel: channelId, text });
250
+ const postedTs = posted.ts;
251
+ if (posted.ok && typeof postedTs === "string") {
252
+ await slack.request("pins.add", { channel: channelId, timestamp: postedTs });
253
+ }
254
+ }
255
+ }
256
+ catch {
257
+ /* status mirroring is best-effort, never fails the task */
258
+ }
259
+ }
260
+ // Best-effort: enrich an app_mention's facts with the public/private bit, which
261
+ // the mention envelope doesn't carry, via conversations.info. On any failure we
262
+ // leave is_private unknown β€” the binding fails closed (treats it as private).
263
+ async function enrichVisibility(ctx, channelId, base) {
264
+ if (base.isPrivate !== undefined)
265
+ return base;
266
+ try {
267
+ const info = await ctx.slack.request("conversations.info", { channel: channelId });
268
+ return { ...base, ...channelFactsFromInfo(info) };
269
+ }
270
+ catch {
271
+ return base; // unknown -> fail closed in the binding
272
+ }
273
+ }
274
+ // Best-effort, TTL-cached: is the DM PARTNER an internal member? A DM's `trust` now gates its
275
+ // SHARED read layer (internal partner β†’ `dm`, external β†’ `public`), so we positively determine it
276
+ // via users.info. On any failure we leave it unset β€” the binding fails closed (external). Cached
277
+ // per user (DMs repeat; users.info is rate-limited and membership changes rarely).
278
+ const dmTrustCache = new Map();
279
+ async function enrichDmPartner(ctx, message, base) {
280
+ const userId = message.author?.userId;
281
+ if (!userId)
282
+ return base; // no actor β†’ unverified β†’ fail closed
283
+ const hit = dmTrustCache.get(userId);
284
+ if (hit && Date.now() - hit.at < dmTrustTtlMs())
285
+ return { ...base, dmPartnerTrust: hit.trust };
286
+ try {
287
+ const info = await ctx.slack.request("users.info", { user: userId });
288
+ const { dmPartnerTrust } = partnerFactsFromInfo(info, message.teamId);
289
+ dmTrustCache.set(userId, { trust: dmPartnerTrust, at: Date.now() });
290
+ return { ...base, dmPartnerTrust };
291
+ }
292
+ catch {
293
+ return base; // lookup failed β†’ unverified β†’ fail closed in the binding
294
+ }
295
+ }
296
+ async function resolveFacts(ctx, message, isDm) {
297
+ return isDm
298
+ ? enrichDmPartner(ctx, message, { channelType: "im", isPrivate: true }) // DM is unambiguous; verify the partner
299
+ : enrichVisibility(ctx, message.channelId, channelFactsFromRaw(message.raw));
300
+ }
301
+ function authFromFacts(message, facts) {
302
+ const author = message.author;
303
+ if (!author)
304
+ return null; // system event with no actor β€” drop
305
+ // The Enterprise Grid rung (OPE-1508, carried onto this ingress by OPE-1559). This door is handed
306
+ // the INNER event, so the rung cannot come off `message` β€” the front route stows the envelope's
307
+ // `enterprise_id` around the forward and this is the read back. Spread conditionally, the same way
308
+ // every other wake route spreads it: a non-Grid delivery stamps no attribute, so its trust walk is
309
+ // byte-identical to what it was.
310
+ const enterpriseId = forwardedSlackEnterpriseId(message);
311
+ return buildSlackAuth({
312
+ userId: author.userId,
313
+ teamId: message.teamId,
314
+ ...(enterpriseId === undefined ? {} : { enterpriseId }),
315
+ channelId: message.channelId,
316
+ threadTs: message.threadTs,
317
+ channelType: facts.channelType,
318
+ isPrivate: facts.isPrivate,
319
+ isExternal: facts.isExternal,
320
+ trustChecked: facts.trustChecked,
321
+ dmPartnerTrust: facts.dmPartnerTrust,
322
+ });
323
+ }
324
+ // First-act onboarding for a real channel: post an intro, and adopt the channel's
325
+ // pinned instructions as STANDING INSTRUCTIONS in its memory. Runs once per surface
326
+ // (gated by a marker on the operational SURFACE tier), pre-dispatch β€” so the adopted
327
+ // instructions are synced into THIS session's sandbox at session.started. Best-effort.
328
+ //
329
+ // The marker is per-surface operational state (alongside STATUS.md); adopted
330
+ // instructions go to the channel's WRITABLE memory target (its domain's silo). A
331
+ // channel with no domain memory (or an externally-shared one) still gets the intro,
332
+ // just no adopted instructions β€” there's nowhere durable for them to live.
333
+ //
334
+ // πŸ”΄ THE ENV-SELECTED `getProvider()` HERE IS CORRECT, NOT THE OMISSION OPE-1051 FOUND (below).
335
+ // The one call site (`if (mode === "channel") await maybeOnboard(…)`, further down this file)
336
+ // gates this function to real channels only β€” never a DM, never a group DM β€” so its SURFACE-tier
337
+ // marker never collides with the DM personal silo's DB pin. If a future caller ever invokes this
338
+ // for a DM, route it through `surfaceProvider(bindFrom(where))` first, the same fix `update_status`
339
+ // and `syncPinnedStatus` got.
340
+ async function maybeOnboard(ctx, where) {
341
+ try {
342
+ const p = resolvePartition(where);
343
+ const marker = await getProvider().ensureTier(p, SURFACE); // operational marker tier (registered provider β€” see syncPinnedStatus)
344
+ if (marker[ONBOARDED_MARKER] !== undefined)
345
+ return; // already onboarded
346
+ let adopted = false;
347
+ try {
348
+ const pins = await ctx.slack.request("pins.list", { channel: ctx.slack.channelId });
349
+ const instructions = extractPinnedInstructions(pins);
350
+ const target = instructions ? planFor(bindFrom(where), p).writes[0] : undefined;
351
+ if (instructions && target) {
352
+ const provider = providerById(target.silo.provider);
353
+ const mem = await provider.ensureTier(p, target.tier, target.silo, target.access);
354
+ const date = new Date().toISOString().slice(0, 10);
355
+ mem["MEMORY.md"] = upsertUnderSection(mem["MEMORY.md"] ?? "", "Standing instructions", instructionBullet(instructions, date));
356
+ // THE ONE ROAD (OPE-1873). Adopting a channel's pinned instructions puts CONTENT into a
357
+ // silo's MEMORY.md, so it is a `memory.write` like any other remembered fact β€” before this
358
+ // it wrote the silo and left no row at all.
359
+ await writeMemoryTier({
360
+ provider,
361
+ partition: p,
362
+ tier: target.tier,
363
+ files: mem,
364
+ message: `adopt pinned instructions [${tierId(target.tier)}]`,
365
+ silo: target.silo,
366
+ access: target.access,
367
+ road: "pinned-instructions",
368
+ });
369
+ adopted = true;
370
+ }
371
+ }
372
+ catch {
373
+ /* pins unavailable / no writable memory β€” still post the intro and mark onboarded */
374
+ }
375
+ marker[ONBOARDED_MARKER] = new Date().toISOString();
376
+ // THE ONE ROAD (OPE-1873), as an OPERATIONAL write β€” the marker is bookkeeping, not a fact.
377
+ await writeMemoryTier({
378
+ provider: getProvider(),
379
+ partition: p,
380
+ tier: SURFACE,
381
+ files: marker,
382
+ message: `onboarding ${p.surfaceKey}`,
383
+ road: "onboarding-marker",
384
+ kind: "operational",
385
+ });
386
+ try {
387
+ // Introduce as the bot's actual workspace name (fail-soft β†’ product-name fallback).
388
+ await ctx.thread.post(introMessage(adopted, await botDisplayName()));
389
+ }
390
+ catch {
391
+ /* best-effort */
392
+ }
393
+ }
394
+ catch {
395
+ /* onboarding never blocks the task */
396
+ }
397
+ }
398
+ // A BindableContext for an inbound message, BEFORE the session (and its auth) exist β€”
399
+ // built from the message's own ids + facts via the same binding the session would use.
400
+ // Carries the external/trust facts so the trust gate resolves correctly pre-dispatch.
401
+ function bindableForMessage(message, facts) {
402
+ const attributes = {
403
+ team_id: message.teamId ?? "",
404
+ channel_id: message.channelId,
405
+ channel_type: facts.channelType ?? "",
406
+ };
407
+ if (typeof facts.isExternal === "boolean")
408
+ attributes.is_ext_shared = String(facts.isExternal);
409
+ if (typeof facts.trustChecked === "boolean")
410
+ attributes.trust_checked = String(facts.trustChecked);
411
+ if (facts.dmPartnerTrust)
412
+ attributes.dm_partner_trust = facts.dmPartnerTrust;
413
+ return {
414
+ channel: { kind: "slack" },
415
+ session: { auth: { current: { attributes, principalId: message.author?.userId ?? "", principalType: "slack-user" } } },
416
+ };
417
+ }
418
+ function partitionForMessage(message, facts) {
419
+ return resolvePartition(bindableForMessage(message, facts));
420
+ }
421
+ /**
422
+ * THE DROP. Returning `{ auth: null }` from `onAppMention`/`onDirectMessage` does **not** drop the
423
+ * wake β€” eve dispatches the turn anyway, just auth-less. From eve's own type docs
424
+ * (`SlackMentionResult`): *"Return an object (auth may be `null`) to dispatch a turn, or `null` to
425
+ * drop the inbound message."* Verified against the compiled 0.27.5 channel 2026-07-25: a handler
426
+ * returning `{ auth: null }` still reaches `deliverSlackMessage` β†’ `send({auth: null})` β†’ a full
427
+ * model turn on an auth-less session (`slack/dev/default`, no principal, guest).
428
+ *
429
+ * That counterfeit drop is what defeated the spend cap (OPE-33): the bot-author leg below "dropped"
430
+ * a non-allowlisted bot mention with `{ auth: null }` β€” ABOVE the spend gate β€” so the turn ran
431
+ * having never consulted a budget, and the capped surface kept spending with zero `spend.gate`
432
+ * rows to show for it. The declined-by-cap and stop-request legs had the same hole: the refusal
433
+ * posted (or the stop was acked) and the model answered anyway.
434
+ *
435
+ * Every "no turn" exit therefore returns THIS constant. `slack-spend-gate-test.ts` scans this file
436
+ * for the counterfeit shape so the class of bug can't come back.
437
+ */
438
+ const DROP = null;
439
+ /**
440
+ * Make a dropped wake OBSERVABLE. The webhook function's console output never reaches
441
+ * `vercel logs` (only workflow-process invocations stream), so a `console.log` on a drop is
442
+ * invisible in production β€” which is most of why the OPE-33 diagnosis was a day of guessing at
443
+ * an absence. One tiny `surface.wake_dropped` row per drop puts the reason on the audit trail
444
+ * the control API can read. Best-effort by contract: never throws, never changes the decision β€”
445
+ * and the drop path AWAITS it, so the row lands before the hook returns. Volume is negligible
446
+ * (bot mentions only), and a drop is by definition a turn nobody is waiting on.
447
+ *
448
+ * πŸ”΄ IT RETURNS ITS PROMISE, AND EVERY CALL SITE HOLDS IT (OPE-1648). This used to be a bare
449
+ * `void (async () => …)`. The events route answers its 200 at once β€” eve returns the response and
450
+ * dispatches this hook in the route's `waitUntil` β€” so on Vercel the invocation can freeze behind
451
+ * the response and a fire-and-forget write loses the row this function calls the only durable
452
+ * signal a drop has. Each call site AWAITS it before returning `DROP`, which keeps eve's own
453
+ * dispatch promise pending until the row lands β€” and that promise is in the snapshot the platform
454
+ * waits on. `holdRouteWork` additionally tracks it on the delivery's keepalive
455
+ * (slack-route-hold.ts), which is what covers a writer no caller can await.
456
+ */
457
+ function auditWakeDropped(message, reason, detail = {}) {
458
+ return holdRouteWork((async () => {
459
+ try {
460
+ const p = resolvePartition({
461
+ channel: { kind: "slack" },
462
+ session: { auth: { current: { attributes: { team_id: message.teamId ?? "", channel_id: message.channelId }, principalId: "", principalType: "slack-user" } } },
463
+ });
464
+ await getOperationalStore().appendAudit({
465
+ ts: new Date().toISOString(),
466
+ org: p.org,
467
+ platform: p.platform,
468
+ workspace: p.workspace,
469
+ surface: p.surface,
470
+ action: "surface.wake_dropped",
471
+ detail: { reason, channel: message.channelId, ts: message.ts, ...detail },
472
+ });
473
+ }
474
+ catch {
475
+ /* observability only */
476
+ }
477
+ })());
478
+ }
479
+ async function onInbound(ctx, message, isDm,
480
+ /** Set by the spend-gate consult below; {@link admitInbound} refuses to admit a turn without it. */
481
+ consult) {
482
+ // TEAM-ID HEAL (live-caught 2026-07-25): app-authored message events carry NO inner `team`
483
+ // field, so message.teamId arrives undefined for any driver/app-posted mention. Left unhealed,
484
+ // EVERY downstream derivation quietly degrades β€” the workspace binds the "dev" fallback, the
485
+ // spend gate keys "slack/dev/…" (a capped surface never matches β‡’ the cap silently never
486
+ // trips), and the session binds memoryless/guest. The thread handle knows the installed team;
487
+ // backfill once here so facts, the spend gate, partition and auth all scope the real workspace.
488
+ if (!message.teamId) {
489
+ const threadTeam = ctx.thread.teamId;
490
+ const healed = typeof threadTeam === "string" && threadTeam.length > 0
491
+ ? threadTeam
492
+ : // Pre-dispatch the thread handle's state isn't hydrated (teamId null β€” live-observed), so
493
+ // fall back to the deploy's own config: a mapped channel is declared fully-qualified as
494
+ // `slack/<team>/<channel>` in domains.ts, which names the team authoritatively. An
495
+ // unmapped channel in a message with no team stays unhealed (same degraded posture as
496
+ // before β€” nothing to invent).
497
+ teamIdFromDomainConfig(message.channelId);
498
+ if (healed)
499
+ message = { ...message, teamId: healed };
500
+ }
501
+ // Bot-author gate (loop safety, mirrors the Discord gateway's drop). Eve's DM leg
502
+ // already filters bot_id, but its app_mention leg does NOT (verified in the compiled
503
+ // channel, 2026-07-07) β€” so without this, another bot @mentioning ours is an un-gated
504
+ // wake: burned inference at best, a mention ping-pong at worst. Our own engagement and
505
+ // name-drop wake paths drop bot authors too (slack-events.ts authorIsBot); this closes
506
+ // the one remaining leg. ONE carve-out, same as the Discord gateway: an allowlisted e2e
507
+ // driver (OE_TEST_DRIVER_IDS `teamId:botId`). The driver posts with a USER token, but
508
+ // Slack stamps app-authored posts with the app's bot_id regardless (live 2026-07-07 β€”
509
+ // the plan's "xoxp needs no seam" premise was wrong). Fail-closed, guild^Wteam-pinned,
510
+ // exact-id, loud β€” see testing/driver-allowlist.ts for the full posture.
511
+ //
512
+ // CORRECTION (OPE-33, 2026-07-25): this used to `return { auth: null }` and note that doing so
513
+ // "doesn't even DROP the wake β€” eve still runs the mention with no attributes, so the session
514
+ // binds fail-closed external and memoryless". That was the bug, not a tolerable degradation:
515
+ // an un-gated model turn ran on every non-allowlisted bot mention, ABOVE the spend gate, so a
516
+ // capped surface kept spending. It is now a REAL drop (see DROP). Note the consequence for the
517
+ // e2e driver: if its `teamId:botId` isn't in OE_TEST_DRIVER_IDS, its mentions no longer produce
518
+ // a degraded reply β€” they produce NO reply plus a `surface.wake_dropped` audit row saying so.
519
+ if (message.author?.isBot) {
520
+ const rawBotId = typeof message.raw?.bot_id === "string" ? message.raw.bot_id : undefined;
521
+ if (!isAllowedSlackDriver(message.teamId, rawBotId)) {
522
+ // A REAL drop (was `{ auth: null }`, which eve dispatches anyway β€” see DROP). This leg sat
523
+ // ABOVE the spend gate, so the counterfeit drop admitted an un-gated, auth-less model turn
524
+ // for every non-allowlisted bot mention: the OPE-33 live signature (a reply, a guest
525
+ // `slack/dev/default` session, and zero `spend.gate` rows).
526
+ console.log(`[open-everywhere:slack] bot-authored mention dropped β€” no turn (bot ${rawBotId ?? "?"} in team ${message.teamId ?? "?"} is not an allowlisted e2e driver)`);
527
+ await auditWakeDropped(message, "bot author is not an allowlisted driver", { botId: rawBotId ?? null, team: message.teamId ?? null });
528
+ return DROP;
529
+ }
530
+ console.log(`[open-everywhere:slack] e2e driver accepted: bot-authored mention from ${rawBotId} in team ${message.teamId} (allowlisted)`);
531
+ }
532
+ const facts = await resolveFacts(ctx, message, isDm);
533
+ const mode = slackMode(facts.channelType, message.channelId);
534
+ // UNMAPPED-CHANNEL GUARD (OPE-119) β€” the TENANT floor, above the spend gate because a wake with
535
+ // no workspace can't even be keyed for a cap ("slack/dev/…" matches nothing). The heal above is
536
+ // the only chance this wake had to learn its team; if it still has none, the channel is in no
537
+ // domain and NOTHING downstream can recover β€” the binding files the session under the env
538
+ // stand-in workspace, trust fails closed with no principal, and the read/memory tools refuse one
539
+ // by one. That turn is un-answerable, so it does not run: post the config nudge and DROP.
540
+ // Rationale + scope (channels only, real platforms only, and why this is NOT "the channel has no
541
+ // domain") in access/wake-mapping.ts.
542
+ // TENANT FENCE (OPE-309 phase 0c) β€” the unmapped guard's sibling, one question further along:
543
+ // that one asks "did this wake resolve a team at all", this asks "is it a team this deployment
544
+ // ADMITS". Above the spend gate for the same reason, and BEFORE the unmapped guard's nudge
545
+ // because a foreign workspace must not be handed our config-hole copy. DMs included, no
546
+ // exemption: a Slack DM carries its own team_id, and a DM is exactly where a colliding id
547
+ // resolves somebody's PERSONAL credentials. Rationale + the four proceed-paths (including why an
548
+ // undeclared deployment stays inert) live in access/tenant-admission.ts.
549
+ // THE GRID RUNG (OPE-1645) β€” the mention door reads its `enterprise_id` off the carrier the front
550
+ // route stows, exactly as `authFromFacts` does below, because eve hands this hook the INNER event
551
+ // and the envelope that named the Grid is gone by then. A single-workspace install carries none
552
+ // and the fence then judges the workspace alone, as before.
553
+ const mentionEnterpriseId = forwardedSlackEnterpriseId(message);
554
+ if (slackTenantAdmission(message.teamId, mentionEnterpriseId) === "unadmitted") {
555
+ console.log(unadmittedTenantLog("slack", message.teamId, admittedTenantsFromConfig("slack"), mentionEnterpriseId));
556
+ await auditWakeDropped(message, UNADMITTED_TENANT_DROP_REASON, {
557
+ door: "mention",
558
+ team: message.teamId ?? null,
559
+ enterprise: mentionEnterpriseId ?? null,
560
+ });
561
+ return DROP;
562
+ }
563
+ if (classifyWakeMapping({ platform: "slack", mode, workspaceId: message.teamId }) === "unmapped") {
564
+ console.log(`[open-everywhere:slack] unmapped-channel wake dropped β€” no turn (channel ${message.channelId} resolved no team; map it in agent/memory/config/domains.ts)`);
565
+ await auditWakeDropped(message, UNMAPPED_WAKE_DROP_REASON, { door: "mention", channelType: facts.channelType ?? null });
566
+ try {
567
+ await ctx.thread.post(unmappedWakeCopy("slack", message.channelId));
568
+ }
569
+ catch {
570
+ /* best-effort β€” a lost nudge must not turn the drop into a turn */
571
+ }
572
+ return DROP;
573
+ }
574
+ // Spend cap β€” MENTION DOOR, and the ADMISSION CHOKEPOINT for it: every path from here on either
575
+ // returns DROP or admits a turn, and admitInbound refuses to admit unless this consult ran. The
576
+ // ENGAGEMENT door (slack-events.ts slackWakeSpendGate β€” three routes, one helper) is the other
577
+ // chokepoint. One gate, two doors (OPE-33). DMs are gated too since OPE-267 (an ORG ADMIN still
578
+ // gets through, via the audited spend/bypass.ts escape the gate consults); no-op until caps are
579
+ // configured. Decision table, refusal copy, admin-bypass semantics and the audit row all live in
580
+ // slack-spend-gate.ts.
581
+ //
582
+ // The refusal posts through `ctx.thread`, whose binding eve rooted at `message.threadTs` β€” and
583
+ // eve's parser reads that as `thread_ts || ts`, so a TOP-LEVEL mention roots on its own ts. The
584
+ // refusal therefore lands as a THREADED reply under the wake's root message, which is what a
585
+ // `conversations.replies` poll on the mention's ts (the S10 driver) can see.
586
+ const gate = await slackSpendGate({
587
+ bindable: bindableForMessage(message, facts),
588
+ mode,
589
+ principalId: message.author?.userId,
590
+ teamId: message.teamId,
591
+ door: "mention",
592
+ post: async (text) => {
593
+ await ctx.thread.post(text);
594
+ },
595
+ });
596
+ consult.gated = true;
597
+ if (gate === "declined") {
598
+ // A REAL drop (was `{ auth: null }`): the refusal used to post AND the model answered anyway,
599
+ // which is precisely the "NO model turn follows the refusal" guarantee S10 asserts. (No status
600
+ // reaction to clear β€” the gate runs before the πŸ‘€ is set.)
601
+ return DROP;
602
+ }
603
+ // First-act onboarding (real channels only β€” not DMs / execution-only group DMs).
604
+ if (mode === "channel") {
605
+ await maybeOnboard(ctx, bindableForMessage(message, facts));
606
+ }
607
+ try {
608
+ await ctx.thread.startTyping(statusVerb({ channelId: message.channelId, teamId: message.teamId }));
609
+ }
610
+ catch {
611
+ /* typing indicator is best-effort */
612
+ }
613
+ // πŸ‘€ β€” seen it, working on it. Removed when the turn ends (absence = done).
614
+ await setStatusReaction(ctx.slack, "working");
615
+ // Component 2's STOP LEG USED TO BE HERE and is deleted (OPE-418). It ran a regex over the
616
+ // mention body and, on a match, disengaged the thread and posted a fixed sentence β€” before any
617
+ // model call, which is why it existed at all. Standing down is now judged BETWEEN turns by the
618
+ // agent itself (`stand_down`), because the phrasing is unbounded and a pattern over it produces
619
+ // false positives at about the rate it produces true ones ("don't stop the deploy" was one
620
+ // careful clause away from disengaging the thread). The thing this leg was genuinely good at β€”
621
+ // being FREE β€” is preserved where it actually matters, on the CANCEL affordance (βœ‹ / `/oe stop`),
622
+ // which is pre-model by construction.
623
+ // Component 1: inject the surrounding conversation so the agent understands what's
624
+ // happening in the channel/thread, not just the bare mention. DMs are skipped β€” the
625
+ // 1:1 history IS the conversation and the durable session already carries it. The
626
+ // context is untrusted-wrapped inside loadConversationContext; best-effort (never
627
+ // blocks the turn). Mark the thread engaged so later non-mention replies route back
628
+ // (Component 2 wake path) without a re-tag.
629
+ let context;
630
+ if (!isDm) {
631
+ context = await loadConversationContext(ctx, message);
632
+ // An @mention is an INVITATION β†’ participating:true (promotes a warm thread + clears
633
+ // its silent streak, and exempts it from warm auto-disengage).
634
+ await markThreadEngaged(partitionForMessage(message, facts), message.threadTs, new Date().toISOString(), { participating: true });
635
+ }
636
+ return { auth: authFromFacts(message, facts), context };
637
+ }
638
+ /**
639
+ * THE WAKE-ADMISSION CHOKEPOINT for eve's native mention/DM door (OPE-33) β€” the only thing wired
640
+ * into `onAppMention`/`onDirectMessage`.
641
+ *
642
+ * A returned turn is a spend commitment, so this wrapper enforces the invariant that inspection
643
+ * kept failing to hold: **nothing is admitted unless the spend gate was consulted for THIS
644
+ * message**. Fail-CLOSED β€” an admission with no consult (a future early-return added above the
645
+ * gate, exactly the shape that broke it) is downgraded to a real DROP and said out loud, rather
646
+ * than quietly spending. A DROP passes straight through (nothing to gate: no turn, no spend).
647
+ *
648
+ * The refusal itself is posted by the gate, so a declined wake is still visible in-thread; this
649
+ * wrapper only decides whether a MODEL TURN follows.
650
+ */
651
+ export async function admitInbound(ctx, message, isDm, run = (consult) => onInbound(ctx, message, isDm, consult)) {
652
+ const consult = { gated: false };
653
+ const outcome = await run(consult);
654
+ if (outcome === DROP)
655
+ return DROP;
656
+ if (!consult.gated) {
657
+ console.warn(`[open-everywhere:slack] wake admitted without a spend-gate consult β€” dropped fail-closed (${message.channelId}/${message.ts})`);
658
+ await auditWakeDropped(message, "admitted without a spend-gate consult β€” fail-closed");
659
+ return DROP;
660
+ }
661
+ return outcome;
662
+ }
663
+ // The mention/DM/interaction pipeline, wrapped at an INTERNAL route so our engagement front
664
+ // route (slack-front.ts) can own the PUBLIC Slack URL and forward to this handler. Both live
665
+ // on ONE channel β†’ one session namespace, so a non-mention wake continues the mention's
666
+ // session).
667
+ const slackMentionChannel = slackChannel({
668
+ route: INTERNAL_SLACK_ROUTE,
669
+ // πŸ”΄ QUEUE, NEVER STEER (OPE-412). eve's own default is `steer`, and a steer delivery carrying
670
+ // a message CANCELS the turn in flight β€” rolling session history back to the last completed
671
+ // model call while that call's tool side effects (posts, PRs, writes) stay real and unrecorded.
672
+ // OE's internal wakes ride this same send path (channels/unified.ts): a task-completion wake, an
673
+ // auto-redrive or a cross-surface divert landing mid-turn would cancel the turn that spawned it.
674
+ turnPolicy: "queue",
675
+ // Both hooks go through the ADMISSION CHOKEPOINT, never onInbound directly (OPE-33).
676
+ onAppMention: (ctx, message) => admitInbound(ctx, message, false),
677
+ onDirectMessage: (ctx, message) => admitInbound(ctx, message, true),
678
+ // πŸ”΄ WHO MAY ANSWER A PARKED QUESTION (OPE-574). Omitting this hook is not "no policy" β€” it is
679
+ // eve's `defaultOnInputResponse`, which accepts the submitting user and compares them to nothing,
680
+ // so any workspace member who could see the thread could answer `ask_question` and the
681
+ // session-limit prompt. The admission runs the SAME room floor the approval click runs
682
+ // (approvals/clicker-floor.ts); see channels/slack-input-response.ts for the shape.
683
+ onInputResponse: (ctx, submission) => admitSlackInputResponse(ctx, submission),
684
+ events: {
685
+ // Snapshot the supervisor's REAL per-turn reply target, then run eve's default
686
+ // turn.started body. eve wraps turn.started specially (verified in the compiled
687
+ // slackChannel): it always sets `triggeringUserId` from the auth, THEN calls the
688
+ // authored handler IN PLACE OF `defaultEvents["turn.started"]` β€” so authoring this
689
+ // handler REPLACES the default's state-clear + "Working…" typing. We therefore
690
+ // replicate that default body verbatim below so NON-supervisor turns stay
691
+ // byte-identical to today; the supervisor snapshot is the only addition, gated on the
692
+ // `oeSupervisor` flag (T1.3's wake state).
693
+ //
694
+ // Why snapshot here (T1.4 / C1): a supervisor session's `state.threadTs` is a NON-EMPTY
695
+ // sentinel (amendment A), so it can't carry the turn's real target. But eve rewrites
696
+ // `session.auth.current` from the DELIVERY's auth on every delivery, and Slack auth
697
+ // attributes carry `channel_id`/`thread_ts` β€” so at turn.started they are THIS turn's
698
+ // real trigger. We copy them into `channel.state.oeReplyTarget` for message.completed to
699
+ // post to. `thread_ts` present ⇔ the trigger was threaded (buildSlackAuth only sets it
700
+ // when the message had a thread_ts); absent ⇔ a top-level channel message.
701
+ "turn.started": async (_data, channel, ctx) => {
702
+ // Supervisor target snapshot FIRST β€” must not depend on the typing call succeeding.
703
+ if (isSupervisorReplyTurn(channel.state)) {
704
+ const attrs = ctx?.session?.auth?.current?.attributes;
705
+ const channelId = typeof attrs?.channel_id === "string" ? attrs.channel_id : undefined;
706
+ const threadTs = typeof attrs?.thread_ts === "string" ? attrs.thread_ts : undefined;
707
+ // channelId missing β‡’ leave no stale target from a prior turn (fail safe: no target
708
+ // means message.completed posts NOTHING, never to a guessed channel).
709
+ channel.state.oeReplyTarget = channelId
710
+ ? { channelId, threadTs }
711
+ : undefined;
712
+ }
713
+ // OPE-645: REPLY PRINCIPAL snapshot β€” same T1.4 pattern as the supervisor target above, for the
714
+ // same reason: message.completed has no live auth ctx, so the canonical row it writes cannot
715
+ // derive WHO from ctx at that point. Since OPE-2006 that row is the DROP row, never a
716
+ // `reply.post`. authPrincipalId(ctx) is the exact read bindFrom(ctx).principalId falls back to
717
+ // for an ordinary human turn (identity/binding.ts) β€” snapshotting it here means the drop row
718
+ // names the same person the turn's egress.policy rows already do.
719
+ channel.state.oeReplyPrincipalId = authPrincipalId(ctx);
720
+ // OPE-98: DM-SEAT per-wake anchor. A DM seat (OPE-90) is ONE per-channel eve session spanning
721
+ // many assistant threads, but eve freezes the session's reply target to the FIRST wake's
722
+ // thread (`state.threadTs`, seeded once at session birth β€” see slack-events.ts's
723
+ // dmWakeThreadOverride header). Snapshot THIS wake's thread from the delivery auth (rewritten
724
+ // per delivery β‡’ the waking message's thread) so the reply + thinking-status ride it. SCOPED
725
+ // to `im` DMs β€” every channel/supervisor/mpim turn leaves `oeWakeThreadTs` unset, so their
726
+ // targeting stays byte-identical.
727
+ {
728
+ const attrs = ctx?.session?.auth?.current?.attributes;
729
+ if (attrs?.channel_type === "im") {
730
+ channel.state.oeWakeThreadTs =
731
+ typeof attrs.thread_ts === "string" ? attrs.thread_ts : undefined;
732
+ }
733
+ }
734
+ // eve's default turn.started body, byte-preserved (see header above).
735
+ channel.state.pendingToolCallMessage = null;
736
+ channel.state.lastReasoningTypingAtMs = null;
737
+ channel.state.lastReasoningTypingStatus = null;
738
+ await setWakeTyping(channel, "Working...");
739
+ },
740
+ // Keep the model's REASONING out of the channel. GLM-5.2 runs with reasoning:"medium",
741
+ // and Eve's default `reasoning.appended` handler pipes the chain-of-thought straight into
742
+ // the Slack typing-status line (startTyping(reasoningText)). That leaks internal thinking
743
+ // into the surface. We override it to refresh a GENERIC status instead β€” the indicator
744
+ // stays alive (so a long reasoning phase doesn't look stalled) without echoing the
745
+ // reasoning itself. Throttled to the default's ~5s cadence to avoid spamming startTyping.
746
+ // The clean final answer leaves by the `reply` TOOL and by nothing else: this channel authors
747
+ // its own `message.completed`, and since OPE-2006 that handler DROPS the turn's closing prose
748
+ // and records it (`channels/closing-prose-drop.ts`). Re-delegating to eve's default poster here
749
+ // would re-open the road this ticket removed, on the surface OPE-771 measured the loss on.
750
+ "reasoning.appended": async (_data, channel) => {
751
+ const now = Date.now();
752
+ const last = channel.state.lastReasoningTypingAtMs;
753
+ if (last != null && now - last >= 0 && now - last < 5000)
754
+ return;
755
+ channel.state.lastReasoningTypingAtMs = now;
756
+ await refreshTypingStatus(channel);
757
+ },
758
+ // Eve's default falls back to "Running bash, read_channel..." for a tool-call
759
+ // step, which means nothing to non-engineers and advertises the toolbox to the whole
760
+ // channel β€” so we override with a generic status. We ALSO used to show the model's own
761
+ // preamble text here (its line before the tool call, written for humans, Tag-style) β€”
762
+ // dropped 2026-07 (Tommy, live): that text streams in mid-sentence, so the typing
763
+ // indicator would flash a truncated fragment ("I'll go away and look to setu…") for a
764
+ // moment before reverting, which read as broken. Always the clean status verb now,
765
+ // same as reasoning.appended.
766
+ "actions.requested": async (_data, channel) => {
767
+ await refreshTypingStatus(channel);
768
+ },
769
+ // Override the reply-posting handler for TWO additions: (1) a woken (non-mention) turn
770
+ // is told to judge whether a reply is warranted and to end with NO_REPLY when it isn't
771
+ // (slack-events.ts renderWakeMessage) β€” that sentinel must be swallowed, not posted;
772
+ // (2) a turn where a propose tool already posted a code-rendered approval card
773
+ // (approvals/flow.ts's requestApproval, signalled via approvals/card-signal.ts)
774
+ // suppresses the model's redundant text β€” the card already said it (Tommy, live: "the
775
+ // card should suffice"). Everything else mirrors Eve's default handler byte-for-byte
776
+ // (tool-calls is an intermediate step with no turn-final effects; empty final message
777
+ // posts nothing), via the pure, unit-tested finalMessageAction()/turnEffectsFor().
778
+ "message.completed": async (data, channel, ctx) => {
779
+ // The eve session id for this turn β€” stamped into the terminal audit rows below so an
780
+ // audited reply/silence/dropped-prose joins back to its transcript (control-read transcript).
781
+ const sessionId = ctx?.session?.id;
782
+ // OPE-1647: the TURN ID, so the stand-down silence mark (channels/stand-down-silence.ts) can
783
+ // prove it belongs to THIS turn. Same extraction idiom this file already uses for the Β§POSTED
784
+ // consume below.
785
+ // πŸ”΄ SINCE OPE-2006 IT SERVES THE SILENCE DECISION ALONE. It is read once here and handed to
786
+ // `finalMessageActionForTurn`, whose `consumeStandDownSilenceThisTurn(turnId)` is its only
787
+ // reader on this path. There is no reply audit below to stamp it onto: this surface DROPS its
788
+ // closing prose, so the durable latch that guarded the old post has no door left to shut here.
789
+ const turnId = ctx?.session?.turn?.id;
790
+ const action = finalMessageActionForTurn(data.finishReason, data.message, turnId);
791
+ if (action.kind === "buffer-tool-status") {
792
+ // An intermediate tool-call step, not the turn's end β€” nothing to decide yet. In
793
+ // particular: don't read the card-posted flag here, the tool this step is about to
794
+ // run may be the one that posts the card.
795
+ return;
796
+ }
797
+ // Read-and-reset whether an approval card already posted THIS turn β€” consumed once,
798
+ // right before the turn's terminal effects are decided, so the next turn always
799
+ // starts clean (mirrors how oeSilentTurn/oeUninvited below are REASSIGNED on every
800
+ // terminal call rather than cleared as a separate step).
801
+ const cardPostedThisTurn = consumeCardPostedThisTurn();
802
+ // Same read-and-reset contract, for the bare-stop check right below. A turn that
803
+ // reacted OR posted mid-turn via the reply-tool family (reply-signal.ts) acted, so
804
+ // both signals are consumed here and OR'd into the bare-stop input.
805
+ const reactedThisTurn = consumeReactedThisTurn();
806
+ // TURN-ID KEYED (OPE-805): a bare read-and-reset let a mark this turn never consumed answer a
807
+ // LATER turn's read β€” see reply-signal.ts's header.
808
+ const repliedThisTurn = consumeRepliedThisTurn(ctx?.session?.turn?.id);
809
+ if (action.kind === "silent") {
810
+ // Remember the turn ended silent so session.waiting doesn't stamp a ❓ status
811
+ // reaction on a conversation the agent chose NOT to join (live-observed: a
812
+ // judged-silent name-drop left a ❓ on the triggering message).
813
+ channel.state.oeSilentTurn = true;
814
+ // Silence is designed to leave no trace ON THE SURFACE β€” but the operator
815
+ // needs to distinguish "judged NO_REPLY" from "never woke" (bit us live).
816
+ // action.reason distinguishes the sentinel route from the no_reply TOOL route
817
+ // (finalMessageActionForTurn folds both into "silent").
818
+ console.log(`[open-everywhere:slack] judged silence (${action.reason}) β€” nothing posted (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`);
819
+ // ACTIONS-ONLY posture (audit-wedge build plan Β§1c, 2026-07-14): a judged silence on an
820
+ // ambient chat surface is NOT an audited action β€” an agent meant to reply to ~1% of channel
821
+ // messages would otherwise produce a table that is ~99% silence rows. "Why did it stay
822
+ // quiet?" is answered by the session transcript (GET /control/v1/session/:id/transcript),
823
+ // not the audit log. The console line above stays (operator-legible, free); the bare-stop
824
+ // row below stays (an ANOMALY β€” a judgment went missing β€” not a judged silence).
825
+ //
826
+ // ONE exception (OPE-890): the model ended the turn on the bare NO_REPLY sentinel β€” the
827
+ // TOOL route already carries an always-on `reply.no_reply_observed` row (tools/no_reply.ts,
828
+ // OPE-649); this is the sentinel route's twin, same action name, best-effort.
829
+ if (action.reason === NO_REPLY_SENTINEL_REASON) {
830
+ try {
831
+ const p = resolvePartition({
832
+ channel: { kind: "slack" },
833
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channel.state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
834
+ });
835
+ await auditNoReplySentinelObserved(p, sessionId);
836
+ }
837
+ catch (err) {
838
+ console.warn(`[open-everywhere:slack] sentinel no_reply audit write failed: ${String(err)}`);
839
+ }
840
+ }
841
+ // Bare-stop enforcement (feature 2's investigation β€” see slack-events.ts's
842
+ // isBareStop header for why this is DETECT-AND-LOG, not a hard gate): a turn
843
+ // that went silent with NO explanation (no sentinel, no no_reply tool) and
844
+ // NO react either did, as far as anyone can tell, nothing at all. Loud warn
845
+ // + a best-effort audit row; never a post β€” silence stays silence.
846
+ if (isBareStop(action, reactedThisTurn || repliedThisTurn)) {
847
+ const channelId = channel.state.channelId ?? undefined;
848
+ const threadTs = channel.state.threadTs ?? undefined;
849
+ console.warn(`[open-everywhere:slack] BARE STOP β€” turn ended with no reply, no react, and no no_reply ` +
850
+ `(${channelId ?? "?"}/${threadTs ?? "?"}) β€” this usually means a judgment call went missing, not that one was made.`);
851
+ try {
852
+ const p = resolvePartition({
853
+ channel: { kind: "slack" },
854
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
855
+ });
856
+ await getOperationalStore().appendAudit({
857
+ ts: new Date().toISOString(),
858
+ org: p.org,
859
+ platform: p.platform,
860
+ workspace: p.workspace,
861
+ surface: p.surface,
862
+ action: "turn.bare_stop",
863
+ detail: { threadTs, ...(sessionId ? { sessionId } : {}) },
864
+ });
865
+ }
866
+ catch (err) {
867
+ console.warn(`[open-everywhere:slack] bare-stop audit write failed: ${String(err)}`);
868
+ }
869
+ }
870
+ // Warm auto-disengage: a WARM (non-participating) thread that keeps judging silence
871
+ // is shed after WARM_SILENT_LIMIT turns, so a one-off name-drop doesn't leave the
872
+ // bot quietly following forever. PARTICIPATING threads are exempt β€” they're sticky,
873
+ // only an explicit stop disengages them β€” so a missing record OR participating:true
874
+ // is a no-op here. Build the partition exactly like the post branch does; all
875
+ // best-effort, engagement bookkeeping never throws out of the handler.
876
+ try {
877
+ const { channelId, teamId, threadTs } = channel.state;
878
+ if (channelId && threadTs) {
879
+ const p = resolvePartition({
880
+ channel: { kind: "slack" },
881
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
882
+ });
883
+ const rec = await getThreadEngagement(p, threadTs);
884
+ if (rec && rec.participating !== true) {
885
+ const streak = await bumpThreadSilentStreak(p, threadTs);
886
+ if (streak >= WARM_SILENT_LIMIT) {
887
+ await disengageThread(p, threadTs);
888
+ console.log(`[open-everywhere:slack] warm thread shed after ${streak} silent turns β€” disengaged (${channelId}/${threadTs})`);
889
+ }
890
+ }
891
+ }
892
+ }
893
+ catch (err) {
894
+ console.warn(`[open-everywhere:slack] warm silent-streak bookkeeping failed: ${String(err)}`);
895
+ }
896
+ try {
897
+ await setWakeTyping(channel);
898
+ }
899
+ catch {
900
+ /* typing reset is best-effort */
901
+ }
902
+ return;
903
+ }
904
+ // action.kind === "post" here β€” the pure decision (unit-tested in slack-events.ts)
905
+ // gates ONLY whether the text itself posts; every other effect below is unconditional.
906
+ const effects = turnEffectsFor(action, cardPostedThisTurn);
907
+ if (effects.kind !== "reply") {
908
+ // Unreachable: turnEffectsFor only returns "silent" for a "silent" input action,
909
+ // and action.kind === "post" is established above. Kept as a narrowing guard.
910
+ return;
911
+ }
912
+ channel.state.oeSilentTurn = false;
913
+ // --- SUPERVISOR host-side reply targeting (T1.4 / C1 workaround) ----------------
914
+ // A channel-supervisor session's `state.threadTs` is a NON-EMPTY sentinel (T1.3 /
915
+ // amendment A), so eve's default `channel.thread.post` (bound to state.threadTs) would
916
+ // post the answer INTO the sentinel pseudo-thread β€” a leak. The supervisor MUST post
917
+ // host-side via `slackForm` to the REAL per-turn target snapshotted at turn.started,
918
+ // and MUST NEVER call `channel.thread.post` (that path also re-anchors an empty binding).
919
+ // Gated strictly on `oeSupervisor` (isSupervisorReplyTurn) so non-supervisor turns fall
920
+ // through to the byte-identical default path below. NO_REPLY/no_reply/react/card are all
921
+ // upstream (the `silent` branch already returned; a supervisor NO_REPLY posts nothing,
922
+ // same as today). FAIL SAFE: no resolvable target β‡’ deliverSupervisorReply posts NOTHING.
923
+ //
924
+ // UNIFIED cross-surface note (formerly TODO(unified-reply) β€” the non-Slack legs are wired,
925
+ // 2026-07-11): this leg is Slack-specific (posts host-side via slackForm to a Slack target)
926
+ // and that is CORRECT β€” each surface's own completion handler fires for its own deliveries
927
+ // (eve namespaces continuation tokens per channel, so a GitHub/Linear/Notion wake completes
928
+ // on THAT channel's message.completed, which posts via its own arm to its own per-wake
929
+ // snapshot: github.ts / linear.ts / notion.ts supervisor branches, all behind the same
930
+ // message-tool-only gate). See unified-supervisor.ts's cross-surface-reply header for the
931
+ // whole model + the per-platform session-namespacing caveat pending a live run.
932
+ if (isSupervisorReplyTurn(channel.state)) {
933
+ const target = channel.state.oeReplyTarget;
934
+ // The channel/org is always known (the supervisor owns one channel) β€” build its
935
+ // partition from the session state, independent of whether a reply target resolved.
936
+ const channelId = channel.state.channelId ?? undefined;
937
+ const teamId = channel.state.teamId;
938
+ const partition = channelId
939
+ ? resolvePartition({
940
+ channel: { kind: "slack" },
941
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
942
+ })
943
+ : undefined;
944
+ // --- MESSAGE-TOOL-ONLY hard-silence gate (OpenClaw steal, slack-events.ts) -----------
945
+ // A supervisor's terminal completion text is NOT a reply: a visible reply must come from
946
+ // the `reply` tool (which posted mid-turn via reply-post.ts + marked repliedThisTurn).
947
+ // Force-drop the terminal prose so a judge that emits stray narration leaks NOTHING to the
948
+ // watched channel; binding below is unaffected (deliverSupervisorReply binds regardless of
949
+ // postText). Fail-open only where the surface has no reply tool (never true for Slack).
950
+ const delivery = supervisorTextDelivery(true /* action.kind==="post" β‡’ non-empty prose */, capabilitiesForPlatform("slack").midTurnPost);
951
+ if (delivery.droppedProse && isSupervisorProseLeak(true, repliedThisTurn, cardPostedThisTurn)) {
952
+ // The load-bearing leak case: the judge produced terminal prose but took NO positive
953
+ // action to post it (no `reply`, no card). Drop it (never post), but make the silence
954
+ // auditable β€” mirrors OpenClaw's suppressed-delivery metadata (`didSendViaMessagingTool:
955
+ // false`) so "why did the judge go quiet on prose it wrote" always has an answer.
956
+ console.warn(`[open-everywhere:slack] supervisor message-tool-only: DROPPED terminal prose (judge emitted text without calling reply) β€” nothing posted (${channelId ?? "?"}/${target?.threadTs ?? "top-level"})`);
957
+ if (partition) {
958
+ try {
959
+ await getOperationalStore().appendAudit({
960
+ ts: new Date().toISOString(),
961
+ org: partition.org,
962
+ platform: partition.platform,
963
+ workspace: partition.workspace,
964
+ surface: partition.surface,
965
+ action: "turn.supervisor_prose_dropped",
966
+ detail: { threadTs: target?.threadTs, chars: action.text.length, ...(sessionId ? { sessionId } : {}) },
967
+ });
968
+ }
969
+ catch (err) {
970
+ console.warn(`[open-everywhere:slack] supervisor prose-dropped audit write failed: ${String(err)}`);
971
+ }
972
+ }
973
+ }
974
+ await deliverSupervisorReply({
975
+ postText: delivery.postText,
976
+ text: action.text,
977
+ target,
978
+ async post(cid, tts, text) {
979
+ // Idempotency (operational/post-once.ts): this raw supervisor-reply post bypasses
980
+ // eve's durable reply primitive, so a `deliver`-step re-run after it lands would
981
+ // double-post. postOnce (org-partitioned, short TTL) skips the resume duplicate. No
982
+ // stableId β€” a deliver hook has no eve callId β€” so the key is channel+thread+text.
983
+ // Fail-open (incl. an unresolved partition β‡’ undefined org): still posts.
984
+ await postOnce({
985
+ org: partition?.org,
986
+ key: outboundKey({ surface: "slack", channel: cid, threadTs: tts, action: "slack.supervisor-reply", payload: text }),
987
+ run: async () => {
988
+ await slackForm("chat.postMessage", supervisorPostFormBody(cid, tts, text));
989
+ },
990
+ });
991
+ },
992
+ async resolveOwnerToken() {
993
+ if (!partition)
994
+ return undefined;
995
+ try {
996
+ return (await getOperationalStore().getChannelSession(partition.org, partition.surfaceKey))?.token;
997
+ }
998
+ catch {
999
+ return undefined;
1000
+ }
1001
+ },
1002
+ async bind(tts, ownerToken) {
1003
+ if (!partition)
1004
+ return;
1005
+ // ownerToken = the supervisor's registry token β†’ the thread is now owned by the
1006
+ // supervisor session (Tag: "the channel session answers a cheap thread and now
1007
+ // owns it"). markThreadEngaged is best-effort (never fails a delivered reply).
1008
+ // `byOwnPost` for the same reason as the delivery promotion below (OPE-1669): the
1009
+ // trigger here is the agent's own delivered reply, never a person inviting it back, so
1010
+ // it must not lift a stand-down. The ownership bind itself is unaffected.
1011
+ await markThreadEngaged(partition, tts, new Date().toISOString(), { participating: true, ownerToken, byOwnPost: true });
1012
+ },
1013
+ });
1014
+ // Clear the uninvited flag only on ACTUAL participation β€” the supervisor replied via the
1015
+ // `reply` tool, or a card narrated for it. Under message-tool-only the terminal text is
1016
+ // dropped, so stray prose is NOT participation and must not promote the session (a judge
1017
+ // that leaked prose it never posted stays uninvited, so its later errors keep quiet).
1018
+ if (repliedThisTurn || cardPostedThisTurn) {
1019
+ channel.state.oeUninvited = false;
1020
+ }
1021
+ return; // NEVER fall through to channel.thread.post β€” that would post into the sentinel.
1022
+ }
1023
+ if (effects.postText) {
1024
+ // πŸ”΄ THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
1025
+ // engaged-thread terminal reply through `postTerminalProse`, with a raw `channel.thread.post`
1026
+ // fallback for a state that named no channel. Both are gone: the `reply` tool is the only
1027
+ // channel a seat's words leave by, so a turn that ended in prose and never called `reply`
1028
+ // has said nothing here. The row is the record β€” no re-send, no queue, no host-side re-post.
1029
+ // WHAT TELLS THE MODEL is the seat prompt, not this handler: Slack declares
1030
+ // `closingProseDropped`, so a conversational seat here reads the `reply-only` delivery arm,
1031
+ // whose copy states that the turn-final text is discarded. It reaches a supervisor-shaped
1032
+ // seat inside its persona and a `native`-grain @mention thread β€” the seat this branch mostly
1033
+ // serves β€” through the `delivery` slot (instructions/access.ts `renderDeliverySlot`).
1034
+ const channelId = channel.state.channelId ?? undefined;
1035
+ const replyThreadTs = replyThreadTsForState(channel.state);
1036
+ if (channelId) {
1037
+ const p = resolvePartition({
1038
+ channel: { kind: "slack" },
1039
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1040
+ });
1041
+ await dropClosingProse({
1042
+ row: {
1043
+ org: p.org,
1044
+ platform: p.platform,
1045
+ workspace: p.workspace,
1046
+ surface: p.surface,
1047
+ principalId: channel.state.oeReplyPrincipalId,
1048
+ },
1049
+ chars: action.text.length,
1050
+ ...(sessionId ? { sessionId } : {}),
1051
+ // OPE-98: the PER-WAKE thread (dmWakeThreadOverride ?? state.threadTs) β€” the anchor the
1052
+ // post would have used, so the row names the conversation the answer was owed to.
1053
+ anchor: { threadTs: replyThreadTs ?? null },
1054
+ log: (m) => console.warn(`[open-everywhere:slack] ${m} (${channelId}/${replyThreadTs ?? "?"})`),
1055
+ });
1056
+ }
1057
+ else {
1058
+ // πŸ”΄ NO CHANNEL ID, SO NO PARTITION TO FILE IT UNDER, SO NO ROW. `resolvePartition` answers
1059
+ // `surface: "default"` for a blank channel, and a lost answer filed against a surface that
1060
+ // names no room pollutes the drop count for everything else that resolves to `default`.
1061
+ // The drop still happens and the log line is the whole record β€” the same honest degradation
1062
+ // the three sibling handlers take, and the same one the deleted poster's fallback took.
1063
+ console.warn(`[open-everywhere:slack] closing prose DROPPED (${action.text.length}c) β€” nothing posted, and no partition to audit it under (?/${replyThreadTs ?? "?"})`);
1064
+ }
1065
+ }
1066
+ else {
1067
+ // A card already posted this turn β€” suppress the redundant narration, but this is
1068
+ // STILL a participating reply for everything below (πŸ‘€ clears, thread engages,
1069
+ // oeUninvited clears), same as an ordinary post.
1070
+ console.log(`[open-everywhere:slack] approval card already posted this turn β€” suppressing the redundant text reply (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`);
1071
+ }
1072
+ // DID THIS TURN ACTUALLY DELIVER ANYTHING? Since OPE-2006 the closing prose is dropped, so
1073
+ // reaching this point no longer means a message left. Only a `reply` tool call or an approval
1074
+ // card put bytes in the room, and the two promotions below are what a delivery earns.
1075
+ // πŸ”΄ IT IS THE RULE THE SUPERVISOR BRANCH ABOVE ALREADY STATES β€” "stray prose is NOT
1076
+ // participation and must not promote the session" β€” applied to the seat that used to be the
1077
+ // exception because its prose posted. A turn whose only output was dropped prose stays
1078
+ // uninvited and leaves the thread's engagement exactly as it found it.
1079
+ const deliveredThisTurn = repliedThisTurn || cardPostedThisTurn;
1080
+ // The agent is now a PARTICIPANT: an uninvited (name-drop) session that posts
1081
+ // earns normal standing, so later failures go loud like any engaged thread's.
1082
+ if (deliveredThisTurn)
1083
+ channel.state.oeUninvited = false;
1084
+ // Remove the πŸ‘€ WITH the reply, not at park: session.waiting fires a workflow
1085
+ // step later and can lag the post by many seconds (live-observed), leaving a
1086
+ // stale "working" mark on an already-answered message. The waiting/completed
1087
+ // removals below stay as the idempotent safety net (e.g. for turns whose final
1088
+ // delivery goes through a path that skips this handler).
1089
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1090
+ // A posted reply = the agent is participating in this thread β†’ follow it. For
1091
+ // mention threads this is an idempotent refresh (onInbound already engaged); for a
1092
+ // name-drop reply it's what ESTABLISHES engagement, so follow-ups flow without a
1093
+ // mention. Best-effort β€” engagement must never fail a delivered reply.
1094
+ try {
1095
+ const { channelId, teamId, threadTs } = channel.state;
1096
+ if (deliveredThisTurn && channelId && threadTs) {
1097
+ const p = resolvePartition({
1098
+ channel: { kind: "slack" },
1099
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1100
+ });
1101
+ // A posted reply PROMOTES the thread: participating:true so a warm (name-drop)
1102
+ // thread becomes an invited participant (sticky, streak cleared, failures loud).
1103
+ // πŸ”΄ `byOwnPost` IS WHAT KEEPS A STAND-DOWN STOOD DOWN (OPE-1669). This promotion carries
1104
+ // no promoter id, so without the flag it CLEARS the durable `stopped` marker β€” and the
1105
+ // acknowledgement `stand_down` asks for travels by the agent's own `reply`, so it reaches
1106
+ // this promotion and would lift the very stop it was acknowledging. See
1107
+ // channels/engagement.ts.
1108
+ await markThreadEngaged(p, threadTs, new Date().toISOString(), { participating: true, byOwnPost: true });
1109
+ }
1110
+ }
1111
+ catch {
1112
+ /* best-effort */
1113
+ }
1114
+ },
1115
+ // Safe to own (no built-in default): turn's over β†’ REMOVE the πŸ‘€ (absence = done;
1116
+ // the reply is the result). Silent turns skip entirely β€” no trace, no API calls, on
1117
+ // a conversation the agent declined to join.
1118
+ "session.completed": async (_data, channel) => {
1119
+ if (channel.state.oeSilentTurn !== true) {
1120
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1121
+ }
1122
+ await syncPinnedStatus(channel);
1123
+ },
1124
+ // Parked waiting for the next human message β€” same removal: a parked session is the
1125
+ // steady state of a durable thread, not a "needs input" condition worth a ❓.
1126
+ "session.waiting": async (_data, channel) => {
1127
+ if (channel.state.oeSilentTurn === true)
1128
+ return;
1129
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1130
+ },
1131
+ // Eve's default posts "I hit an error while handling your request…" into the
1132
+ // thread. Keep that for INVITED turns (failures must stay visible to whoever asked)
1133
+ // but suppress it for uninvited ones β€” a name-drop turn that would have judged
1134
+ // NO_REPLY must not spam the conversation with an error instead (log + audit only).
1135
+ "turn.failed": async (data, channel) => {
1136
+ if (isUninvited(channel.state)) {
1137
+ await recordQuietFailure(channel.state, "turn.failed", data.message);
1138
+ return;
1139
+ }
1140
+ await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1141
+ try {
1142
+ await postWakeText(channel, `:octagonal_sign: I hit an error while handling your request (${data.message}). Please try again or rephrase.`);
1143
+ }
1144
+ catch {
1145
+ /* best-effort */
1146
+ }
1147
+ },
1148
+ // Override carefully: πŸ›‘ blocked + re-post the error the default would have,
1149
+ // so failures stay visible. Same uninvited-turn suppression as turn.failed.
1150
+ "session.failed": async (data, channel) => {
1151
+ if (isUninvited(channel.state)) {
1152
+ await recordQuietFailure(channel.state, "session.failed", data.message);
1153
+ return;
1154
+ }
1155
+ await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1156
+ try {
1157
+ await postWakeText(channel, `:octagonal_sign: Task failed: ${data.message}`);
1158
+ }
1159
+ catch {
1160
+ /* best-effort */
1161
+ }
1162
+ await syncPinnedStatus(channel);
1163
+ },
1164
+ },
1165
+ });
1166
+ // Front the public Slack URL with the engagement route so plain (non-mention) thread replies
1167
+ // wake the engaged thread's session, while everything slackChannel owns is forwarded through.
1168
+ export default frontSlackChannel(slackMentionChannel);
1169
+ //# sourceMappingURL=slack.js.map