@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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/slack-attachment-fetch.ts +168 -0
- package/agent/app-manifest.ts +149 -0
- package/agent/channels/slack-approval-card.ts +275 -0
- package/agent/channels/slack-attachments.ts +195 -0
- package/agent/channels/slack-commands.ts +227 -0
- package/agent/channels/slack-context.ts +185 -0
- package/agent/channels/slack-events.ts +5406 -0
- package/agent/channels/slack-front.ts +2349 -0
- package/agent/channels/slack-identity.ts +520 -0
- package/agent/channels/slack-input-response.ts +162 -0
- package/agent/channels/slack-mrkdwn.ts +61 -0
- package/agent/channels/slack-origin-codec.ts +62 -0
- package/agent/channels/slack-post-arm.ts +314 -0
- package/agent/channels/slack-read.ts +248 -0
- package/agent/channels/slack-route-hold.ts +107 -0
- package/agent/channels/slack-shortcut.ts +336 -0
- package/agent/channels/slack-signature.ts +53 -0
- package/agent/channels/slack-spend-gate.ts +62 -0
- package/agent/channels/slack-tenant-fence.ts +99 -0
- package/agent/channels/slack-wake-backscroll.ts +604 -0
- package/agent/channels/slack-web-api.ts +178 -0
- package/agent/channels/slack-webhook-verifier.ts +116 -0
- package/agent/channels/slack.ts +1266 -0
- package/agent/contributions.ts +32 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +50 -0
- package/agent/provider-events.ts +91 -0
- package/agent/register.ts +144 -0
- package/agent/surface-parity.ts +99 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
- package/dist/agent/access/slack-attachment-fetch.js +151 -0
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
- package/dist/agent/app-manifest.d.ts +15 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +141 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/slack-approval-card.d.ts +71 -0
- package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-approval-card.js +232 -0
- package/dist/agent/channels/slack-approval-card.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +66 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
- package/dist/agent/channels/slack-attachments.js +174 -0
- package/dist/agent/channels/slack-attachments.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +27 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -0
- package/dist/agent/channels/slack-commands.js +208 -0
- package/dist/agent/channels/slack-commands.js.map +1 -0
- package/dist/agent/channels/slack-context.d.ts +26 -0
- package/dist/agent/channels/slack-context.d.ts.map +1 -0
- package/dist/agent/channels/slack-context.js +178 -0
- package/dist/agent/channels/slack-context.js.map +1 -0
- package/dist/agent/channels/slack-events.d.ts +1654 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -0
- package/dist/agent/channels/slack-events.js +4240 -0
- package/dist/agent/channels/slack-events.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +387 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -0
- package/dist/agent/channels/slack-front.js +1958 -0
- package/dist/agent/channels/slack-front.js.map +1 -0
- package/dist/agent/channels/slack-identity.d.ts +245 -0
- package/dist/agent/channels/slack-identity.d.ts.map +1 -0
- package/dist/agent/channels/slack-identity.js +405 -0
- package/dist/agent/channels/slack-identity.js.map +1 -0
- package/dist/agent/channels/slack-input-response.d.ts +26 -0
- package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
- package/dist/agent/channels/slack-input-response.js +127 -0
- package/dist/agent/channels/slack-input-response.js.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.js +53 -0
- package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/slack-origin-codec.js +55 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -0
- package/dist/agent/channels/slack-post-arm.d.ts +54 -0
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/slack-post-arm.js +268 -0
- package/dist/agent/channels/slack-post-arm.js.map +1 -0
- package/dist/agent/channels/slack-read.d.ts +84 -0
- package/dist/agent/channels/slack-read.d.ts.map +1 -0
- package/dist/agent/channels/slack-read.js +205 -0
- package/dist/agent/channels/slack-read.js.map +1 -0
- package/dist/agent/channels/slack-route-hold.d.ts +34 -0
- package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
- package/dist/agent/channels/slack-route-hold.js +97 -0
- package/dist/agent/channels/slack-route-hold.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +162 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
- package/dist/agent/channels/slack-shortcut.js +262 -0
- package/dist/agent/channels/slack-shortcut.js.map +1 -0
- package/dist/agent/channels/slack-signature.d.ts +19 -0
- package/dist/agent/channels/slack-signature.d.ts.map +1 -0
- package/dist/agent/channels/slack-signature.js +50 -0
- package/dist/agent/channels/slack-signature.js.map +1 -0
- package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
- package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
- package/dist/agent/channels/slack-spend-gate.js +48 -0
- package/dist/agent/channels/slack-spend-gate.js.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.js +88 -0
- package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.js +473 -0
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +46 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
- package/dist/agent/channels/slack-web-api.js +147 -0
- package/dist/agent/channels/slack-web-api.js.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.js +112 -0
- package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/slack.d.ts +37 -0
- package/dist/agent/channels/slack.d.ts.map +1 -0
- package/dist/agent/channels/slack.js +1169 -0
- package/dist/agent/channels/slack.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +32 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +38 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +84 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +118 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +95 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +209 -0
- package/tsconfig.build.json +22 -0
- 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
|