@open-everywhere/provider-slack 0.7.0 → 0.8.1

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 (127) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +18 -2
  3. package/agent/channels/slack-agent-session.ts +338 -0
  4. package/agent/channels/slack-attachments.ts +20 -11
  5. package/agent/channels/slack-commands.ts +172 -12
  6. package/agent/channels/slack-context.ts +0 -5
  7. package/agent/channels/slack-events.ts +161 -33
  8. package/agent/channels/slack-follow-ups.ts +148 -0
  9. package/agent/channels/slack-front.ts +232 -26
  10. package/agent/channels/slack-home.ts +9 -3
  11. package/agent/channels/slack-identity.ts +43 -2
  12. package/agent/channels/slack-memory-canvas.ts +192 -51
  13. package/agent/channels/slack-origin-codec.ts +9 -0
  14. package/agent/channels/slack-post-arm.ts +68 -11
  15. package/agent/channels/slack-read.ts +27 -2
  16. package/agent/channels/slack-reply-blocks.ts +180 -0
  17. package/agent/channels/slack-reply-feedback.ts +75 -0
  18. package/agent/channels/slack-shortcut.ts +1 -1
  19. package/agent/channels/slack-voice-clips.ts +82 -0
  20. package/agent/channels/slack-wake-backscroll.ts +0 -5
  21. package/agent/channels/slack.ts +74 -13
  22. package/agent/docs-generated.ts +1299 -2
  23. package/agent/ops.ts +14 -21
  24. package/agent/register.ts +13 -0
  25. package/agent/setup/credentials.ts +17 -0
  26. package/agent/setup/page.ts +6 -3
  27. package/agent/surface-parity.ts +2 -0
  28. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
  29. package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
  30. package/dist/agent/app-manifest.d.ts.map +1 -1
  31. package/dist/agent/app-manifest.js +18 -2
  32. package/dist/agent/app-manifest.js.map +1 -1
  33. package/dist/agent/channels/slack-agent-session.d.ts +85 -0
  34. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-agent-session.js +282 -0
  36. package/dist/agent/channels/slack-agent-session.js.map +1 -0
  37. package/dist/agent/channels/slack-attachments.d.ts +11 -0
  38. package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
  39. package/dist/agent/channels/slack-attachments.js +21 -14
  40. package/dist/agent/channels/slack-attachments.js.map +1 -1
  41. package/dist/agent/channels/slack-commands.d.ts +113 -10
  42. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-commands.js +146 -13
  44. package/dist/agent/channels/slack-commands.js.map +1 -1
  45. package/dist/agent/channels/slack-context.d.ts +1 -2
  46. package/dist/agent/channels/slack-context.d.ts.map +1 -1
  47. package/dist/agent/channels/slack-context.js +0 -4
  48. package/dist/agent/channels/slack-context.js.map +1 -1
  49. package/dist/agent/channels/slack-events.d.ts +53 -100
  50. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  51. package/dist/agent/channels/slack-events.js +142 -26
  52. package/dist/agent/channels/slack-events.js.map +1 -1
  53. package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
  54. package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-follow-ups.js +129 -0
  56. package/dist/agent/channels/slack-follow-ups.js.map +1 -0
  57. package/dist/agent/channels/slack-front.d.ts +28 -45
  58. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  59. package/dist/agent/channels/slack-front.js +207 -22
  60. package/dist/agent/channels/slack-front.js.map +1 -1
  61. package/dist/agent/channels/slack-home.d.ts.map +1 -1
  62. package/dist/agent/channels/slack-home.js +10 -3
  63. package/dist/agent/channels/slack-home.js.map +1 -1
  64. package/dist/agent/channels/slack-identity.d.ts +33 -21
  65. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  66. package/dist/agent/channels/slack-identity.js +36 -2
  67. package/dist/agent/channels/slack-identity.js.map +1 -1
  68. package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
  69. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
  70. package/dist/agent/channels/slack-memory-canvas.js +162 -52
  71. package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
  72. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  73. package/dist/agent/channels/slack-origin-codec.js +10 -0
  74. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  75. package/dist/agent/channels/slack-post-arm.d.ts +21 -1
  76. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
  77. package/dist/agent/channels/slack-post-arm.js +57 -10
  78. package/dist/agent/channels/slack-post-arm.js.map +1 -1
  79. package/dist/agent/channels/slack-read.d.ts +11 -1
  80. package/dist/agent/channels/slack-read.d.ts.map +1 -1
  81. package/dist/agent/channels/slack-read.js +27 -3
  82. package/dist/agent/channels/slack-read.js.map +1 -1
  83. package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
  84. package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
  85. package/dist/agent/channels/slack-reply-blocks.js +172 -0
  86. package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
  87. package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
  88. package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
  89. package/dist/agent/channels/slack-reply-feedback.js +63 -0
  90. package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
  91. package/dist/agent/channels/slack-shortcut.d.ts +10 -0
  92. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
  93. package/dist/agent/channels/slack-shortcut.js +1 -1
  94. package/dist/agent/channels/slack-shortcut.js.map +1 -1
  95. package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
  96. package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
  97. package/dist/agent/channels/slack-voice-clips.js +80 -0
  98. package/dist/agent/channels/slack-voice-clips.js.map +1 -0
  99. package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
  100. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
  101. package/dist/agent/channels/slack-wake-backscroll.js +0 -4
  102. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
  103. package/dist/agent/channels/slack.d.ts +22 -1
  104. package/dist/agent/channels/slack.d.ts.map +1 -1
  105. package/dist/agent/channels/slack.js +63 -13
  106. package/dist/agent/channels/slack.js.map +1 -1
  107. package/dist/agent/docs-generated.d.ts.map +1 -1
  108. package/dist/agent/docs-generated.js +1299 -2
  109. package/dist/agent/docs-generated.js.map +1 -1
  110. package/dist/agent/ops.d.ts +1 -1
  111. package/dist/agent/ops.d.ts.map +1 -1
  112. package/dist/agent/ops.js +15 -21
  113. package/dist/agent/ops.js.map +1 -1
  114. package/dist/agent/register.d.ts.map +1 -1
  115. package/dist/agent/register.js +12 -1
  116. package/dist/agent/register.js.map +1 -1
  117. package/dist/agent/setup/credentials.d.ts +12 -0
  118. package/dist/agent/setup/credentials.d.ts.map +1 -1
  119. package/dist/agent/setup/credentials.js +17 -0
  120. package/dist/agent/setup/credentials.js.map +1 -1
  121. package/dist/agent/setup/page.d.ts.map +1 -1
  122. package/dist/agent/setup/page.js +6 -3
  123. package/dist/agent/setup/page.js.map +1 -1
  124. package/dist/agent/surface-parity.d.ts.map +1 -1
  125. package/dist/agent/surface-parity.js +2 -0
  126. package/dist/agent/surface-parity.js.map +1 -1
  127. package/package.json +31 -3
@@ -42,6 +42,7 @@ import {
42
42
  type EgressRefusalCause,
43
43
  } from "@open-everywhere/core/agent/adapter-api.js";
44
44
  import { slackBotToken } from "../setup/credentials.js";
45
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
45
46
 
46
47
  // THE CREDENTIAL'S IDENTITY, FOR THE AUDIT ROW (OPE-1547) — the ref NAME, never the value. This is
47
48
  // the one host-side seam that attaches a credential, so before this ticket it was the one place
@@ -140,7 +141,7 @@ export async function fetchSlackAttachment(ctx: BindableContext, ref: TrustedSla
140
141
  // referenced here; the row carries the ref name only, which is the identity an operator looks up.
141
142
  // `route` is the dispatch route — it was called `via` until OPE-1547, the same field name the
142
143
  // SANDBOX plane used for the stamping connection.
143
- const row = (outcome: "allowed" | "failed", extra: Record<string, unknown> = {}): Record<string, unknown> => ({
144
+ const row = (outcome: "allowed" | "failed", extra: AuditDetail = {}): AuditDetail => ({
144
145
  ...hostCallAuditFields({
145
146
  host: safe.url.hostname,
146
147
  verb: ATTACHMENT_VERB,
@@ -150,7 +150,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
150
150
  description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
151
151
  // Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
152
152
  // scripts/slack-app-manifest-test.ts fails if one is missing here.
153
- usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | watch [more | less | none] | help",
153
+ usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | watch [more | less | none] | cover [on | off] | suggest [on | off] | help",
154
154
  should_escape: false,
155
155
  },
156
156
  ],
@@ -243,8 +243,21 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
243
243
  // knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
244
244
  // per channel, and memory itself is unaffected.
245
245
  "canvases:write",
246
+ // canvases.sections.lookup — find a memory canvas made before the `memory_canvas` row
247
+ // existed, by the marker line it carries (OPE-2587, core channels/slack-canvas-list.ts
248
+ // `memoryCanvasAmong`). Without it such a canvas held only as a channel tab is not found:
249
+ // its refresh and take-down record `failed` and `clear-failed`, and nothing is overwritten.
250
+ "canvases:read",
251
+ // slackLists.items.list / .create / .update — the agent's own List writes (OPE-2521,
252
+ // core tools/update_list.ts): it reads a List's rows to match one by name, then adds or
253
+ // changes rows on a person's approval. `canvases:write` above also covers the agent's own
254
+ // canvas writes (canvases.create, canvases.edit; core tools/write_canvas.ts).
255
+ "lists:read",
256
+ "lists:write",
246
257
  // The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
247
- // 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).
248
261
  "assistant:write",
249
262
  // Slack requires this bot scope on an app whose people connect to Slack's MCP server
250
263
  // (OPE-2421). Declared only when the app declares "search Slack as you".
@@ -271,6 +284,8 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
271
284
  // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
272
285
  // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
273
286
  // app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
287
+ // agent_session_stopped → chat:write (Slack's native Stop button on an agent session,
288
+ // OPE-2519; Slack shows the button only to an app subscribed to it)
274
289
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
275
290
  // this list drops one (OPE-2223).
276
291
  bot_events: [
@@ -286,6 +301,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
286
301
  "channel_shared",
287
302
  "user_change",
288
303
  "app_context_changed",
304
+ "agent_session_stopped",
289
305
  ],
290
306
  },
291
307
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -0,0 +1,338 @@
1
+ // THE SLACK AGENT SESSION (OPE-2519) — Slack's native Stop, the session title, and the "needs you" state.
2
+ //
3
+ // Slack's agent experience shows each conversation with the app as an AGENT SESSION, keyed by the
4
+ // channel and the thread root (docs.slack.dev/ai/agent-sessions, re-read 2026-10-14). A session has a
5
+ // lifecycle status (`processing`, `active`, `suspended`, `closed`) and a title. This file owns the three
6
+ // things OE does with it:
7
+ //
8
+ // • STOP. While a session is `processing`, Slack shows a Stop button, but only to an app that
9
+ // subscribes to `agent_session_stopped`. A press arrives as that event. It takes the SAME cancel
10
+ // path as the ✋ reaction and `/oe stop` (`cancelAnchoredTurn`): no session is minted, no model is
11
+ // called, nothing is spent. Slack does not move the status itself, so the route sets `active`.
12
+ // • TITLE. A top-level message the person sends in their DM with the app opens a new conversation,
13
+ // and gets a short title from the words the person TYPED (`authoredText`). The lines of a
14
+ // forwarded or quoted message are another conversation's words, so they never title it. The host
15
+ // derives the title in code: no model writes it. Slack applies a `title` only when the call
16
+ // CREATES the session, so a title the person typed over ours is never overwritten. A thread reply,
17
+ // a shortcut and a group DM get no title.
18
+ // • STATE. Core's turn-anchor hook sets the session `processing` when a turn starts, and at the park
19
+ // `suspended` when the turn reported through `update_status` that it waits on a person
20
+ // (`needs_input` or `blocked`), else `active` (channels/surface-session-state.ts). This file
21
+ // registers the Slack setter: it names the session from the delivery's auth attributes. The
22
+ // person's next message sets `processing`, then parks `active` unless it reports again that it
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`.
26
+ // • A FORWARDED JOB's WAIT (OPE-2536). A job the DM seat forwards to the work session can end waiting
27
+ // on the person: it asked a question in the room (`ask_room`), or it needs their own sign-in. The
28
+ // work session has no `update_status`, so the host sets the session `suspended` at the job's
29
+ // settlement (core channels/work-surface-wait.ts). This file registers the PIN for that: the
30
+ // session's id and the three attributes that name it again, kept on the job.
31
+ //
32
+ // WHICH SESSIONS. Only the person's 1:1 DM with the app (the Messages tab and the side panel), where
33
+ // Slack lists the person's agent sessions. The DM door stamps the session's thread root on the wake's
34
+ // auth (`agent_session_ts`): a top-level message opens its own session, keyed by its own `ts`, and a
35
+ // thread reply belongs to its thread's session. A channel thread gets no state from OE.
36
+ //
37
+ // Every call here is best-effort. A workspace without Slack's agent features answers an error, which
38
+ // is logged and changes nothing else.
39
+
40
+ import {
41
+ cancelAnchoredTurn,
42
+ resolvePartition,
43
+ STATUS_REACTION,
44
+ swallowRoomOp,
45
+ turnAnchorKey,
46
+ turnChannelKey,
47
+ type AttachSessionForCancel,
48
+ type CancelResult,
49
+ type SurfaceSessionPinner,
50
+ type SurfaceSessionSetter,
51
+ } from "@open-everywhere/core/agent/adapter-api.js";
52
+ import { bindableForSlackIds, slackForm, type SlackEventEnvelope } from "./slack-events.js";
53
+
54
+ /** Slack's event for a press of the session's Stop button. */
55
+ export const AGENT_SESSION_STOPPED_EVENT = "agent_session_stopped";
56
+
57
+ /** The longest title OE sets. Slack accepts 200 characters; a session list shows far fewer. */
58
+ export const SLACK_SESSION_TITLE_MAX = 60;
59
+
60
+ /** The session states OE sets. */
61
+ export type SlackSessionStatus = "processing" | "active" | "suspended";
62
+
63
+ /** The auth attribute the DM door stamps with the session's thread root (slack-front.ts `scheduleDmWake`). */
64
+ const AGENT_SESSION_TS_ATTRIBUTE = "agent_session_ts";
65
+
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
+ /** The form-call seam: `slackForm` in production, a recorder in the tests. */
74
+ export type SlackFormCall = (method: string, body: string) => Promise<Record<string, unknown> | undefined>;
75
+
76
+ // --- the title ------------------------------------------------------------------------------------
77
+
78
+ /** Slack's three escaped characters, decoded. */
79
+ function decodeSlackEntities(text: string): string {
80
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
81
+ }
82
+
83
+ /**
84
+ * The session title for a conversation that opens with `text`, or undefined when nothing readable is
85
+ * left. Pure. It keeps the first non-empty line, renders a link as its label, a channel as `#name`, and
86
+ * drops a member or group mention (no raw id reaches the title). It strips `*`, `~` and backticks, and
87
+ * keeps `_`, so an identifier such as `oe_config` stays intact. A long line is cut at a word and ends
88
+ * with `…`.
89
+ */
90
+ export function slackSessionTitle(text: string | undefined): string | undefined {
91
+ if (typeof text !== "string") return undefined;
92
+ const rendered = text
93
+ // A member mention, with or without a label: dropped.
94
+ .replace(/<@[A-Z0-9]+(?:\|[^>]*)?>/g, " ")
95
+ // A channel mention: its name when Slack sent one, else dropped.
96
+ .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1")
97
+ .replace(/<#[A-Z0-9]+>/g, " ")
98
+ // A special or group mention (`<!here>`, `<!subteam^S1|@team>`): dropped.
99
+ .replace(/<![^>]*>/g, " ")
100
+ // A link: its label, else the address.
101
+ .replace(/<[^>|]+\|([^>]+)>/g, "$1")
102
+ .replace(/<([^>]+)>/g, "$1");
103
+ const line = decodeSlackEntities(rendered)
104
+ .split(/\r?\n/)
105
+ .map((l) => l.replace(/[*~`]+/g, "").replace(/\s+/g, " ").trim())
106
+ .find((l) => l.length > 0);
107
+ if (line === undefined) return undefined;
108
+ if (line.length <= SLACK_SESSION_TITLE_MAX) return line;
109
+ const head = line.slice(0, SLACK_SESSION_TITLE_MAX - 1);
110
+ const cut = head.lastIndexOf(" ");
111
+ return `${(cut >= SLACK_SESSION_TITLE_MAX / 2 ? head.slice(0, cut) : head).trimEnd()}…`;
112
+ }
113
+
114
+ // --- the status call -------------------------------------------------------------------------------
115
+
116
+ /** One `agents.sessions.setStatus` call. */
117
+ export interface SlackSessionStatusCall {
118
+ readonly channelId: string;
119
+ /** The session's thread root. */
120
+ readonly threadTs: string;
121
+ readonly status: SlackSessionStatus;
122
+ /** Applied only when this call creates the session. */
123
+ readonly title?: string;
124
+ }
125
+
126
+ /** The form body of {@link SlackSessionStatusCall}. Pure. */
127
+ export function slackSessionStatusForm(call: SlackSessionStatusCall): string {
128
+ const body = new URLSearchParams({ channel_id: call.channelId, thread_ts: call.threadTs, status: call.status });
129
+ if (call.title !== undefined && call.title.length > 0) body.set("title", call.title);
130
+ return body.toString();
131
+ }
132
+
133
+ /** Set a session's status (and, on creation, its title). True when Slack answered `ok`. Never throws. */
134
+ export async function setSlackSessionStatus(call: SlackSessionStatusCall, form: SlackFormCall = slackForm): Promise<boolean> {
135
+ try {
136
+ const res = await form("agents.sessions.setStatus", slackSessionStatusForm(call));
137
+ if (res?.ok === true) return true;
138
+ console.warn(`[open-everywhere:slack-session] agents.sessions.setStatus ${call.status} was not applied (${call.channelId}/${call.threadTs}): ${String(res?.error ?? "no answer")}`);
139
+ return false;
140
+ } catch (err) {
141
+ console.warn(`[open-everywhere:slack-session] agents.sessions.setStatus ${call.status} failed (${call.channelId}/${call.threadTs}): ${String(err)}`);
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The session a delivery's auth attributes name, or undefined for none. Only a delivery the DM door
148
+ * stamped (`agent_session_ts`) names one: a shortcut, a group DM and every channel carry no stamp, so
149
+ * they never set a session's state, even on a threaded message. Pure.
150
+ */
151
+ export function slackAgentSessionOf(attributes: Readonly<Record<string, unknown>>): { readonly channelId: string; readonly threadTs: string } | undefined {
152
+ if (attributes.channel_type !== "im") return undefined;
153
+ const channelId = attributes.channel_id;
154
+ const stamped = attributes[AGENT_SESSION_TS_ATTRIBUTE];
155
+ if (typeof channelId !== "string" || channelId.length === 0 || typeof stamped !== "string" || stamped.length === 0) return undefined;
156
+ return { channelId, threadTs: stamped };
157
+ }
158
+
159
+ /**
160
+ * The Slack pinner (OPE-2536): the session a delivery names, as the setter's own id and the three
161
+ * attributes that name it again. A forwarded job keeps this, so its settlement can set the session
162
+ * `suspended` long after the delivery's auth is gone. Ids only, never text. Pure.
163
+ */
164
+ export const slackSurfaceSessionPin: SurfaceSessionPinner = (attributes) => {
165
+ const session = slackAgentSessionOf(attributes);
166
+ if (!session) return undefined;
167
+ return {
168
+ id: `${session.channelId}:${session.threadTs}`,
169
+ attributes: { channel_id: session.channelId, channel_type: "im", [AGENT_SESSION_TS_ATTRIBUTE]: session.threadTs, [SESSION_PIN_ATTRIBUTE]: "1" },
170
+ };
171
+ };
172
+
173
+ // --- the working signs ------------------------------------------------------------------------------
174
+ //
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.
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...";
192
+
193
+ /** The 👀 the bot sets while it works (core status/status.ts `STATUS_REACTION.working`). */
194
+ const WORKING_REACTION = STATUS_REACTION.working;
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 👀 needs the reactions:write bot scope",
204
+ "reactions.remove": "the 👀 needs 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
+ /** 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.`);
231
+ }
232
+
233
+ /** Test-only: forget which sign failures were already logged. */
234
+ export function _resetSignWarningsForTest(): void {
235
+ warnedSignErrors.clear();
236
+ }
237
+
238
+ /**
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.
242
+ */
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)]);
248
+ }
249
+
250
+ /** The Slack setter core's turn-anchor hook calls (registered in register.ts). */
251
+ export function slackSurfaceSessionSetter(form: SlackFormCall = slackForm): SurfaceSessionSetter {
252
+ return async (attributes, status) => {
253
+ 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}`;
258
+ };
259
+ }
260
+
261
+ // --- the Stop button -------------------------------------------------------------------------------
262
+
263
+ /** One press of the Stop button, as the route reads it. */
264
+ export interface ParsedSessionStop {
265
+ readonly channelId: string;
266
+ /** The session's thread root. */
267
+ readonly threadTs: string;
268
+ /** Who pressed Stop. */
269
+ readonly userId: string;
270
+ /** From the `event_callback` envelope: Slack does not put it in the event. */
271
+ readonly teamId?: string;
272
+ readonly eventTs?: string;
273
+ }
274
+
275
+ /** Parse an `agent_session_stopped` envelope, or null for anything else. Pure. */
276
+ export function parseAgentSessionStopped(envelope: SlackEventEnvelope): ParsedSessionStop | null {
277
+ if (envelope?.type !== "event_callback") return null;
278
+ const e = envelope.event as Record<string, unknown> | undefined;
279
+ if (!e || e.type !== AGENT_SESSION_STOPPED_EVENT) return null;
280
+ const channelId = typeof e.channel === "string" ? e.channel : "";
281
+ const threadTs = typeof e.thread_ts === "string" ? e.thread_ts : "";
282
+ const userId = typeof e.user === "string" ? e.user : "";
283
+ if (!channelId || !threadTs || !userId) return null;
284
+ const teamId = typeof envelope.team_id === "string" && envelope.team_id.length > 0 ? envelope.team_id : undefined;
285
+ return {
286
+ channelId,
287
+ threadTs,
288
+ userId,
289
+ ...(teamId ? { teamId } : {}),
290
+ ...(typeof e.event_ts === "string" ? { eventTs: e.event_ts } : {}),
291
+ };
292
+ }
293
+
294
+ /** A 1:1 DM channel id. Only there does the stop fall back to the channel's newest turn. */
295
+ function isDmChannel(channelId: string): boolean {
296
+ return channelId.startsWith("D");
297
+ }
298
+
299
+ /** What the Stop route needs. */
300
+ export interface SessionStopDeps {
301
+ /** eve's I/O-free cancel door, the same one the ✋ arm uses. */
302
+ readonly attachSession: AttachSessionForCancel;
303
+ /** The cancel path. Absent means the live `cancelAnchoredTurn`. */
304
+ readonly cancel?: typeof cancelAnchoredTurn;
305
+ /** The status call. Absent means the live {@link setSlackSessionStatus}. */
306
+ readonly setStatus?: (call: SlackSessionStatusCall) => Promise<boolean>;
307
+ }
308
+
309
+ /**
310
+ * Stop the turn a Stop press names, then set the session `active`.
311
+ *
312
+ * The turn is found by the session's thread first (the key the ✋ arm uses for a thread root). In the
313
+ * person's 1:1 DM it falls back to the DM's newest turn, because a top-level DM message keys its turn by
314
+ * the channel alone. In a shared channel it does not fall back: there the newest turn can belong to
315
+ * another thread, and cancelling nothing beats cancelling a guess. The status is set whatever the
316
+ * cancel answers, because a session left `processing` keeps its spinner for an hour. Never throws.
317
+ */
318
+ export async function routeAgentSessionStop(ev: ParsedSessionStop, deps: SessionStopDeps): Promise<CancelResult> {
319
+ const p = resolvePartition(bindableForSlackIds({ teamId: ev.teamId, channelId: ev.channelId, userId: ev.userId }));
320
+ const anchorKey = turnAnchorKey("slack", ev.channelId, ev.threadTs);
321
+ const channelKey = isDmChannel(ev.channelId) ? turnChannelKey("slack", ev.channelId) : undefined;
322
+ let result: CancelResult;
323
+ try {
324
+ result = await (deps.cancel ?? cancelAnchoredTurn)({
325
+ org: p.org,
326
+ platform: "slack",
327
+ workspace: p.workspace,
328
+ requestedBy: ev.userId,
329
+ via: "stop-button",
330
+ query: { ...(anchorKey ? { anchorKey } : {}), ...(channelKey ? { channelKey } : {}) },
331
+ attachSession: deps.attachSession,
332
+ });
333
+ } catch (err) {
334
+ result = { outcome: "error", message: `stop failed: ${String(err)}` };
335
+ }
336
+ await (deps.setStatus ?? setSlackSessionStatus)({ channelId: ev.channelId, threadTs: ev.threadTs, status: "active" }).catch(() => false);
337
+ return result;
338
+ }
@@ -33,6 +33,7 @@ import {
33
33
  registerAttachmentResolver,
34
34
  AttachmentOversizeError,
35
35
  type SlackAttachmentRow,
36
+ type BindableContext,
36
37
  } from "@open-everywhere/core/agent/adapter-api.js";
37
38
  import { fetchSlackAttachment } from "../access/slack-attachment-fetch.js";
38
39
 
@@ -135,6 +136,24 @@ export async function resolveSlackAttachmentDurable(fileId: string, org?: string
135
136
  }
136
137
  }
137
138
 
139
+ /**
140
+ * The size-guarded byte pull over a TRUSTED `url_private`: the bot-token, host-pinned fetch, then the Content-Length
141
+ * pre-check (rejects with {@link AttachmentOversizeError} phase "declared" BEFORE the body is read), then the post-read
142
+ * cap (phase "actual"). Two callers: the guarded resolver below, and the voice-clip loader (slack-voice-clips.ts),
143
+ * which reads a clip off the event that woke the agent. Both pass an event-supplied URL, never a model string.
144
+ */
145
+ export async function pullSlackAttachmentBytes(ctx: BindableContext, ref: { readonly urlPrivate: string; readonly id?: string }, maxBytes: number): Promise<Uint8Array> {
146
+ const res = await fetchSlackAttachment(ctx, ref);
147
+ if (!res.ok) throw new Error(`fetching the upload failed (HTTP ${res.status})`);
148
+ // Content-Length pre-check off the declared length — reject before reading the body.
149
+ const declared = Number.parseInt(res.headers.get("content-length") ?? "", 10);
150
+ if (Number.isFinite(declared) && declared > maxBytes) throw new AttachmentOversizeError(declared, "declared", maxBytes);
151
+ const bytes = new Uint8Array(await res.arrayBuffer());
152
+ // Post-read cap — a chunked response can exceed the (possibly-absent) Content-Length.
153
+ if (bytes.byteLength > maxBytes) throw new AttachmentOversizeError(bytes.byteLength, "actual", maxBytes);
154
+ return bytes;
155
+ }
156
+
138
157
  /**
139
158
  * Register Slack as an attachment-plane resolver (channels/attachments.ts) — the neutral seam
140
159
  * describe_image consults so its contract carries no per-platform union arm. The resolver wraps the
@@ -169,17 +188,7 @@ export function registerSlackAttachmentResolver(): void {
169
188
  ref: `slack-file:${attachmentId}`,
170
189
  mimeType: rec.mimeType,
171
190
  name: rec.name,
172
- async pull({ maxBytes }) {
173
- const res = await fetchSlackAttachment(ctx, { urlPrivate: rec.urlPrivate, id: attachmentId });
174
- if (!res.ok) throw new Error(`fetching the upload failed (HTTP ${res.status})`);
175
- // Content-Length pre-check off the declared length — reject before reading the body.
176
- const declared = Number.parseInt(res.headers.get("content-length") ?? "", 10);
177
- if (Number.isFinite(declared) && declared > maxBytes) throw new AttachmentOversizeError(declared, "declared", maxBytes);
178
- const bytes = new Uint8Array(await res.arrayBuffer());
179
- // Post-read cap — a chunked response can exceed the (possibly-absent) Content-Length.
180
- if (bytes.byteLength > maxBytes) throw new AttachmentOversizeError(bytes.byteLength, "actual", maxBytes);
181
- return bytes;
182
- },
191
+ pull: ({ maxBytes }) => pullSlackAttachmentBytes(ctx, { urlPrivate: rec.urlPrivate, id: attachmentId }, maxBytes),
183
192
  };
184
193
  },
185
194
  });