@open-everywhere/provider-slack 0.8.1 → 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 (62) hide show
  1. package/agent/channels/slack-agent-session.ts +100 -63
  2. package/agent/channels/slack-events.ts +1 -1
  3. package/agent/channels/slack-front.ts +15 -8
  4. package/agent/channels/slack-identity.ts +8 -0
  5. package/agent/channels/slack-web-api.ts +3 -3
  6. package/agent/channels/slack-working-signs.ts +71 -0
  7. package/agent/channels/slack.ts +36 -50
  8. package/agent/docs-generated.ts +36 -16
  9. package/agent/ops.ts +4 -0
  10. package/agent/register.ts +4 -2
  11. package/agent/setup/claim.ts +9 -8
  12. package/agent/setup/credentials.ts +82 -79
  13. package/agent/setup/eve-credentials.ts +2 -2
  14. package/agent/setup/page.ts +3 -3
  15. package/agent/status.ts +12 -4
  16. package/dist/agent/channels/slack-agent-session.d.ts +19 -3
  17. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -1
  18. package/dist/agent/channels/slack-agent-session.js +102 -65
  19. package/dist/agent/channels/slack-agent-session.js.map +1 -1
  20. package/dist/agent/channels/slack-events.js +1 -1
  21. package/dist/agent/channels/slack-events.js.map +1 -1
  22. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-front.js +16 -9
  24. package/dist/agent/channels/slack-front.js.map +1 -1
  25. package/dist/agent/channels/slack-identity.d.ts +7 -0
  26. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  27. package/dist/agent/channels/slack-identity.js +2 -0
  28. package/dist/agent/channels/slack-identity.js.map +1 -1
  29. package/dist/agent/channels/slack-web-api.js +3 -3
  30. package/dist/agent/channels/slack-web-api.js.map +1 -1
  31. package/dist/agent/channels/slack-working-signs.d.ts +34 -0
  32. package/dist/agent/channels/slack-working-signs.d.ts.map +1 -0
  33. package/dist/agent/channels/slack-working-signs.js +63 -0
  34. package/dist/agent/channels/slack-working-signs.js.map +1 -0
  35. package/dist/agent/channels/slack.d.ts.map +1 -1
  36. package/dist/agent/channels/slack.js +28 -46
  37. package/dist/agent/channels/slack.js.map +1 -1
  38. package/dist/agent/docs-generated.d.ts.map +1 -1
  39. package/dist/agent/docs-generated.js +36 -16
  40. package/dist/agent/docs-generated.js.map +1 -1
  41. package/dist/agent/ops.d.ts.map +1 -1
  42. package/dist/agent/ops.js +4 -1
  43. package/dist/agent/ops.js.map +1 -1
  44. package/dist/agent/register.d.ts.map +1 -1
  45. package/dist/agent/register.js +4 -2
  46. package/dist/agent/register.js.map +1 -1
  47. package/dist/agent/setup/claim.d.ts.map +1 -1
  48. package/dist/agent/setup/claim.js +9 -8
  49. package/dist/agent/setup/claim.js.map +1 -1
  50. package/dist/agent/setup/credentials.d.ts +21 -20
  51. package/dist/agent/setup/credentials.d.ts.map +1 -1
  52. package/dist/agent/setup/credentials.js +71 -75
  53. package/dist/agent/setup/credentials.js.map +1 -1
  54. package/dist/agent/setup/eve-credentials.js +2 -2
  55. package/dist/agent/setup/eve-credentials.js.map +1 -1
  56. package/dist/agent/setup/page.js +3 -3
  57. package/dist/agent/setup/page.js.map +1 -1
  58. package/dist/agent/status.d.ts +10 -2
  59. package/dist/agent/status.d.ts.map +1 -1
  60. package/dist/agent/status.js +12 -4
  61. package/dist/agent/status.js.map +1 -1
  62. package/package.json +6 -2
@@ -21,8 +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). The same setter shows the 👀 and the status line on the session's
25
- // root while a turn works, and clears both at the park. See `setSlackDmWorkingSigns`.
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.
26
27
  // • A FORWARDED JOB's WAIT (OPE-2536). A job the DM seat forwards to the work session can end waiting
27
28
  // on the person: it asked a question in the room (`ask_room`), or it needs their own sign-in. The
28
29
  // work session has no `update_status`, so the host sets the session `suspended` at the job's
@@ -40,7 +41,6 @@
40
41
  import {
41
42
  cancelAnchoredTurn,
42
43
  resolvePartition,
43
- STATUS_REACTION,
44
44
  swallowRoomOp,
45
45
  turnAnchorKey,
46
46
  turnChannelKey,
@@ -48,8 +48,13 @@ import {
48
48
  type CancelResult,
49
49
  type SurfaceSessionPinner,
50
50
  type SurfaceSessionSetter,
51
+ type SurfaceSessionStatus,
52
+ type SurfaceSessionStepper,
53
+ type SurfaceTurnEnd,
51
54
  } from "@open-everywhere/core/agent/adapter-api.js";
52
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";
53
58
 
54
59
  /** Slack's event for a press of the session's Stop button. */
55
60
  export const AGENT_SESSION_STOPPED_EVENT = "agent_session_stopped";
@@ -63,13 +68,6 @@ export type SlackSessionStatus = "processing" | "active" | "suspended";
63
68
  /** The auth attribute the DM door stamps with the session's thread root (slack-front.ts `scheduleDmWake`). */
64
69
  const AGENT_SESSION_TS_ATTRIBUTE = "agent_session_ts";
65
70
 
66
- /**
67
- * The mark a PINNED bag carries (OPE-2629). A forwarded job's settlement sets the session's state from
68
- * its pin, not from a turn, so the setter must not touch the turn's working signs for it: a turn of the
69
- * same session can be running at that moment, and its 👀 must stay until its own park.
70
- */
71
- const SESSION_PIN_ATTRIBUTE = "oe_session_pin";
72
-
73
71
  /** The form-call seam: `slackForm` in production, a recorder in the tests. */
74
72
  export type SlackFormCall = (method: string, body: string) => Promise<Record<string, unknown> | undefined>;
75
73
 
@@ -166,32 +164,34 @@ export const slackSurfaceSessionPin: SurfaceSessionPinner = (attributes) => {
166
164
  if (!session) return undefined;
167
165
  return {
168
166
  id: `${session.channelId}:${session.threadTs}`,
169
- attributes: { channel_id: session.channelId, channel_type: "im", [AGENT_SESSION_TS_ATTRIBUTE]: session.threadTs, [SESSION_PIN_ATTRIBUTE]: "1" },
167
+ attributes: { channel_id: session.channelId, channel_type: "im", [AGENT_SESSION_TS_ATTRIBUTE]: session.threadTs },
170
168
  };
171
169
  };
172
170
 
173
171
  // --- the working signs ------------------------------------------------------------------------------
174
172
  //
175
- // 🔴 THE DM SEAT'S 👀 AND STATUS LINE LIVE HERE (OPE-2629). A 1:1 DM runs on the `unified` channel, so
176
- // none of the Slack channel's own handlers reach it: `onInbound` (the 👀) and the `turn.started`
177
- // renderer (the status line) serve only eve's native mention door. Before this, a person who DMed
178
- // the app saw neither during the turn. Core's turn-anchor hook calls this setter at every DM turn's
179
- // start (`processing`) and park (`active` or `suspended`), so the signs ride the same two calls:
180
- // • 👀 on the session's thread root — the message itself when it is top-level, the same root the
181
- // mention door's 👀 uses for a threaded one. Added at `processing`, removed at the park.
182
- // • Slack's assistant status (`assistant.threads.setStatus`) on that root: `Working...` at
183
- // `processing`, cleared at the park. The flat DM reply does not clear it, so the park must.
184
- // A forwarded job's settlement (OPE-2536) also calls this setter, from the job's PIN. A turn of the
185
- // same session can run at that moment, so a pinned bag (`SESSION_PIN_ATTRIBUTE`) moves the session
186
- // state only and leaves the signs to the turn's own park.
187
- // A turn that fails for good emits `session.failed` and never parks, so its signs stay: the 👀 until
188
- // the session's next turn parks, and the status line until Slack expires it.
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
189
 
190
- /** The status line a DM turn shows while it works — the same words the mention door's `turn.started` shows. */
191
- export const SLACK_DM_WORKING_STATUS = "Working...";
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
192
 
193
- /** The 👀 the bot sets while it works (core status/status.ts `STATUS_REACTION.working`). */
194
- const WORKING_REACTION = STATUS_REACTION.working;
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
195
 
196
196
  /**
197
197
  * What an operator does when one sign call fails with a setup error (`SIGN_SETUP_ERRORS`). The sign
@@ -200,8 +200,8 @@ const WORKING_REACTION = STATUS_REACTION.working;
200
200
  * every turn.
201
201
  */
202
202
  const SIGN_SETUP_FIX: Readonly<Record<string, string>> = {
203
- "reactions.add": "the 👀 needs the reactions:write bot scope",
204
- "reactions.remove": "the 👀 needs the reactions:write bot scope",
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
205
  "assistant.threads.setStatus": "the DM status line needs the assistant:write bot scope and the agent view (features.agent_view)",
206
206
  };
207
207
  /** Slack's answers that mean the app lacks a scope or a feature: a setup gap, not a passing fault. */
@@ -210,24 +210,26 @@ const SIGN_SETUP_ERRORS = new Set(["missing_scope", "feature_disabled", "not_all
210
210
  const SIGN_NO_OP_ERRORS = new Set(["already_reacted", "no_reaction"]);
211
211
  const warnedSignErrors = new Set<string>();
212
212
 
213
- /** One sign call. Never throws; a failure is logged once per method and error, with the setup fix. */
214
- async function sendSign(form: SlackFormCall, method: string, body: URLSearchParams): Promise<void> {
215
- let error: string;
216
- try {
217
- const res = await form(method, body.toString());
218
- if (res?.ok === true) return;
219
- error = String(res?.error ?? "no answer");
220
- } catch (err) {
221
- error = String(err);
222
- }
223
- if (SIGN_NO_OP_ERRORS.has(error)) return;
224
- const key = `${method}:${error}`;
225
- if (warnedSignErrors.has(key)) return;
226
- warnedSignErrors.add(key);
227
- const fix = SIGN_SETUP_ERRORS.has(error)
228
- ? `${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`
229
- : "the sign is best-effort and the turn goes on";
230
- console.warn(`[open-everywhere:slack-session] ${method} failed (${error}) — ${fix}. Logged once per process.`);
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
+ };
231
233
  }
232
234
 
233
235
  /** Test-only: forget which sign failures were already logged. */
@@ -235,26 +237,61 @@ export function _resetSignWarningsForTest(): void {
235
237
  warnedSignErrors.clear();
236
238
  }
237
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
+
238
249
  /**
239
- * Show or clear the DM turn's working signs on the session's root: the 👀 and the status line. The
240
- * two calls run in parallel, because the turn waits on this setter. A swallowed room op (the egress
241
- * spoof) sends neither. Never throws.
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.
242
253
  */
243
- async function setSlackDmWorkingSigns(session: { readonly channelId: string; readonly threadTs: string }, working: boolean, form: SlackFormCall = slackForm): Promise<void> {
244
- if (swallowRoomOp(`a slack DM working sign ${working ? "add" : "remove"}`)) return;
245
- const reaction = new URLSearchParams({ channel: session.channelId, name: WORKING_REACTION, timestamp: session.threadTs });
246
- const status = new URLSearchParams({ channel_id: session.channelId, thread_ts: session.threadTs, status: working ? SLACK_DM_WORKING_STATUS : "" });
247
- await Promise.all([sendSign(form, working ? "reactions.add" : "reactions.remove", reaction), sendSign(form, "assistant.threads.setStatus", status)]);
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
+ ]);
248
261
  }
249
262
 
250
- /** The Slack setter core's turn-anchor hook calls (registered in register.ts). */
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
+ */
251
268
  export function slackSurfaceSessionSetter(form: SlackFormCall = slackForm): SurfaceSessionSetter {
252
- return async (attributes, status) => {
269
+ const request = formSignRequest(form);
270
+ return async (attributes, status, end) => {
253
271
  const session = slackAgentSessionOf(attributes);
254
- if (!session) return undefined;
255
- const fromTurn = attributes[SESSION_PIN_ATTRIBUTE] === undefined;
256
- await Promise.all([setSlackSessionStatus({ ...session, status }, form), ...(fromTurn ? [setSlackDmWorkingSigns(session, status === "processing", form)] : [])]);
257
- 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 }));
258
295
  };
259
296
  }
260
297
 
@@ -3533,7 +3533,7 @@ const TRANSCRIPT_TOTAL_CHARS = 4000; // whole-block cap — the wake prompt stay
3533
3533
  // instead of a hunt through every caller.
3534
3534
  //
3535
3535
  // 🔴 THE DEFAULT IS RESOLVED, NOT AN ENV READ (OPE-2231). It used to default to
3536
- // `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
3537
3537
  // may live in the credential store, so resolving it is async. The resolution therefore moved into the
3538
3538
  // body. An explicitly-passed token (the multi-bot seam) still wins untouched; no caller in the tree
3539
3539
  // passes one today, which is why this is a default-shape change and not a behaviour change for any
@@ -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
@@ -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
+ }