@open-everywhere/provider-slack 0.7.0 → 0.8.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 (127) hide show
  1. package/agent/access/slack-attachment-fetch.ts +2 -1
  2. package/agent/app-manifest.ts +15 -1
  3. package/agent/channels/slack-agent-session.ts +249 -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 +103 -29
  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 +9 -1
  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 +1282 -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 +15 -1
  32. package/dist/agent/app-manifest.js.map +1 -1
  33. package/dist/agent/channels/slack-agent-session.d.ts +81 -0
  34. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-agent-session.js +198 -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 +46 -97
  50. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  51. package/dist/agent/channels/slack-events.js +86 -21
  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 +7 -21
  65. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  66. package/dist/agent/channels/slack-identity.js +3 -1
  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 +1282 -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,6 +243,17 @@ 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
258
  // subscription makes Slack attach the viewed room to each side-panel `message.im`.
248
259
  "assistant:write",
@@ -271,6 +282,8 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
271
282
  // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
272
283
  // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
273
284
  // app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
285
+ // agent_session_stopped → chat:write (Slack's native Stop button on an agent session,
286
+ // OPE-2519; Slack shows the button only to an app subscribed to it)
274
287
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
275
288
  // this list drops one (OPE-2223).
276
289
  bot_events: [
@@ -286,6 +299,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
286
299
  "channel_shared",
287
300
  "user_change",
288
301
  "app_context_changed",
302
+ "agent_session_stopped",
289
303
  ],
290
304
  },
291
305
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -0,0 +1,249 @@
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
+ // • A FORWARDED JOB's WAIT (OPE-2536). A job the DM seat forwards to the work session can end waiting
25
+ // on the person: it asked a question in the room (`ask_room`), or it needs their own sign-in. The
26
+ // work session has no `update_status`, so the host sets the session `suspended` at the job's
27
+ // settlement (core channels/work-surface-wait.ts). This file registers the PIN for that: the
28
+ // session's id and the three attributes that name it again, kept on the job.
29
+ //
30
+ // WHICH SESSIONS. Only the person's 1:1 DM with the app (the Messages tab and the side panel), where
31
+ // Slack lists the person's agent sessions. The DM door stamps the session's thread root on the wake's
32
+ // auth (`agent_session_ts`): a top-level message opens its own session, keyed by its own `ts`, and a
33
+ // thread reply belongs to its thread's session. A channel thread gets no state from OE.
34
+ //
35
+ // Every call here is best-effort. A workspace without Slack's agent features answers an error, which
36
+ // is logged and changes nothing else.
37
+
38
+ import {
39
+ cancelAnchoredTurn,
40
+ resolvePartition,
41
+ turnAnchorKey,
42
+ turnChannelKey,
43
+ type AttachSessionForCancel,
44
+ type CancelResult,
45
+ type SurfaceSessionPinner,
46
+ type SurfaceSessionSetter,
47
+ } from "@open-everywhere/core/agent/adapter-api.js";
48
+ import { bindableForSlackIds, slackForm, type SlackEventEnvelope } from "./slack-events.js";
49
+
50
+ /** Slack's event for a press of the session's Stop button. */
51
+ export const AGENT_SESSION_STOPPED_EVENT = "agent_session_stopped";
52
+
53
+ /** The longest title OE sets. Slack accepts 200 characters; a session list shows far fewer. */
54
+ export const SLACK_SESSION_TITLE_MAX = 60;
55
+
56
+ /** The session states OE sets. */
57
+ export type SlackSessionStatus = "processing" | "active" | "suspended";
58
+
59
+ /** The auth attribute the DM door stamps with the session's thread root (slack-front.ts `scheduleDmWake`). */
60
+ const AGENT_SESSION_TS_ATTRIBUTE = "agent_session_ts";
61
+
62
+ /** The form-call seam: `slackForm` in production, a recorder in the tests. */
63
+ export type SlackFormCall = (method: string, body: string) => Promise<Record<string, unknown> | undefined>;
64
+
65
+ // --- the title ------------------------------------------------------------------------------------
66
+
67
+ /** Slack's three escaped characters, decoded. */
68
+ function decodeSlackEntities(text: string): string {
69
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
70
+ }
71
+
72
+ /**
73
+ * The session title for a conversation that opens with `text`, or undefined when nothing readable is
74
+ * left. Pure. It keeps the first non-empty line, renders a link as its label, a channel as `#name`, and
75
+ * drops a member or group mention (no raw id reaches the title). It strips `*`, `~` and backticks, and
76
+ * keeps `_`, so an identifier such as `oe_config` stays intact. A long line is cut at a word and ends
77
+ * with `…`.
78
+ */
79
+ export function slackSessionTitle(text: string | undefined): string | undefined {
80
+ if (typeof text !== "string") return undefined;
81
+ const rendered = text
82
+ // A member mention, with or without a label: dropped.
83
+ .replace(/<@[A-Z0-9]+(?:\|[^>]*)?>/g, " ")
84
+ // A channel mention: its name when Slack sent one, else dropped.
85
+ .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1")
86
+ .replace(/<#[A-Z0-9]+>/g, " ")
87
+ // A special or group mention (`<!here>`, `<!subteam^S1|@team>`): dropped.
88
+ .replace(/<![^>]*>/g, " ")
89
+ // A link: its label, else the address.
90
+ .replace(/<[^>|]+\|([^>]+)>/g, "$1")
91
+ .replace(/<([^>]+)>/g, "$1");
92
+ const line = decodeSlackEntities(rendered)
93
+ .split(/\r?\n/)
94
+ .map((l) => l.replace(/[*~`]+/g, "").replace(/\s+/g, " ").trim())
95
+ .find((l) => l.length > 0);
96
+ if (line === undefined) return undefined;
97
+ if (line.length <= SLACK_SESSION_TITLE_MAX) return line;
98
+ const head = line.slice(0, SLACK_SESSION_TITLE_MAX - 1);
99
+ const cut = head.lastIndexOf(" ");
100
+ return `${(cut >= SLACK_SESSION_TITLE_MAX / 2 ? head.slice(0, cut) : head).trimEnd()}…`;
101
+ }
102
+
103
+ // --- the status call -------------------------------------------------------------------------------
104
+
105
+ /** One `agents.sessions.setStatus` call. */
106
+ export interface SlackSessionStatusCall {
107
+ readonly channelId: string;
108
+ /** The session's thread root. */
109
+ readonly threadTs: string;
110
+ readonly status: SlackSessionStatus;
111
+ /** Applied only when this call creates the session. */
112
+ readonly title?: string;
113
+ }
114
+
115
+ /** The form body of {@link SlackSessionStatusCall}. Pure. */
116
+ export function slackSessionStatusForm(call: SlackSessionStatusCall): string {
117
+ const body = new URLSearchParams({ channel_id: call.channelId, thread_ts: call.threadTs, status: call.status });
118
+ if (call.title !== undefined && call.title.length > 0) body.set("title", call.title);
119
+ return body.toString();
120
+ }
121
+
122
+ /** Set a session's status (and, on creation, its title). True when Slack answered `ok`. Never throws. */
123
+ export async function setSlackSessionStatus(call: SlackSessionStatusCall, form: SlackFormCall = slackForm): Promise<boolean> {
124
+ try {
125
+ const res = await form("agents.sessions.setStatus", slackSessionStatusForm(call));
126
+ if (res?.ok === true) return true;
127
+ console.warn(`[open-everywhere:slack-session] agents.sessions.setStatus ${call.status} was not applied (${call.channelId}/${call.threadTs}): ${String(res?.error ?? "no answer")}`);
128
+ return false;
129
+ } catch (err) {
130
+ console.warn(`[open-everywhere:slack-session] agents.sessions.setStatus ${call.status} failed (${call.channelId}/${call.threadTs}): ${String(err)}`);
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The session a delivery's auth attributes name, or undefined for none. Only a delivery the DM door
137
+ * stamped (`agent_session_ts`) names one: a shortcut, a group DM and every channel carry no stamp, so
138
+ * they never set a session's state, even on a threaded message. Pure.
139
+ */
140
+ export function slackAgentSessionOf(attributes: Readonly<Record<string, unknown>>): { readonly channelId: string; readonly threadTs: string } | undefined {
141
+ if (attributes.channel_type !== "im") return undefined;
142
+ const channelId = attributes.channel_id;
143
+ const stamped = attributes[AGENT_SESSION_TS_ATTRIBUTE];
144
+ if (typeof channelId !== "string" || channelId.length === 0 || typeof stamped !== "string" || stamped.length === 0) return undefined;
145
+ return { channelId, threadTs: stamped };
146
+ }
147
+
148
+ /**
149
+ * The Slack pinner (OPE-2536): the session a delivery names, as the setter's own id and the three
150
+ * attributes that name it again. A forwarded job keeps this, so its settlement can set the session
151
+ * `suspended` long after the delivery's auth is gone. Ids only, never text. Pure.
152
+ */
153
+ export const slackSurfaceSessionPin: SurfaceSessionPinner = (attributes) => {
154
+ const session = slackAgentSessionOf(attributes);
155
+ if (!session) return undefined;
156
+ return {
157
+ id: `${session.channelId}:${session.threadTs}`,
158
+ attributes: { channel_id: session.channelId, channel_type: "im", [AGENT_SESSION_TS_ATTRIBUTE]: session.threadTs },
159
+ };
160
+ };
161
+
162
+ /** The Slack setter core's turn-anchor hook calls (registered in register.ts). */
163
+ export function slackSurfaceSessionSetter(form: SlackFormCall = slackForm): SurfaceSessionSetter {
164
+ return async (attributes, status) => {
165
+ const session = slackAgentSessionOf(attributes);
166
+ if (!session) return undefined;
167
+ await setSlackSessionStatus({ ...session, status }, form);
168
+ return `${session.channelId}:${session.threadTs}`;
169
+ };
170
+ }
171
+
172
+ // --- the Stop button -------------------------------------------------------------------------------
173
+
174
+ /** One press of the Stop button, as the route reads it. */
175
+ export interface ParsedSessionStop {
176
+ readonly channelId: string;
177
+ /** The session's thread root. */
178
+ readonly threadTs: string;
179
+ /** Who pressed Stop. */
180
+ readonly userId: string;
181
+ /** From the `event_callback` envelope: Slack does not put it in the event. */
182
+ readonly teamId?: string;
183
+ readonly eventTs?: string;
184
+ }
185
+
186
+ /** Parse an `agent_session_stopped` envelope, or null for anything else. Pure. */
187
+ export function parseAgentSessionStopped(envelope: SlackEventEnvelope): ParsedSessionStop | null {
188
+ if (envelope?.type !== "event_callback") return null;
189
+ const e = envelope.event as Record<string, unknown> | undefined;
190
+ if (!e || e.type !== AGENT_SESSION_STOPPED_EVENT) return null;
191
+ const channelId = typeof e.channel === "string" ? e.channel : "";
192
+ const threadTs = typeof e.thread_ts === "string" ? e.thread_ts : "";
193
+ const userId = typeof e.user === "string" ? e.user : "";
194
+ if (!channelId || !threadTs || !userId) return null;
195
+ const teamId = typeof envelope.team_id === "string" && envelope.team_id.length > 0 ? envelope.team_id : undefined;
196
+ return {
197
+ channelId,
198
+ threadTs,
199
+ userId,
200
+ ...(teamId ? { teamId } : {}),
201
+ ...(typeof e.event_ts === "string" ? { eventTs: e.event_ts } : {}),
202
+ };
203
+ }
204
+
205
+ /** A 1:1 DM channel id. Only there does the stop fall back to the channel's newest turn. */
206
+ function isDmChannel(channelId: string): boolean {
207
+ return channelId.startsWith("D");
208
+ }
209
+
210
+ /** What the Stop route needs. */
211
+ export interface SessionStopDeps {
212
+ /** eve's I/O-free cancel door, the same one the ✋ arm uses. */
213
+ readonly attachSession: AttachSessionForCancel;
214
+ /** The cancel path. Absent means the live `cancelAnchoredTurn`. */
215
+ readonly cancel?: typeof cancelAnchoredTurn;
216
+ /** The status call. Absent means the live {@link setSlackSessionStatus}. */
217
+ readonly setStatus?: (call: SlackSessionStatusCall) => Promise<boolean>;
218
+ }
219
+
220
+ /**
221
+ * Stop the turn a Stop press names, then set the session `active`.
222
+ *
223
+ * The turn is found by the session's thread first (the key the ✋ arm uses for a thread root). In the
224
+ * person's 1:1 DM it falls back to the DM's newest turn, because a top-level DM message keys its turn by
225
+ * the channel alone. In a shared channel it does not fall back: there the newest turn can belong to
226
+ * another thread, and cancelling nothing beats cancelling a guess. The status is set whatever the
227
+ * cancel answers, because a session left `processing` keeps its spinner for an hour. Never throws.
228
+ */
229
+ export async function routeAgentSessionStop(ev: ParsedSessionStop, deps: SessionStopDeps): Promise<CancelResult> {
230
+ const p = resolvePartition(bindableForSlackIds({ teamId: ev.teamId, channelId: ev.channelId, userId: ev.userId }));
231
+ const anchorKey = turnAnchorKey("slack", ev.channelId, ev.threadTs);
232
+ const channelKey = isDmChannel(ev.channelId) ? turnChannelKey("slack", ev.channelId) : undefined;
233
+ let result: CancelResult;
234
+ try {
235
+ result = await (deps.cancel ?? cancelAnchoredTurn)({
236
+ org: p.org,
237
+ platform: "slack",
238
+ workspace: p.workspace,
239
+ requestedBy: ev.userId,
240
+ via: "stop-button",
241
+ query: { ...(anchorKey ? { anchorKey } : {}), ...(channelKey ? { channelKey } : {}) },
242
+ attachSession: deps.attachSession,
243
+ });
244
+ } catch (err) {
245
+ result = { outcome: "error", message: `stop failed: ${String(err)}` };
246
+ }
247
+ await (deps.setStatus ?? setSlackSessionStatus)({ channelId: ev.channelId, threadTs: ev.threadTs, status: "active" }).catch(() => false);
248
+ return result;
249
+ }
@@ -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
  });
@@ -42,13 +42,17 @@ import {
42
42
  oeSlash,
43
43
  parseSlackCommandText,
44
44
  resolvePartition,
45
+ auditSurfaceModeFrom,
45
46
  getOperationalStore,
46
47
  admittedTenantsFromConfig,
47
48
  unadmittedTenantLog,
48
49
  UNADMITTED_TENANT_DROP_REASON,
49
50
  type BindableContext,
51
+ type SurfaceBinding,
50
52
  } from "@open-everywhere/core/agent/adapter-api.js";
51
53
  import { slackTenantAdmission } from "./slack-tenant-fence.js";
54
+ import { slackAuthorIsMember, slackChannelFactsFor } from "./slack-events.js";
55
+ import { slackMode } from "./slack-identity.js";
52
56
 
53
57
  function log(msg: string): void {
54
58
  console.log(`[open-everywhere:slack-commands] ${msg}`);
@@ -64,6 +68,21 @@ function formField(params: URLSearchParams, name: string): string | undefined {
64
68
  return v && v.length > 0 ? v : undefined;
65
69
  }
66
70
 
71
+ /** What the route learned about a command's trust before it binds. `dmPartnerTrust` is the invoker's own
72
+ * verdict in a 1:1 DM. `room` is a channel's `conversations.info` verdict, present whenever Slack answered
73
+ * it, whoever typed the command. `invokerMember` is present only when `users.info` CONFIRMED the invoker a
74
+ * member (see {@link slashRoomVerdict}). The person's binding stamps `room` only beside `invokerMember`;
75
+ * the room's binding stamps it alone (OPE-2588). */
76
+ export interface SlashTrustFacts {
77
+ readonly dmPartnerTrust?: "internal" | "external";
78
+ readonly room?: { readonly extShared: boolean };
79
+ readonly invokerMember?: true;
80
+ }
81
+
82
+ /** Which verdict a slash binding resolves: the PERSON's (the room's verdict folded with the invoker
83
+ * check, which every member gate reads) or the ROOM's alone (which the room's budget reads). */
84
+ export type SlashTrustLeg = "person" | "room";
85
+
67
86
  /** Build the BindableContext the identity seam (bindFrom/bindSlack) expects from Slack's
68
87
  * slash-command form fields. Slack's slash-command payload carries no `channel_type` /
69
88
  * `is_private` bit (unlike an app_mention/message event) — `bindSlack`'s existing fallbacks
@@ -76,16 +95,36 @@ function formField(params: URLSearchParams, name: string): string | undefined {
76
95
  * reads — so a Grid declared once now answers here the same way it answers at every other door,
77
96
  * instead of resolving as though the line were absent.
78
97
  *
79
- * 🔴 STATE ITS REACH EXACTLY, because this door is narrower than the wake doors. For a CHANNEL
80
- * command it does NOT change `binding.trust`: this payload carries no `conversations.info` verdict,
81
- * so `slackTrust(undefined, undefined, "channel")` fails closed to `external`, and `bindFrom` takes
82
- * that raw verdict ahead of any vouch. So a channel `/oe` stays `trust: "external"` with the rung
83
- * and without it, and the budget scope `domainNameForScope` picks is unchanged. The rung reaches
84
- * `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), where the mapper leaves trust
85
- * undefined and the vouch is what answers. Spread conditionally, like every other door: a
86
- * single-workspace install stamps nothing. */
87
- export function bindableForSlashCommand(params: URLSearchParams): BindableContext {
98
+ * 🔴 STATE ITS REACH EXACTLY. For a CHANNEL command the rung does NOT change `binding.trust`: the
99
+ * room's own verdict answers there (see below), and `bindFrom` takes that raw verdict ahead of any
100
+ * vouch. The rung reaches `binding.trust` only on a DM-shaped `/oe` (a `D…` channel id), and only
101
+ * when the invoker probe gave no verdict: the mapper then leaves trust undefined and the vouch
102
+ * answers (see below). Spread conditionally, like every other door: a single-workspace install
103
+ * stamps nothing.
104
+ *
105
+ * 🔴 A CHANNEL COMMAND CARRIES THE ROOM'S OWN VERDICT (OPE-2579). Slack's slash payload carries no
106
+ * sharing bit, so before this every channel `/oe` bound `slackTrust(undefined, undefined,
107
+ * "channel")`, which fails closed to `external`. A member in an internal channel was then refused
108
+ * `/oe suggest on` as an outsider. The route now passes {@link slashRoomVerdict}'s answer as
109
+ * `is_ext_shared` + `trust_checked`, the two attributes a channel message stamps from the same
110
+ * TTL-cached `conversations.info`. So a channel command reads the room's `internal` or `external` as a
111
+ * message in that room does. On the `person` leg (the default) the verdict is stamped only for an
112
+ * invoker `users.info` confirms a member. On the `room` leg it is stamped for anybody, because that
113
+ * leg answers the room's budget, not a member gate (OPE-2588, {@link bindSlashCommand}). No verdict
114
+ * stamps nothing, and the command stays fail-closed `external`.
115
+ *
116
+ * 🔴 A DM COMMAND CARRIES THE INVOKER'S OWN VERDICT (OPE-2579). In a 1:1 DM the invoker IS the
117
+ * partner, and the route passes {@link slashInvokerDmTrust}'s answer as `dm_partner_trust`, the
118
+ * attribute the chat DM leg stamps from the same `users.info` probe. Before this, a workspace vouch
119
+ * alone answered a DM command, so a guest or a Connect stranger in their DM with the app read
120
+ * `internal` here while the same person read `external` in a DM turn. A verdict outranks the vouch
121
+ * in both directions, exactly as in a DM turn: a guest reads `external` under a workspace vouch, and
122
+ * a confirmed member reads `internal` with no declaration at all. No verdict leaves the attribute
123
+ * unset and the binding resolves as before. This reaches every `/oe` command typed in a DM, not
124
+ * only `watch`. */
125
+ export function bindableForSlashCommand(params: URLSearchParams, invoker?: SlashTrustFacts, leg: SlashTrustLeg = "person"): BindableContext {
88
126
  const enterpriseId = formField(params, "enterprise_id");
127
+ const room = invoker?.room && (leg === "room" || invoker.invokerMember === true) ? invoker.room : undefined;
89
128
  return {
90
129
  channel: { kind: "slack" },
91
130
  session: {
@@ -95,6 +134,8 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
95
134
  team_id: formField(params, "team_id") ?? "",
96
135
  channel_id: formField(params, "channel_id") ?? "",
97
136
  ...(enterpriseId ? { enterprise_id: enterpriseId } : {}),
137
+ ...(invoker?.dmPartnerTrust ? { dm_partner_trust: invoker.dmPartnerTrust } : {}),
138
+ ...(room ? { is_ext_shared: String(room.extShared), trust_checked: "true" } : {}),
98
139
  },
99
140
  principalId: formField(params, "user_id") ?? "",
100
141
  principalType: "slack-user",
@@ -104,6 +145,120 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
104
145
  };
105
146
  }
106
147
 
148
+ /** How long each trust read (the invoker probe, the room read) may take before the route answers without
149
+ * it. Slack wants its ack inside three seconds, and `slackForm`'s 429 backoff can wait longer than that. */
150
+ const INVOKER_PROBE_MS = 1_500;
151
+
152
+ /**
153
+ * The invoker's own membership verdict for a 1:1 DM command, in `dm_partner_trust`'s vocabulary, or
154
+ * `{}` when there is none to give (OPE-2579). It is the chat DM leg's reading of the same probe
155
+ * (slack-front.ts `liveDmPartnerTrust`): `slackAuthorIsMember` decides, so the two doors cannot
156
+ * drift on what a guest is. A channel or group DM asks nothing here: there the ROOM's verdict is the
157
+ * binding's trust, and {@link slashRoomVerdict} reads it.
158
+ *
159
+ * 🔴 NO VERDICT IS NOT A VERDICT. No user id, no bot token, a refused `users.info`, a transport
160
+ * failure or a probe slower than {@link INVOKER_PROBE_MS} all answer `{}`, which leaves the binding
161
+ * exactly as it resolved before this probe existed. An unanswered probe never invents a guest, and
162
+ * never invents a member.
163
+ */
164
+ export async function slashInvokerDmTrust(
165
+ params: URLSearchParams,
166
+ probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
167
+ ): Promise<{ readonly dmPartnerTrust?: "internal" | "external" }> {
168
+ const userId = formField(params, "user_id");
169
+ if (!userId || slackMode(undefined, formField(params, "channel_id")) !== "dm") return {};
170
+ const member = await withinAckBudget(probe(userId, formField(params, "team_id")));
171
+ return member === undefined ? {} : { dmPartnerTrust: member ? "internal" : "external" };
172
+ }
173
+
174
+ /** The room facts {@link slashRoomVerdict} reads: `slackChannelFactsFor`'s shape, which every channel wake reads. */
175
+ export type SlashRoomFacts = (channelId: string) => Promise<{ readonly channelType?: string; readonly extShared?: boolean; readonly trustChecked?: boolean }>;
176
+
177
+ /**
178
+ * A CHANNEL command's room verdict and invoker check, or `{}` when there is no room verdict to give
179
+ * (OPE-2579). The room read is `slackChannelFactsFor`, the TTL-cached `conversations.info` every
180
+ * channel wake reads, so the command door and the message door cannot disagree about one room.
181
+ *
182
+ * 🔴 THE INVOKER LEG, AND IT FAILS CLOSED. A channel message keeps the room's verdict on the auth and marks
183
+ * a guest author on the side (slack-events.ts `slackWakeAuthorFacts`), because one guest's message must not
184
+ * demote the channel's memory for everybody. A command has no session to protect: its binding exists for
185
+ * one answer, and `/oe suggest on` and `/oe watch more|less` read `binding.trust` as "is this person a
186
+ * member". So the route folds the author leg into the binding's `trust`, the composition Discord and
187
+ * Teams make: `invokerMember` is set only when `users.info` CONFIRMS the invoker a member, and the
188
+ * person's binding stamps the room's verdict only beside it. A guest, a Slack Connect stranger, and a
189
+ * person the probe could not check (no `users:read`, a refusal, a timeout) all leave `trust` fail-closed
190
+ * `external`. The message door fails OPEN on an unanswered probe because it marks a turn. Here `trust`
191
+ * IS the member gate, and a channel has no declaration to fall back to the way a DM command does.
192
+ *
193
+ * 🔴 THE ROOM'S VERDICT IS RETURNED FOR EVERY INVOKER (OPE-2588). `/oe budget` names the room's budget,
194
+ * and a channel's turns are charged under the room's domain, whoever typed them. So {@link bindSlashCommand}
195
+ * binds the room's verdict alone into `roomTrust`, and the budget reads that. A guest's `trust` is
196
+ * unchanged, and `/oe budget` reads both to refuse a guest in an internal channel (commands/core.ts
197
+ * `budgetWithheldFrom`). `/oe cover on` refuses outside a DM before it reads trust.
198
+ *
199
+ * 🔴 ONLY A ROOM IN THE CHANNEL MODE IS STAMPED. A 1:1 DM has its own leg ({@link slashInvokerDmTrust}).
200
+ * A group DM, or a room whose type Slack did not report, stamps nothing, so its binding is unchanged.
201
+ * Stamping a sharing verdict on a group DM the binding still calls a channel would mix two modes' rules.
202
+ *
203
+ * 🔴 NO VERDICT IS NOT A VERDICT. A failed or refused `conversations.info`, a response without the check,
204
+ * or a read slower than {@link INVOKER_PROBE_MS} answers `{}`. The command then binds `external`, as
205
+ * before this read existed. The room read and the invoker probe run in parallel inside one ack budget.
206
+ */
207
+ export async function slashRoomVerdict(
208
+ params: URLSearchParams,
209
+ roomFacts: SlashRoomFacts = slackChannelFactsFor,
210
+ probe: (userId: string, teamId: string | undefined) => Promise<boolean | undefined> = slackAuthorIsMember,
211
+ ): Promise<SlashTrustFacts> {
212
+ const channelId = formField(params, "channel_id");
213
+ if (!channelId || slackMode(undefined, channelId) === "dm") return {};
214
+ const userId = formField(params, "user_id");
215
+ const [facts, member] = await Promise.all([
216
+ withinAckBudget(roomFacts(channelId)),
217
+ userId ? withinAckBudget(probe(userId, formField(params, "team_id"))) : Promise.resolve(undefined),
218
+ ]);
219
+ if (!facts || facts.trustChecked !== true || typeof facts.extShared !== "boolean") return {};
220
+ if (facts.channelType !== "channel" && facts.channelType !== "group") return {};
221
+ return { room: { extShared: facts.extShared }, ...(member === true ? { invokerMember: true as const } : {}) };
222
+ }
223
+
224
+ /** Both legs, for whichever kind of room the command came from. Never throws. */
225
+ export async function slashTrustFacts(params: URLSearchParams): Promise<SlashTrustFacts> {
226
+ return slackMode(undefined, formField(params, "channel_id")) === "dm" ? slashInvokerDmTrust(params) : slashRoomVerdict(params);
227
+ }
228
+
229
+ /**
230
+ * The slash command's binding, with the room's trust and the person's trust kept apart (OPE-2588).
231
+ *
232
+ * `trust` is the PERSON leg: the member gate `/oe suggest on`, `/oe watch more|less` and `/oe cover on`
233
+ * read. `roomTrust` is the ROOM leg: the same `bindFrom` walk over the room's verdict alone, so the
234
+ * declarations and the fail-closed default apply to it unchanged. `/oe budget` and the caps behind
235
+ * `raise` and `clear` read it through `roomTrustOf`. Before this the binding carried one value, so a
236
+ * guest in an internal channel read the external domain's budget while that channel's turns were
237
+ * charged under the internal domain. Now `/oe budget` refuses that guest, because a guest must not read
238
+ * an internal room's caps and spend (Tommy's ruling on #1431).
239
+ *
240
+ * In a DM both legs bind the same context, so the two values are equal. Every other field comes from
241
+ * the person's binding. The two bindings differ only in the two trust attributes, which move no key.
242
+ */
243
+ export function bindSlashCommand(params: URLSearchParams, facts: SlashTrustFacts): SurfaceBinding {
244
+ const person = bindFrom(bindableForSlashCommand(params, facts, "person"));
245
+ const room = bindFrom(bindableForSlashCommand(params, facts, "room"));
246
+ return { ...person, roomTrust: room.trust };
247
+ }
248
+
249
+ /** `p`'s answer, or `undefined` when it throws or takes longer than {@link INVOKER_PROBE_MS}. */
250
+ async function withinAckBudget<T>(p: Promise<T>): Promise<T | undefined> {
251
+ let timer: ReturnType<typeof setTimeout> | undefined;
252
+ const timeout = new Promise<undefined>((resolve) => {
253
+ timer = setTimeout(() => resolve(undefined), INVOKER_PROBE_MS);
254
+ });
255
+ try {
256
+ return await Promise.race([p.catch(() => undefined), timeout]);
257
+ } finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+
107
262
  /**
108
263
  * Make a refused slash command OBSERVABLE, the same way `slack.ts`'s `auditWakeDropped` makes a
109
264
  * refused wake observable: the webhook function's console output never reaches `vercel logs`, so
@@ -123,16 +278,18 @@ export function bindableForSlashCommand(params: URLSearchParams): BindableContex
123
278
  */
124
279
  async function auditCommandDropped(teamId: string | undefined, channelId: string | undefined, reason: string, enterpriseId?: string): Promise<void> {
125
280
  try {
126
- const p = resolvePartition({
281
+ const bindable = {
127
282
  channel: { kind: "slack" },
128
283
  session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
129
- } as BindableContext);
284
+ } as BindableContext;
285
+ const p = resolvePartition(bindable);
130
286
  await getOperationalStore().appendAudit({
131
287
  ts: new Date().toISOString(),
132
288
  org: p.org,
133
289
  platform: p.platform,
134
290
  workspace: p.workspace,
135
291
  surface: p.surface,
292
+ ...auditSurfaceModeFrom(bindable),
136
293
  action: "surface.wake_dropped",
137
294
  detail: { reason, door: "slash-command", channel: channelId ?? null, team: teamId ?? null, enterprise: enterpriseId ?? null },
138
295
  });
@@ -142,6 +299,9 @@ async function auditCommandDropped(teamId: string | undefined, channelId: string
142
299
  }
143
300
 
144
301
  function ephemeralResponse(text: string): Response {
302
+ // OPE-2490: an empty reply means the command's card posted in this channel and IS the answer. An
303
+ // empty 200 acknowledges the command, and Slack then shows nothing beside the card.
304
+ if (text.length === 0) return new Response(null, { status: 200 });
145
305
  return new Response(JSON.stringify({ response_type: "ephemeral", text }), {
146
306
  status: 200,
147
307
  headers: { "content-type": "application/json" },
@@ -219,7 +379,7 @@ export default defineChannel({
219
379
  }
220
380
 
221
381
  const cmd = parseSlackCommandText(formField(params, "text"));
222
- const binding = bindFrom(bindableForSlashCommand(params));
382
+ const binding = bindSlashCommand(params, await slashTrustFacts(params));
223
383
  try {
224
384
  const reply = await runOeCommand(cmd, binding, { attachSession });
225
385
  return ephemeralResponse(reply);
@@ -38,11 +38,6 @@ import { loadThreadContextMessages } from "eve/channels/slack";
38
38
  import type { SlackMessage } from "eve/channels/slack";
39
39
  import { MAX_MESSAGES, renderContext, type ContextLine } from "@open-everywhere/core/agent/adapter-api.js";
40
40
 
41
- // Re-export the shared render + line shape so slack-wake-backscroll.ts (which imports them from
42
- // here) keeps compiling unchanged after the extraction, and any future Slack consumer has one
43
- // import site. The mapping (`linesFromMessages`) is defined below — it's Slack-message-shaped.
44
- export { renderContext, type ContextLine };
45
-
46
41
  // When the rendered slice fills MAX_MESSAGES exactly, there's very likely more history above
47
42
  // it (both fetchers below request MAX_MESSAGES-worth from Slack, so filling the cap usually
48
43
  // means older messages were left out) — tell the model this is a bounded slice, not the whole