@open-everywhere/provider-slack 0.8.0 → 0.9.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 (68) hide show
  1. package/agent/app-manifest.ts +3 -1
  2. package/agent/channels/slack-agent-session.ts +131 -5
  3. package/agent/channels/slack-events.ts +59 -5
  4. package/agent/channels/slack-front.ts +15 -8
  5. package/agent/channels/slack-identity.ts +42 -1
  6. package/agent/channels/slack-web-api.ts +3 -3
  7. package/agent/channels/slack-working-signs.ts +71 -0
  8. package/agent/channels/slack.ts +36 -50
  9. package/agent/docs-generated.ts +50 -13
  10. package/agent/ops.ts +4 -0
  11. package/agent/register.ts +4 -2
  12. package/agent/setup/claim.ts +9 -8
  13. package/agent/setup/credentials.ts +82 -79
  14. package/agent/setup/eve-credentials.ts +2 -2
  15. package/agent/setup/page.ts +3 -3
  16. package/agent/status.ts +12 -4
  17. package/dist/agent/app-manifest.d.ts.map +1 -1
  18. package/dist/agent/app-manifest.js +3 -1
  19. package/dist/agent/app-manifest.js.map +1 -1
  20. package/dist/agent/channels/slack-agent-session.d.ts +22 -2
  21. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -1
  22. package/dist/agent/channels/slack-agent-session.js +127 -6
  23. package/dist/agent/channels/slack-agent-session.js.map +1 -1
  24. package/dist/agent/channels/slack-events.d.ts +7 -3
  25. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  26. package/dist/agent/channels/slack-events.js +57 -6
  27. package/dist/agent/channels/slack-events.js.map +1 -1
  28. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  29. package/dist/agent/channels/slack-front.js +16 -9
  30. package/dist/agent/channels/slack-front.js.map +1 -1
  31. package/dist/agent/channels/slack-identity.d.ts +33 -0
  32. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  33. package/dist/agent/channels/slack-identity.js +35 -1
  34. package/dist/agent/channels/slack-identity.js.map +1 -1
  35. package/dist/agent/channels/slack-web-api.js +3 -3
  36. package/dist/agent/channels/slack-web-api.js.map +1 -1
  37. package/dist/agent/channels/slack-working-signs.d.ts +34 -0
  38. package/dist/agent/channels/slack-working-signs.d.ts.map +1 -0
  39. package/dist/agent/channels/slack-working-signs.js +63 -0
  40. package/dist/agent/channels/slack-working-signs.js.map +1 -0
  41. package/dist/agent/channels/slack.d.ts.map +1 -1
  42. package/dist/agent/channels/slack.js +28 -46
  43. package/dist/agent/channels/slack.js.map +1 -1
  44. package/dist/agent/docs-generated.d.ts.map +1 -1
  45. package/dist/agent/docs-generated.js +50 -13
  46. package/dist/agent/docs-generated.js.map +1 -1
  47. package/dist/agent/ops.d.ts.map +1 -1
  48. package/dist/agent/ops.js +4 -1
  49. package/dist/agent/ops.js.map +1 -1
  50. package/dist/agent/register.d.ts.map +1 -1
  51. package/dist/agent/register.js +4 -2
  52. package/dist/agent/register.js.map +1 -1
  53. package/dist/agent/setup/claim.d.ts.map +1 -1
  54. package/dist/agent/setup/claim.js +9 -8
  55. package/dist/agent/setup/claim.js.map +1 -1
  56. package/dist/agent/setup/credentials.d.ts +21 -20
  57. package/dist/agent/setup/credentials.d.ts.map +1 -1
  58. package/dist/agent/setup/credentials.js +71 -75
  59. package/dist/agent/setup/credentials.js.map +1 -1
  60. package/dist/agent/setup/eve-credentials.js +2 -2
  61. package/dist/agent/setup/eve-credentials.js.map +1 -1
  62. package/dist/agent/setup/page.js +3 -3
  63. package/dist/agent/setup/page.js.map +1 -1
  64. package/dist/agent/status.d.ts +10 -2
  65. package/dist/agent/status.d.ts.map +1 -1
  66. package/dist/agent/status.js +12 -4
  67. package/dist/agent/status.js.map +1 -1
  68. package/package.json +6 -2
@@ -255,7 +255,9 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
255
255
  "lists:read",
256
256
  "lists:write",
257
257
  // The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
258
- // subscription makes Slack attach the viewed room to each side-panel `message.im`.
258
+ // subscription makes Slack attach the viewed room to each side-panel `message.im`. It also
259
+ // covers `assistant.threads.setStatus`, the status line a turn shows while it works: the
260
+ // mention door's, and the DM seat's (OPE-2629, channels/slack-agent-session.ts).
259
261
  "assistant:write",
260
262
  // Slack requires this bot scope on an app whose people connect to Slack's MCP server
261
263
  // (OPE-2421). Declared only when the app declares "search Slack as you".
@@ -21,6 +21,9 @@
21
21
  // registers the Slack setter: it names the session from the delivery's auth attributes. The
22
22
  // person's next message sets `processing`, then parks `active` unless it reports again that it
23
23
  // waits on the person. A job's result wake names no session, so its turn sets no state.
24
+ // • THE WORKING SIGNS (OPE-2629, OPE-2632). The same setter, and the stepper, show the mention door's
25
+ // signs on a DM-door turn — a 1:1 DM or a group DM: 👀, the status line with its step labels, and
26
+ // at the end no sign, or 🛑 when the turn failed for good. See "the working signs" below.
24
27
  // • A FORWARDED JOB's WAIT (OPE-2536). A job the DM seat forwards to the work session can end waiting
25
28
  // on the person: it asked a question in the room (`ask_room`), or it needs their own sign-in. The
26
29
  // work session has no `update_status`, so the host sets the session `suspended` at the job's
@@ -38,14 +41,20 @@
38
41
  import {
39
42
  cancelAnchoredTurn,
40
43
  resolvePartition,
44
+ swallowRoomOp,
41
45
  turnAnchorKey,
42
46
  turnChannelKey,
43
47
  type AttachSessionForCancel,
44
48
  type CancelResult,
45
49
  type SurfaceSessionPinner,
46
50
  type SurfaceSessionSetter,
51
+ type SurfaceSessionStatus,
52
+ type SurfaceSessionStepper,
53
+ type SurfaceTurnEnd,
47
54
  } from "@open-everywhere/core/agent/adapter-api.js";
48
55
  import { bindableForSlackIds, slackForm, type SlackEventEnvelope } from "./slack-events.js";
56
+ import { type StepAction } from "./slack-step-label.js";
57
+ import { setSlackStatusReaction, setSlackThreadStatus, SLACK_WORKING_STATUS, slackStepStatus, type SlackSignRequest } from "./slack-working-signs.js";
49
58
 
50
59
  /** Slack's event for a press of the session's Stop button. */
51
60
  export const AGENT_SESSION_STOPPED_EVENT = "agent_session_stopped";
@@ -159,13 +168,130 @@ export const slackSurfaceSessionPin: SurfaceSessionPinner = (attributes) => {
159
168
  };
160
169
  };
161
170
 
162
- /** The Slack setter core's turn-anchor hook calls (registered in register.ts). */
171
+ // --- the working signs ------------------------------------------------------------------------------
172
+ //
173
+ // 🔴 THE DM DOOR's WORKING SIGNS (OPE-2629, OPE-2632). A 1:1 DM and a group DM run on the `unified`
174
+ // channel, so none of the Slack channel's own handlers reach them: `onInbound` (the 👀), the
175
+ // `turn.started` and `actions.requested` renderers (the status line and its step labels) and the
176
+ // `session.failed` arm (the 🛑) serve only eve's native mention door. Core's turn-anchor hook calls
177
+ // the setter below at a DM turn's start (`processing`), at its park (`active` or `suspended`) and at
178
+ // `session.failed` (the park's state with `{ failed: true }`), and the stepper at each step. The signs
179
+ // ride those calls, through the SAME functions the mention door calls (slack-working-signs.ts):
180
+ // • the status reaction on the message the DM door stamped (`working_signs_ts`: the message itself
181
+ // when top-level, its thread root for a threaded reply): 👀 at the start, none at the park, 🛑
182
+ // when the turn failed for good;
183
+ // • the status line on that root: `Working...` at the start, each step's code-owned label (or a
184
+ // status verb) at each step, and cleared at every end. The flat DM reply does not clear it.
185
+ // Only a delivery the DM door stamped shows a sign: a person's 1:1 or group-DM message, and a follow-up
186
+ // press inside a thread (on its thread root). A channel turn carries no stamp (the mention door draws
187
+ // its own), and neither does a shortcut or a top-level press (a minted ts names no message) or a
188
+ // forwarded job's PIN (its settlement must not clear a running turn's 👀).
189
+
190
+ /** The status line a DM turn shows at its start — the mention door's own words (slack-working-signs.ts). */
191
+ export const SLACK_DM_WORKING_STATUS = SLACK_WORKING_STATUS;
192
+
193
+ /** The auth attribute the DM door stamps with the message the signs ride (slack-front.ts `scheduleDmWake`). */
194
+ const WORKING_SIGNS_TS_ATTRIBUTE = "working_signs_ts";
195
+
196
+ /**
197
+ * What an operator does when one sign call fails with a setup error (`SIGN_SETUP_ERRORS`). The sign
198
+ * needs a scope or the agent view, and both come from the app manifest, so the fix is the manifest
199
+ * paste. Logged once per method and error, so a workspace without the feature does not log it on
200
+ * every turn.
201
+ */
202
+ const SIGN_SETUP_FIX: Readonly<Record<string, string>> = {
203
+ "reactions.add": "the 👀 and the 🛑 need the reactions:write bot scope",
204
+ "reactions.remove": "the 👀 and the 🛑 need the reactions:write bot scope",
205
+ "assistant.threads.setStatus": "the DM status line needs the assistant:write bot scope and the agent view (features.agent_view)",
206
+ };
207
+ /** Slack's answers that mean the app lacks a scope or a feature: a setup gap, not a passing fault. */
208
+ const SIGN_SETUP_ERRORS = new Set(["missing_scope", "feature_disabled", "not_allowed_token_type", "not_allowed", "access_denied"]);
209
+ /** Slack's answers that say the sign is already in the state asked for. */
210
+ const SIGN_NO_OP_ERRORS = new Set(["already_reacted", "no_reaction"]);
211
+ const warnedSignErrors = new Set<string>();
212
+
213
+ /** The DM door's sign seam over a form call. Never throws; a failure is logged once per method and error, with the setup fix. */
214
+ function formSignRequest(form: SlackFormCall): SlackSignRequest {
215
+ return async (method, params) => {
216
+ let error: string;
217
+ try {
218
+ const res = await form(method, new URLSearchParams({ ...params }).toString());
219
+ if (res?.ok === true) return;
220
+ error = String(res?.error ?? "no answer");
221
+ } catch (err) {
222
+ error = String(err);
223
+ }
224
+ if (SIGN_NO_OP_ERRORS.has(error)) return;
225
+ const key = `${method}:${error}`;
226
+ if (warnedSignErrors.has(key)) return;
227
+ warnedSignErrors.add(key);
228
+ const fix = SIGN_SETUP_ERRORS.has(error)
229
+ ? `${SIGN_SETUP_FIX[method] ?? "this sign needs a manifest scope"}. Fix: paste the app manifest again (App Manifest → Save Changes) and reinstall if Slack asks`
230
+ : "the sign is best-effort and the turn goes on";
231
+ console.warn(`[open-everywhere:slack-session] ${method} failed (${error}) — ${fix}. Logged once per process.`);
232
+ };
233
+ }
234
+
235
+ /** Test-only: forget which sign failures were already logged. */
236
+ export function _resetSignWarningsForTest(): void {
237
+ warnedSignErrors.clear();
238
+ }
239
+
240
+ /** The message a DM-door delivery's signs ride, or undefined when the door stamped none. Pure. */
241
+ export function slackWorkingSignsOf(attributes: Readonly<Record<string, unknown>>): { readonly channelId: string; readonly threadTs: string; readonly teamId?: string } | undefined {
242
+ if (attributes.channel_type !== "im" && attributes.channel_type !== "mpim") return undefined;
243
+ const channelId = attributes.channel_id;
244
+ const stamped = attributes[WORKING_SIGNS_TS_ATTRIBUTE];
245
+ if (typeof channelId !== "string" || channelId.length === 0 || typeof stamped !== "string" || stamped.length === 0) return undefined;
246
+ return { channelId, threadTs: stamped, ...(typeof attributes.team_id === "string" ? { teamId: attributes.team_id } : {}) };
247
+ }
248
+
249
+ /**
250
+ * Set the DM turn's signs for one state: 👀 and `Working...` at `processing`; at a settle, no
251
+ * reaction (or 🛑 when the turn failed) and a cleared status line. The two calls run in parallel,
252
+ * because the turn waits on this setter. A swallowed room op (the egress spoof) sends neither.
253
+ */
254
+ async function setSlackDmWorkingSigns(root: { readonly channelId: string; readonly threadTs: string }, status: SurfaceSessionStatus, end: SurfaceTurnEnd | undefined, request: SlackSignRequest): Promise<void> {
255
+ const working = status === "processing";
256
+ if (swallowRoomOp(`a slack DM working sign (${working ? "start" : end?.failed === true ? "failed" : "end"})`)) return;
257
+ await Promise.all([
258
+ setSlackStatusReaction(request, { channel: root.channelId, timestamp: root.threadTs }, working ? "working" : end?.failed === true ? "blocked" : "none"),
259
+ setSlackThreadStatus(request, root, working ? SLACK_WORKING_STATUS : ""),
260
+ ]);
261
+ }
262
+
263
+ /**
264
+ * The Slack setter core's turn-anchor hook calls (registered in register.ts). It sets the agent
265
+ * session's state for a 1:1 DM, and the working signs for every DM-door delivery. It answers the id
266
+ * of the conversation it named: the agent session, else the message the signs ride.
267
+ */
163
268
  export function slackSurfaceSessionSetter(form: SlackFormCall = slackForm): SurfaceSessionSetter {
164
- return async (attributes, status) => {
269
+ const request = formSignRequest(form);
270
+ return async (attributes, status, end) => {
165
271
  const session = slackAgentSessionOf(attributes);
166
- if (!session) return undefined;
167
- await setSlackSessionStatus({ ...session, status }, form);
168
- return `${session.channelId}:${session.threadTs}`;
272
+ const signs = slackWorkingSignsOf(attributes);
273
+ const named = session ?? signs;
274
+ if (!named) return undefined;
275
+ await Promise.all([
276
+ ...(session ? [setSlackSessionStatus({ ...session, status }, form)] : []),
277
+ ...(signs ? [setSlackDmWorkingSigns(signs, status, end, request)] : []),
278
+ ]);
279
+ return `${named.channelId}:${named.threadTs}`;
280
+ };
281
+ }
282
+
283
+ /**
284
+ * The Slack stepper (OPE-2632): each step of a DM-door turn shows its code-owned label on the status
285
+ * line, or a status verb — the mention door's own `actions.requested` text (slack-working-signs.ts
286
+ * `slackStepStatus`). A delivery with no sign stamp shows nothing here.
287
+ */
288
+ export function slackSurfaceSessionStepper(form: SlackFormCall = slackForm): SurfaceSessionStepper {
289
+ const request = formSignRequest(form);
290
+ return async (attributes, actions) => {
291
+ const signs = slackWorkingSignsOf(attributes);
292
+ if (!signs) return;
293
+ if (swallowRoomOp("a slack DM step label")) return;
294
+ await setSlackThreadStatus(request, signs, slackStepStatus(actions as readonly StepAction[], { channelId: signs.channelId, teamId: signs.teamId ?? null }));
169
295
  };
170
296
  }
171
297
 
@@ -103,6 +103,7 @@ import {
103
103
  type EngagementRow,
104
104
  type ChannelSessionRow,
105
105
  type RoomRoster,
106
+ type RoomShape,
106
107
  type ContextLine,
107
108
  type WakeAttachment,
108
109
  type WakeMessageInput,
@@ -120,7 +121,9 @@ import {
120
121
  slackMode,
121
122
  buildSlackAuth,
122
123
  channelFactsFromInfo,
124
+ imRosterFactsFromInfo,
123
125
  partnerFactsFromInfo,
126
+ slackUserGuestFlagged,
124
127
  appCanReadConversation,
125
128
  type ChannelFacts,
126
129
  type OpenEverywhereSlackAuth,
@@ -3530,7 +3533,7 @@ const TRANSCRIPT_TOTAL_CHARS = 4000; // whole-block cap — the wake prompt stay
3530
3533
  // instead of a hunt through every caller.
3531
3534
  //
3532
3535
  // 🔴 THE DEFAULT IS RESOLVED, NOT AN ENV READ (OPE-2231). It used to default to
3533
- // `process.env.SLACK_BOT_TOKEN` in the parameter list, which a default cannot be any more: the token
3536
+ // the SLACK_BOT_TOKEN env var in the parameter list, which a default cannot be any more: the token
3534
3537
  // may live in the credential store, so resolving it is async. The resolution therefore moved into the
3535
3538
  // body. An explicitly-passed token (the multi-bot seam) still wins untouched; no caller in the tree
3536
3539
  // passes one today, which is why this is a default-shape change and not a behaviour change for any
@@ -3785,13 +3788,16 @@ function slackRosterFailureState(res: Record<string, unknown> | undefined): "uno
3785
3788
  /**
3786
3789
  * Live Slack roster for a governance channel's floor tripwire (OPE-110, access/audience-floor.ts).
3787
3790
  * Up to three Slack reads: conversations.info (is_private + externally-shared bit),
3788
- * conversations.members (member ids) and auth.test (the bot's own user id). The roster excludes the
3791
+ * conversations.members (member ids) and auth.test (the bot's own user id). A 1:1 DM adds a fourth,
3792
+ * users.info on the partner, and reads neither `is_org_shared` nor `is_shared` (OPE-2630, {@link slackImRoster}). The roster excludes the
3789
3793
  * bot's own user id, so the agent doesn't count as a non-admin member. A failed conversations.info
3790
3794
  * or conversations.members read, a members list of a NON-SHARED channel longer than one page, or a
3791
3795
  * thrown error returns `resolved: false`, with an `unresolvedState` (OPE-2306). A transient failure
3792
3796
  * is `failed`: a non-ok answer outside SLACK_ROSTER_UNOBSERVABLE_ERRORS, no answer, a thrown error, a
3793
3797
  * conversations.info answer with no channel object. A steady state is `unobservable`: a code in that
3794
- * set, or a non-shared members list longer than one page. A failed auth.test does not unresolve the roster: the roster stays resolved and counts the bot as a
3798
+ * set, or a non-shared members list longer than one page. For a 1:1 DM, a failed partner users.info
3799
+ * read also returns `resolved: false`, with the same split ({@link slackImRoster}): so a short users.info
3800
+ * outage makes the connector-disclosure floor refuse in every 1:1 DM for up to 30 s. A failed auth.test does not unresolve the roster: the roster stays resolved and counts the bot as a
3795
3801
  * non-admin member, so the governance channel does not arm. `resolved: false` is closed for the
3796
3802
  * governance tripwire: the floor computes to `guest`, so the channel can never arm. It is
3797
3803
  * also closed for the clicker floor's beneficiary leg, which refuses on it (`unattested-room`). It
@@ -3803,7 +3809,8 @@ function slackRosterFailureState(res: Record<string, unknown> | undefined): "uno
3803
3809
  * answer for UNRESOLVED_ROSTER_TTL_MS (30 s) only, not for the full memo TTL (OPE-2150), so one
3804
3810
  * failed read disarms the inert legs, and refuses the connector-disclosure floor, for at most that
3805
3811
  * short window.
3806
- * `hasGuest` uses the channel's externally-shared bit as the available external signal;
3812
+ * `hasGuest` uses the channel's externally-shared bit as the available external signal (a 1:1 DM
3813
+ * reads the Slack Connect bits and the partner's guest flags instead, {@link slackImRoster});
3807
3814
  * per-member guest classification rides the guest-detection work (design §2/§10 fragile-point #1) and
3808
3815
  * is a future tightening, not a loosening (an internal-only private channel already needs every
3809
3816
  * member on the roster to arm). Governance channels are small by config lint, so a single members
@@ -3834,7 +3841,7 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
3834
3841
  // with `hasGuest: true` and an EMPTY member list, never a partial one. The floor is `guest` for every consumer, and
3835
3842
  // the observed-guest legs refuse. The share bit is a steady state, so the full memo TTL is fine.
3836
3843
  if (facts.trustChecked === true && facts.isExternal === true) {
3837
- return { humanMemberIds: [], resolved: true, isPrivate: facts.isPrivate === true, hasGuest: true };
3844
+ return { humanMemberIds: [], resolved: true, isPrivate: facts.isPrivate === true, hasGuest: true, ...slackRoomShape(facts) };
3838
3845
  }
3839
3846
  // An ok conversations.info with no channel object is an odd answer, not a steady state: it is
3840
3847
  // `failed`, as on the one-page path below.
@@ -3853,6 +3860,8 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
3853
3860
  const humanMemberIds = (members.members as unknown[])
3854
3861
  .filter((m): m is string => typeof m === "string")
3855
3862
  .filter((m) => m !== botId);
3863
+ const im = imRosterFactsFromInfo(info);
3864
+ if (im) return await slackImRoster(im, humanMemberIds, facts.isPrivate === true);
3856
3865
  return {
3857
3866
  humanMemberIds,
3858
3867
  resolved: facts.trustChecked === true, // conversations.info authoritative
@@ -3860,12 +3869,57 @@ export async function slackGovernanceRoster(channelId: string): Promise<RoomRost
3860
3869
  ...(facts.trustChecked === true ? {} : { unresolvedState: "failed" as const }),
3861
3870
  isPrivate: facts.isPrivate === true,
3862
3871
  hasGuest: facts.isExternal === true,
3872
+ ...slackRoomShape(facts),
3863
3873
  };
3864
3874
  } catch {
3865
3875
  return { humanMemberIds: [], resolved: false, unresolvedState: "failed", isPrivate: false, hasGuest: false };
3866
3876
  }
3867
3877
  }
3868
3878
 
3879
+ /**
3880
+ * The roster of a 1:1 DM (OPE-2630). A DM has two parties, the app and one person, so its audience is
3881
+ * that person. `hasGuest` is true when Slack OBSERVES an outside party: a Slack Connect DM
3882
+ * (`is_ext_shared` / `is_pending_ext_shared`), or a partner whose `users.info` carries a guest flag
3883
+ * (`slackUserGuestFlagged`). `is_org_shared` and `is_shared` do not count. `is_org_shared` marks a
3884
+ * share across the Grid's own workspaces. `is_shared` marks any share, so a Connect DM sets it too, but
3885
+ * a Connect DM also carries the Connect bits read here. Reading the two as a guest refused every
3886
+ * approval card in a full member's own Grid DM (`imRosterFactsFromInfo` holds the reason).
3887
+ *
3888
+ * The workspace rung walk (`partnerFactsFromInfo`) is NOT read here. An undeclared Grid workspace is
3889
+ * a guest by INFERENCE (OPE-1508), not by observation, and the observed-guest legs of the floor refuse
3890
+ * only an observation (access/audience-floor.ts, the polarity rule). Reading the walk here would
3891
+ * floor every DM of an undeclared Grid to `guest` again.
3892
+ *
3893
+ * A failed `users.info` read does not resolve the roster: `failed` on a transient error, and
3894
+ * `unobservable` on a steady one (`missing_scope`), the same split as the room reads above.
3895
+ */
3896
+ async function slackImRoster(
3897
+ im: { readonly partnerId?: string; readonly connectShared: boolean },
3898
+ humanMemberIds: readonly string[],
3899
+ isPrivate: boolean,
3900
+ ): Promise<RoomRoster> {
3901
+ if (im.connectShared) return { humanMemberIds, resolved: true, isPrivate, hasGuest: true, room: "dm" };
3902
+ const partnerId = im.partnerId ?? (humanMemberIds.length === 1 ? humanMemberIds[0] : undefined);
3903
+ // A DM that names no partner gives no person to ask about. The roster then rests on the Connect
3904
+ // bits alone (read above): resolved, with no outside party observed. Before OPE-2630 a DM like this
3905
+ // with `is_org_shared` or `is_shared` set floored to `guest`; now it does not.
3906
+ if (partnerId === undefined) return { humanMemberIds, resolved: true, isPrivate, hasGuest: false, room: "dm" };
3907
+ const who = await slackForm("users.info", `user=${encodeURIComponent(partnerId)}`);
3908
+ const user = who?.ok === true ? (who.user as Record<string, unknown> | undefined) : undefined;
3909
+ if (!user || typeof user !== "object") {
3910
+ return { humanMemberIds: [], resolved: false, unresolvedState: who?.ok === true ? "failed" : slackRosterFailureState(who), isPrivate: false, hasGuest: false };
3911
+ }
3912
+ return { humanMemberIds, resolved: true, isPrivate, hasGuest: slackUserGuestFlagged(user), room: "dm" };
3913
+ }
3914
+
3915
+ /** The roster's `room` from a `conversations.info` channel type, or nothing when Slack did not say. */
3916
+ function slackRoomShape(facts: ChannelFacts): { readonly room?: RoomShape } {
3917
+ if (facts.channelType === "im") return { room: "dm" };
3918
+ if (facts.channelType === "mpim") return { room: "group-dm" };
3919
+ if (facts.channelType === "channel" || facts.channelType === "group") return { room: "channel" };
3920
+ return {};
3921
+ }
3922
+
3869
3923
  /**
3870
3924
  * Register {@link slackGovernanceRoster} as the governance floor-tripwire member supplier (OPE-110).
3871
3925
  * Core owns the seam + the fail-closed default; this is the Slack arm, wired at boot from
@@ -1114,9 +1114,9 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1114
1114
  // instead, so the resolver may touch the vault. It is awaited ONCE at the top of
1115
1115
  // `handleFrontRequest` and the VALUE is used by both readers below (the inbound verify and the
1116
1116
  // native forward's re-sign) — one resolution per request, and the two can never disagree about
1117
- // which secret this request is being held to, which is what the re-sign depends on. A deployment
1118
- // with `SLACK_SIGNING_SECRET` set never reaches the store (the resolver is env-first), so this is
1119
- // the same property read it always was for every pre-OPE-2231 deployment.
1117
+ // which secret this request is being held to, which is what the re-sign depends on. The
1118
+ // `SLACK_SIGNING_SECRET` operator secret comes first (OPE-2650), and its store lookup is remembered
1119
+ // for thirty seconds, so the steady state costs no store round trip per request.
1120
1120
  const resolveSigningSecret = async (): Promise<string | undefined> => opts.signingSecret ?? (await slackSigningSecret());
1121
1121
  const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
1122
1122
  const stampEventVerified = opts.stampEventVerified ?? (() => stampSurfaceEventVerified("slack"));
@@ -1229,7 +1229,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1229
1229
  * the SAME seam (OPE-2231: `channels/slack.ts` hands `slackChannel` a `webhookVerifier` built on
1230
1230
  * `setup/credentials.ts`), so there is no path on which the two can disagree about which secret
1231
1231
  * this request is held to. Before that wiring there WAS one — eve fell back to
1232
- * `process.env.SLACK_SIGNING_SECRET` while this route read the credential store — and a
1232
+ * the SLACK_SIGNING_SECRET env var while this route read the credential store — and a
1233
1233
  * store-configured deployment 401'd in the inner handler after passing here.
1234
1234
  *
1235
1235
  * FAIL-SOFT TO TODAY'S BEHAVIOUR. If the body carried nothing to strip (the common path), or the
@@ -1501,6 +1501,10 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1501
1501
  * rides `authoredText` behind a `[voice clip]` marker, and a clip OE could not hear adds a
1502
1502
  * host-written line, so neither is the person's typed text. */
1503
1503
  agentSessionTypedText?: string,
1504
+ /** OPE-2632: the wake names a real message for the turn's working signs to ride: a `message.im`,
1505
+ * a `message.mpim`, a group-DM mention, or a follow-up press inside a thread (its thread root).
1506
+ * A shortcut and a top-level press leave it false: a minted ts names no message. */
1507
+ showsWorkingSigns = false,
1504
1508
  ): Promise<void> => {
1505
1509
  const opensAgentSession = agentSessionTypedText !== undefined;
1506
1510
  // 🔴 THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
@@ -1659,6 +1663,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1659
1663
  ...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
1660
1664
  // OPE-2519: the 1:1 message's Slack agent session — its thread, or itself when top-level.
1661
1665
  ...(opensAgentSession && !isGroup ? { agentSessionTs: ev.threadTs ?? ev.ts } : {}),
1666
+ // OPE-2632: the message the turn's 👀, status line and 🛑 ride — the same root, on a 1:1 and a group DM.
1667
+ ...(showsWorkingSigns && (ev.threadTs ?? ev.ts) ? { workingSignsTs: ev.threadTs ?? ev.ts } : {}),
1662
1668
  // 🔴 THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
1663
1669
  // channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
1664
1670
  // reads the pair through `slackTrust`, so this is the binding's only route to the fact
@@ -1980,7 +1986,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1980
1986
  // A voice clip is heard ONCE, before the retry wrapper, so a strand retry never pays for a second
1981
1987
  // transcription (OPE-2524, slack-voice-clips.ts). A DM is always addressed, so its clips are always heard.
1982
1988
  const heard = await hearVoice(dev);
1983
- await deliverWithStrandRetry(() => scheduleDmWake(heard, decision.dmKind, driverAdmitted, decision.dmKind === "im" ? dev.authoredText : undefined));
1989
+ await deliverWithStrandRetry(() => scheduleDmWake(heard, decision.dmKind, driverAdmitted, decision.dmKind === "im" ? dev.authoredText : undefined, true));
1984
1990
  console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke — diverted to the DM window seat`);
1985
1991
  }),
1986
1992
  );
@@ -2195,7 +2201,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2195
2201
  fifo.enqueue(dmFifoKey(mev.channelId), async () => {
2196
2202
  // A bot's group-DM mention is not heard: `scheduleDmWake` drops a bot author after this step.
2197
2203
  const heard = mev.botId ? mev : await hearVoice(mev);
2198
- await deliverWithStrandRetry(() => scheduleDmWake({ ...heard, channelType: "mpim" }, "mpim"));
2204
+ await deliverWithStrandRetry(() => scheduleDmWake({ ...heard, channelType: "mpim" }, "mpim", false, undefined, true));
2199
2205
  }),
2200
2206
  );
2201
2207
  return OK();
@@ -2384,7 +2390,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2384
2390
  // The rewrite removes the buttons and shows what the person sent. The wake takes the SAME road a
2385
2391
  // typed DM takes, so the seat, the trust probe, the external-participant policy and the spend
2386
2392
  // gate all decide it. The agent session is touched only when the answer sits in a thread: a
2387
- // top-level answer has no session thread for a minted ts to name.
2393
+ // top-level answer has no session thread for a minted ts to name. The working signs (OPE-2632)
2394
+ // follow the same rule: a threaded press shows them on its thread root, a real message.
2388
2395
  args.waitUntil(
2389
2396
  markFollowUpPressed(click)
2390
2397
  .then((rewritten) => {
@@ -2394,7 +2401,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2394
2401
  );
2395
2402
  args.waitUntil(
2396
2403
  fifo.enqueue(dmFifoKey(click.channelId), async () => {
2397
- await deliverWithStrandRetry(() => scheduleDmWake(sev, "im", false, click.threadTs === undefined ? undefined : click.text));
2404
+ await deliverWithStrandRetry(() => scheduleDmWake(sev, "im", false, click.threadTs === undefined ? undefined : click.text, click.threadTs !== undefined));
2398
2405
  console.log(`[open-everywhere:slack-front] follow-up press in ${click.channelId}: woke the DM window`);
2399
2406
  }),
2400
2407
  );
@@ -62,6 +62,13 @@ export interface SlackIdentityInput {
62
62
  * reply still reads `thread_ts`.
63
63
  */
64
64
  readonly agentSessionTs?: string;
65
+ /**
66
+ * For a DM-family message (a 1:1 or a group DM) the person sent (OPE-2632): the message the turn's
67
+ * working signs ride — its `thread_ts`, or its own `ts` when top-level, the root the mention door's
68
+ * 👀 uses too. channels/slack-agent-session.ts reads it. A shortcut stamps none: its ts names no
69
+ * message. It changes no reply target.
70
+ */
71
+ readonly workingSignsTs?: string;
65
72
  }
66
73
 
67
74
  /** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
@@ -79,6 +86,7 @@ export function buildSlackAuth(input: SlackIdentityInput): OpenEverywhereSlackAu
79
86
  if (typeof input.trustChecked === "boolean") attributes.trust_checked = String(input.trustChecked);
80
87
  if (input.dmPartnerTrust) attributes.dm_partner_trust = input.dmPartnerTrust;
81
88
  if (input.agentSessionTs) attributes.agent_session_ts = input.agentSessionTs;
89
+ if (input.workingSignsTs) attributes.working_signs_ts = input.workingSignsTs;
82
90
  // Root-DM grain stamp (window-tool-gating §D-DM, widened to group DMs): a 1:1 DM OR an mpim (group
83
91
  // DM) is a STANDING seat ⇒ narrowed hands — both are closed multi-party conversations with no
84
92
  // channel identity. A DM is unambiguously channelType "im" (the DM path always sets it); the
@@ -265,7 +273,7 @@ function slackWorkspaceVouched(teamId: string | undefined, enterpriseId: string
265
273
  export function partnerFactsFromInfo(info: unknown, expectedTeamId?: string): { dmPartnerTrust: "internal" | "external" } {
266
274
  const u = (info as { user?: Record<string, unknown> } | null)?.user;
267
275
  if (!u || typeof u !== "object") return { dmPartnerTrust: "external" }; // lookup failed → fail closed
268
- const guestOrStranger = Boolean(u["is_restricted"] || u["is_ultra_restricted"] || u["is_stranger"]);
276
+ const guestOrStranger = slackUserGuestFlagged(u);
269
277
  const deleted = Boolean(u["deleted"]);
270
278
  const homeTeamId = typeof u["team_id"] === "string" ? (u["team_id"] as string) : undefined;
271
279
  // 🔴 NO EXPECTED WORKSPACE IS NOT AN OWN-WORKSPACE ANSWER. This used to read the absence as
@@ -288,6 +296,39 @@ export function partnerFactsFromInfo(info: unknown, expectedTeamId?: string): {
288
296
  return { dmPartnerTrust: internal ? "internal" : "external" };
289
297
  }
290
298
 
299
+ /** Slack's own guest flags on a `users.info` user object: a multi-channel guest (`is_restricted`), a
300
+ * single-channel guest (`is_ultra_restricted`) or a Slack Connect stranger (`is_stranger`). The ONE
301
+ * reader of these three flags, so {@link partnerFactsFromInfo} and the 1:1 DM roster
302
+ * ({@link imRosterFactsFromInfo}) cannot disagree on what Slack calls a guest. It reads no workspace
303
+ * and no declaration: it is what Slack OBSERVED about the person, nothing inferred. */
304
+ export function slackUserGuestFlagged(u: Record<string, unknown>): boolean {
305
+ return Boolean(u["is_restricted"] || u["is_ultra_restricted"] || u["is_stranger"]);
306
+ }
307
+
308
+ /**
309
+ * The room facts of a 1:1 DM (`is_im`) from a `conversations.info` response, or `undefined` for any
310
+ * other conversation (OPE-2630). The DM roster reads these instead of
311
+ * {@link ChannelFacts.isExternal}.
312
+ *
313
+ * 🔴 WHY A DM DOES NOT READ `isExternal`. `isExternal` is true on ANY of the four share bits.
314
+ * `is_org_shared` marks a share across the workspaces of ONE Enterprise Grid organisation. `is_shared`
315
+ * marks ANY share, a Slack Connect one included, so it alone does not tell a Grid share from a Connect
316
+ * one. A Connect DM also carries `is_ext_shared` (or `is_pending_ext_shared`), and this function reads
317
+ * those two bits. A 1:1 DM has exactly two parties, the app and one person, so `is_org_shared` or `is_shared` on it
318
+ * says nothing about an outside audience. Read as "external", they floored a full member's own DM to
319
+ * `guest`, and the card-disclosure floor refused every approval card there (OPE-2630, a Grid install).
320
+ * So a DM's audience is judged by the two facts that do name an outside party: the Slack Connect bits
321
+ * (`is_ext_shared`, `is_pending_ext_shared`) here, and the partner's own guest flags
322
+ * ({@link slackUserGuestFlagged}), which the roster reads with `users.info`.
323
+ */
324
+ export function imRosterFactsFromInfo(info: unknown): { readonly partnerId?: string; readonly connectShared: boolean } | undefined {
325
+ const ch = (info as { channel?: Record<string, unknown> } | null)?.channel;
326
+ if (!ch || typeof ch !== "object" || ch["is_im"] !== true) return undefined;
327
+ const partnerId = typeof ch["user"] === "string" && ch["user"].length > 0 ? (ch["user"] as string) : undefined;
328
+ const connectShared = Boolean(ch["is_ext_shared"] || ch["is_pending_ext_shared"]);
329
+ return { ...(partnerId === undefined ? {} : { partnerId }), connectShared };
330
+ }
331
+
291
332
  /** What an app_mention envelope tells us directly (often just channel_type). */
292
333
  export function channelFactsFromRaw(raw: Record<string, unknown>): ChannelFacts {
293
334
  const channelType = typeof raw["channel_type"] === "string" ? (raw["channel_type"] as string) : undefined;
@@ -24,7 +24,7 @@
24
24
  // so form is the only safe default. A null or undefined value is dropped; a string, number or
25
25
  // boolean is stringified; anything else is JSON-encoded.
26
26
  // • Token resolution. An explicit token wins, otherwise the deployment's resolved bot token
27
- // (`SLACK_BOT_TOKEN`, then the credential store — OPE-2231). Neither ⇒ throw
27
+ // (the `SLACK_BOT_TOKEN` operator secret, then the `/setup` value — OPE-2231, OPE-2650). Neither ⇒ throw
28
28
  // `SLACK_BOT_TOKEN is required.` — the same message and the same fail-loud posture. ONE
29
29
  // DELIBERATE NARROWING: eve's `SlackBotToken` also accepts a function (the secret-manager and
30
30
  // rotation form). No caller in this repo passes one, so this seam takes a string. Widening it
@@ -137,8 +137,8 @@ const PROBE_CAP_MS = 2_000;
137
137
  *
138
138
  * 🔴 ASYNC SINCE OPE-2231, and the awaits are the whole change at the call site. The token used to be
139
139
  * a property read; it can now come from the encrypted vault, because `/setup` captures it there. The
140
- * resolver memoises a store hit per process, and a deployment with `SLACK_BOT_TOKEN` set never
141
- * reaches the store at all, so the hot path for every pre-OPE-2231 deployment is unchanged.
140
+ * resolver remembers each store lookup for thirty seconds (the `SLACK_BOT_TOKEN` operator secret and
141
+ * the `/setup` value alike, OPE-2650), so the steady state costs no store round trip per call.
142
142
  */
143
143
  async function resolveBotToken(botToken: string | undefined): Promise<string> {
144
144
  const token = botToken ?? (await slackBotToken());
@@ -0,0 +1,71 @@
1
+ // THE WORKING SIGNS (OPE-2632) — the ONE implementation of what a person sees while the bot works.
2
+ //
3
+ // Two doors start a Slack turn, and before OPE-2632 each drew the signs its own way:
4
+ // • the MENTION DOOR (slack.ts): eve's Slack channel, for a mention in a channel or a thread;
5
+ // • the DM DOOR (slack-agent-session.ts): a 1:1 DM or a group DM, diverted to the DM seat on the
6
+ // `unified` channel, where the Slack channel's own handlers never run. Core's turn-anchor hook
7
+ // drives its signs through the Slack setter and stepper instead.
8
+ // Both now call the functions in this file, so a person sees the same signs at either door:
9
+ // • ONE status reaction on the message: 👀 while the turn works, 🛑 when it fails, and none when it
10
+ // ends (absence is the done signal; ✅ stays reserved for people). See `setSlackStatusReaction`.
11
+ // • the LIVE STATUS LINE (`assistant.threads.setStatus`): `Working...` at the start, then each step's
12
+ // code-owned label ("Searching Notion…", slack-step-label.ts) or a status verb, and cleared at
13
+ // the end. See `slackStepStatus` and `setSlackThreadStatus`.
14
+ // The transport differs (eve's `slack.request` and `startTyping` at the mention door, the host's
15
+ // `slackForm` at the DM door), so each function takes a request seam that never throws.
16
+
17
+ import { getConfig, resolvePartition, STATUS_REACTION, statusVerbsFor, swallowRoomOp, type ReactionState } from "@open-everywhere/core/agent/adapter-api.js";
18
+ import { stepStatusFor, type StepAction } from "./slack-step-label.js";
19
+
20
+ /** One Slack Web API call for a sign. It must never throw: a sign is best-effort and never fails a turn. */
21
+ export type SlackSignRequest = (method: string, params: Readonly<Record<string, string>>) => Promise<void>;
22
+
23
+ /** The status line at a turn's start, at both doors. */
24
+ export const SLACK_WORKING_STATUS = "Working...";
25
+
26
+ /**
27
+ * Set the ONE status reaction on a message: add `state`'s emoji and remove every other status emoji.
28
+ * `"none"` removes them all. The calls run in parallel, because a DM turn waits on this. A swallowed
29
+ * room op (the egress spoof, core channels/egress-spoof.ts) sends nothing.
30
+ */
31
+ export async function setSlackStatusReaction(request: SlackSignRequest, target: { readonly channel: string; readonly timestamp: string }, state: ReactionState | "none"): Promise<void> {
32
+ if (swallowRoomOp("a slack status reaction")) return;
33
+ await Promise.all(
34
+ Object.entries(STATUS_REACTION).map(([s, name]) => request(s === state ? "reactions.add" : "reactions.remove", { channel: target.channel, name, timestamp: target.timestamp })),
35
+ );
36
+ }
37
+
38
+ /** Set the live status line on a thread. An empty `status` clears it. */
39
+ export async function setSlackThreadStatus(request: SlackSignRequest, target: { readonly channelId: string; readonly threadTs: string }, status: string): Promise<void> {
40
+ await request("assistant.threads.setStatus", { channel_id: target.channelId, thread_ts: target.threadTs, status });
41
+ }
42
+
43
+ // --- status verbs (the fun bit) ----------------------------------------------
44
+ // "Tinkering…" / "Untangling…" instead of a flat "Thinking…"/"Working…" — a random
45
+ // pick per status update from the channel's effective vocabulary (framework defaults
46
+ // ← org ← role ← domain, extend-or-replace; memory/domain.ts statusVerbsFor — a
47
+ // domain's ROLE preset contributes its themed verbs as its own rung). Orgs add house
48
+ // verbs, a team adds its in-jokes. Cosmetic only, so ANY failure falls back to a
49
+ // plain verb — the status line must never take a turn down.
50
+ export function slackStatusVerb(state: { channelId?: string | null; teamId?: string | null }): string {
51
+ try {
52
+ const p = resolvePartition({
53
+ channel: { kind: "slack" },
54
+ session: { auth: { current: { attributes: { team_id: state.teamId ?? "", channel_id: state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
55
+ });
56
+ const cfg = getConfig();
57
+ const verbs = statusVerbsFor(p.surfaceKey, cfg.loadDomains(), cfg.loadRoles?.() ?? []);
58
+ return `${verbs[Math.floor(Math.random() * verbs.length)]}…`;
59
+ } catch {
60
+ return "Working…";
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The status line for one step: the first labelled action's code-owned label (OPE-2449), else a
66
+ * status verb. Never the model's own text: slack-step-label.ts reads only the tool's name and, for a
67
+ * connector, the vendor through its own table. `actions` undefined (a reasoning refresh) shows a verb.
68
+ */
69
+ export function slackStepStatus(actions: readonly StepAction[] | undefined, state: { channelId?: string | null; teamId?: string | null }): string {
70
+ return stepStatusFor(actions) ?? slackStatusVerb(state);
71
+ }