@open-everywhere/provider-slack 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/agent/app-manifest.ts +3 -1
  2. package/agent/channels/slack-agent-session.ts +131 -5
  3. package/agent/channels/slack-events.ts +59 -5
  4. package/agent/channels/slack-front.ts +15 -8
  5. package/agent/channels/slack-identity.ts +42 -1
  6. package/agent/channels/slack-web-api.ts +3 -3
  7. package/agent/channels/slack-working-signs.ts +71 -0
  8. package/agent/channels/slack.ts +36 -50
  9. package/agent/docs-generated.ts +50 -13
  10. package/agent/ops.ts +4 -0
  11. package/agent/register.ts +4 -2
  12. package/agent/setup/claim.ts +9 -8
  13. package/agent/setup/credentials.ts +82 -79
  14. package/agent/setup/eve-credentials.ts +2 -2
  15. package/agent/setup/page.ts +3 -3
  16. package/agent/status.ts +12 -4
  17. package/dist/agent/app-manifest.d.ts.map +1 -1
  18. package/dist/agent/app-manifest.js +3 -1
  19. package/dist/agent/app-manifest.js.map +1 -1
  20. package/dist/agent/channels/slack-agent-session.d.ts +22 -2
  21. package/dist/agent/channels/slack-agent-session.d.ts.map +1 -1
  22. package/dist/agent/channels/slack-agent-session.js +127 -6
  23. package/dist/agent/channels/slack-agent-session.js.map +1 -1
  24. package/dist/agent/channels/slack-events.d.ts +7 -3
  25. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  26. package/dist/agent/channels/slack-events.js +57 -6
  27. package/dist/agent/channels/slack-events.js.map +1 -1
  28. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  29. package/dist/agent/channels/slack-front.js +16 -9
  30. package/dist/agent/channels/slack-front.js.map +1 -1
  31. package/dist/agent/channels/slack-identity.d.ts +33 -0
  32. package/dist/agent/channels/slack-identity.d.ts.map +1 -1
  33. package/dist/agent/channels/slack-identity.js +35 -1
  34. package/dist/agent/channels/slack-identity.js.map +1 -1
  35. package/dist/agent/channels/slack-web-api.js +3 -3
  36. package/dist/agent/channels/slack-web-api.js.map +1 -1
  37. package/dist/agent/channels/slack-working-signs.d.ts +34 -0
  38. package/dist/agent/channels/slack-working-signs.d.ts.map +1 -0
  39. package/dist/agent/channels/slack-working-signs.js +63 -0
  40. package/dist/agent/channels/slack-working-signs.js.map +1 -0
  41. package/dist/agent/channels/slack.d.ts.map +1 -1
  42. package/dist/agent/channels/slack.js +28 -46
  43. package/dist/agent/channels/slack.js.map +1 -1
  44. package/dist/agent/docs-generated.d.ts.map +1 -1
  45. package/dist/agent/docs-generated.js +50 -13
  46. package/dist/agent/docs-generated.js.map +1 -1
  47. package/dist/agent/ops.d.ts.map +1 -1
  48. package/dist/agent/ops.js +4 -1
  49. package/dist/agent/ops.js.map +1 -1
  50. package/dist/agent/register.d.ts.map +1 -1
  51. package/dist/agent/register.js +4 -2
  52. package/dist/agent/register.js.map +1 -1
  53. package/dist/agent/setup/claim.d.ts.map +1 -1
  54. package/dist/agent/setup/claim.js +9 -8
  55. package/dist/agent/setup/claim.js.map +1 -1
  56. package/dist/agent/setup/credentials.d.ts +21 -20
  57. package/dist/agent/setup/credentials.d.ts.map +1 -1
  58. package/dist/agent/setup/credentials.js +71 -75
  59. package/dist/agent/setup/credentials.js.map +1 -1
  60. package/dist/agent/setup/eve-credentials.js +2 -2
  61. package/dist/agent/setup/eve-credentials.js.map +1 -1
  62. package/dist/agent/setup/page.js +3 -3
  63. package/dist/agent/setup/page.js.map +1 -1
  64. package/dist/agent/status.d.ts +10 -2
  65. package/dist/agent/status.d.ts.map +1 -1
  66. package/dist/agent/status.js +12 -4
  67. package/dist/agent/status.js.map +1 -1
  68. package/package.json +6 -2
@@ -10,11 +10,11 @@
10
10
  // (the defaults derive a thinner, workspace-scoped auth). The typing indicator shows
11
11
  // a code-owned STEP LABEL while a tool runs ("Searching Notion…", OPE-2449, see
12
12
  // slack-step-label.ts), and otherwise a random status VERB ("Tinkering…"),
13
- // configurable per org/domain — see statusVerb() below.
13
+ // configurable per org/domain — see slackStatusVerb() in slack-working-signs.ts.
14
14
  //
15
15
  // Credentials: supplied to eve (OPE-2231), as a VERIFIER and a token FUNCTION rather than two
16
- // strings — agent/setup/eve-credentials.ts. Both resolve env-first and then this deployment's
17
- // encrypted credential store, which is where a `/setup` run puts them. They used to be omitted, which
16
+ // strings — agent/setup/eve-credentials.ts. Both resolve the operator secret first (OPE-2650: the db
17
+ // store, then its env var on-ramp), then the value a `/setup` run stored. They used to be omitted, which
18
18
  // left eve reading `process.env` on a path OE no longer does. Capturing channel_type/is_private is a RAW-fact job; the
19
19
  // security-critical interpretation (fail-closed to private) lives in the binding.
20
20
 
@@ -43,7 +43,6 @@ import {
43
43
  getOperationalStore,
44
44
  postOnce,
45
45
  outboundKey,
46
- statusVerbsFor,
47
46
  getConfig,
48
47
  finalMessageActionForTurn,
49
48
  NO_REPLY_SENTINEL_REASON,
@@ -76,7 +75,6 @@ import {
76
75
  introMessage,
77
76
  instructionBullet,
78
77
  ONBOARDED_MARKER,
79
- STATUS_REACTION,
80
78
  renderPinned,
81
79
  isPinnedStatusMessage,
82
80
  classifyWakeMapping,
@@ -89,7 +87,6 @@ import {
89
87
  type BindableContext,
90
88
  type ReactionState,
91
89
  dmTrustTtlMs,
92
- swallowRoomOp,
93
90
  domainRoster,
94
91
  domainKeyOf,
95
92
  resolveWakeTrust,
@@ -105,7 +102,8 @@ import { admitSlackInputResponse } from "./slack-input-response.js";
105
102
  import { taskCardConfidentiality } from "./slack-task-card.js";
106
103
  import { turnEffectsFor, botDisplayName, slackForm, isSupervisorReplyTurn, deliverSupervisorReply, dmWakeThreadOverride, replyThreadTsForState, slackAuthorIsMember, slackWakeAuthorFacts, type SlackMemberProbe, type SupervisorReplyTarget } from "./slack-events.js";
107
104
  import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
108
- import { stepStatusFor, type StepAction } from "./slack-step-label.js";
105
+ import { type StepAction } from "./slack-step-label.js";
106
+ import { setSlackStatusReaction, setSlackThreadStatus, SLACK_WORKING_STATUS, slackStatusVerb, slackStepStatus, type SlackSignRequest } from "./slack-working-signs.js";
109
107
  import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
110
108
 
111
109
  // WARM_SILENT_LIMIT (the warm-thread shed threshold) now lives in channels/engagement.ts so
@@ -122,13 +120,8 @@ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
122
120
  // "working" from the inbound handlers we already own — so the HITL approval UI
123
121
  // (input.requested), reply rendering (message.completed), and typing all stay intact.
124
122
 
125
- // --- status verbs (the fun bit) ----------------------------------------------
126
- // "Tinkering…" / "Untangling…" instead of a flat "Thinking…"/"Working…" — a random
127
- // pick per status update from the channel's effective vocabulary (framework defaults
128
- // ← org ← role ← domain, extend-or-replace; memory/domain.ts statusVerbsFor — a
129
- // domain's ROLE preset contributes its themed verbs as its own rung). Orgs add house
130
- // verbs, a team adds its in-jokes. Cosmetic only, so ANY failure falls back to a
131
- // plain verb — the status line must never take a turn down.
123
+ // --- status verbs: slack-working-signs.ts `slackStatusVerb` (OPE-2632 moved them there, so both doors
124
+ // draw one vocabulary). ---------------------------------------------------------
132
125
  /** The team a channel belongs to per THIS deploy's domain config (`slack/<team>/<channel>` entries),
133
126
  * or undefined when the channel is unmapped. Fail-soft: config errors read as "unknown", never
134
127
  * throw into the inbound path. Used only as the last leg of onInbound's teamId heal. */
@@ -150,29 +143,15 @@ function teamIdFromDomainConfig(channelId: string | undefined): string | undefin
150
143
  return undefined;
151
144
  }
152
145
 
153
- function statusVerb(state: { channelId?: string | null; teamId?: string | null }): string {
154
- try {
155
- const p = resolvePartition({
156
- channel: { kind: "slack" },
157
- session: { auth: { current: { attributes: { team_id: state.teamId ?? "", channel_id: state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
158
- });
159
- const cfg = getConfig();
160
- const verbs = statusVerbsFor(p.surfaceKey, cfg.loadDomains(), cfg.loadRoles?.() ?? []);
161
- return `${verbs[Math.floor(Math.random() * verbs.length)]}…`;
162
- } catch {
163
- return "Working…";
164
- }
165
- }
166
-
167
146
  // Shared by `reasoning.appended` and `actions.requested`: the clean status verb, or a CODE-owned
168
147
  // step label (OPE-2449, `slack-step-label.ts`), and NEVER the model's own in-progress text. We used to show the model's pre-tool-call
169
148
  // preamble here (Tag-style, written for humans) — dropped 2026-07 (Tommy, live): that
170
149
  // text streams in mid-sentence, so it could flash a truncated fragment ("I'll go away and
171
150
  // look to setu…") for a moment before reverting to a verb, which read as broken. One
172
151
  // helper, one behaviour, so neither call site can drift back to surfacing partial text.
173
- async function refreshTypingStatus(channel: SlackEventContext, stepLabel?: string): Promise<void> {
152
+ async function refreshTypingStatus(channel: SlackEventContext, actions?: readonly StepAction[]): Promise<void> {
174
153
  try {
175
- await setWakeTyping(channel, stepLabel ?? statusVerb(channel.state));
154
+ await setWakeTyping(channel, slackStepStatus(actions, channel.state));
176
155
  } catch {
177
156
  /* typing indicator is best-effort */
178
157
  }
@@ -190,12 +169,7 @@ async function setWakeTyping(channel: SlackEventContext, status?: string): Promi
190
169
  const override = dmWakeThreadOverride(channel.state);
191
170
  const channelId = channel.state.channelId ?? undefined;
192
171
  if (override && channelId) {
193
- try {
194
- const body = new URLSearchParams({ channel_id: channelId, thread_ts: override, status: status ?? "" });
195
- await slackForm("assistant.threads.setStatus", body.toString());
196
- } catch {
197
- /* thinking-status is best-effort */
198
- }
172
+ await setSlackThreadStatus(formSign, { channelId, threadTs: override }, status ?? "");
199
173
  return;
200
174
  }
201
175
  await channel.thread.startTyping(status);
@@ -284,19 +258,31 @@ async function setStatusReaction(slack: SlackHandle, state: ReactionState | "non
284
258
  const timestamp = threadTsOverride ?? slack.threadTs;
285
259
  if (!channel || !timestamp) return;
286
260
  // The shared egress exit (core's channels/egress-spoof.ts, OPE-1132). This glyph is a room post,
287
- // and it is the ONE Slack write in this bundle that does not travel through core's Slack bridge —
288
- // `slack.request` is eve's own channel handle, so the bridge's gate never sees it. Gated here for
289
- // that reason alone; every other Slack write this deployment makes is already covered upstream.
290
- if (swallowRoomOp("a slack status reaction")) return;
291
- for (const [s, name] of Object.entries(STATUS_REACTION)) {
292
- try {
293
- await slack.request(s === state ? "reactions.add" : "reactions.remove", { channel, name, timestamp });
294
- } catch {
295
- /* already-reacted / no-reaction / missing-scope are all non-fatal */
296
- }
297
- }
261
+ // and `slack.request` is eve's own channel handle, so core's Slack bridge gate never sees it.
262
+ // `setSlackStatusReaction` asks `swallowRoomOp` for that reason.
263
+ // OPE-2632: the shared implementation (slack-working-signs.ts), which the DM door's signs call too.
264
+ await setSlackStatusReaction(
265
+ async (method, params) => {
266
+ try {
267
+ await slack.request(method, { ...params });
268
+ } catch {
269
+ /* already-reacted / no-reaction / missing-scope are all non-fatal */
270
+ }
271
+ },
272
+ { channel, timestamp },
273
+ state,
274
+ );
298
275
  }
299
276
 
277
+ /** The host-side sign seam for {@link setWakeTyping}'s per-wake override: `slackForm`, never throwing. */
278
+ const formSign: SlackSignRequest = async (method, params) => {
279
+ try {
280
+ await slackForm(method, new URLSearchParams({ ...params }).toString());
281
+ } catch {
282
+ /* thinking-status is best-effort */
283
+ }
284
+ };
285
+
300
286
  // Mirror surface-tier STATUS.md to a single pinned Slack message (find it via a
301
287
  // stable header marker so we don't persist a message ts across sessions).
302
288
  async function syncPinnedStatus(channel: SlackEventContext): Promise<void> {
@@ -712,7 +698,7 @@ async function onInbound(
712
698
  }
713
699
 
714
700
  try {
715
- await ctx.thread.startTyping(statusVerb({ channelId: message.channelId, teamId: message.teamId }));
701
+ await ctx.thread.startTyping(slackStatusVerb({ channelId: message.channelId, teamId: message.teamId }));
716
702
  } catch {
717
703
  /* typing indicator is best-effort */
718
704
  }
@@ -934,7 +920,7 @@ const slackMentionChannel = slackChannel({
934
920
  channel.state.pendingTaskResults = null;
935
921
  channel.state.pendingToolCallMessage = null;
936
922
  (channel.state as unknown as Record<string, unknown>).oeReasoningTypingAtMs = null;
937
- await setWakeTyping(channel, "Working...");
923
+ await setWakeTyping(channel, SLACK_WORKING_STATUS);
938
924
  },
939
925
  // Keep the model's REASONING out of the channel. GLM-5.2 runs with reasoning:"medium",
940
926
  // and Eve's default `reasoning.appended` handler pipes the chain-of-thought straight into
@@ -978,7 +964,7 @@ const slackMentionChannel = slackChannel({
978
964
  // so the indicator flashed a truncated fragment ("I'll go away and look to setu…"). That
979
965
  // stays dropped: no model text reaches the status line.
980
966
  "actions.requested": async (data, channel) => {
981
- await refreshTypingStatus(channel, stepStatusFor(data.actions as readonly StepAction[] | undefined));
967
+ await refreshTypingStatus(channel, data.actions as readonly StepAction[] | undefined);
982
968
  },
983
969
  // 🔴 THE OUTPUT FENCE (OPE-2449). eve's defaults for these two events show
984
970
  // `presentation[callId].label`, which a tool's `label.complete`/`label.delta` builds from its
@@ -154,6 +154,21 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
154
154
  "the line they read as members. A workspace under a **different** Grid, or under no declaration, is",
155
155
  "still an outside author.",
156
156
  "",
157
+ "**An approval card in a 1:1 DM does not read the line (OPE-2630).** On a Grid, Slack can mark a DM",
158
+ "with `is_org_shared` (a share across the Grid's own workspaces) and `is_shared` (any share, a Slack",
159
+ "Connect one included). A DM does not count these two bits as an outside audience. A DM refuses an",
160
+ "approval card only on what Slack observes: the Slack Connect bits (`is_ext_shared`,",
161
+ "`is_pending_ext_shared`), or a partner whose `users.info` carries a guest flag. A group DM and a",
162
+ "channel still count all four share bits. The MCP-server propose card passes the same floor (OPE-2640).",
163
+ "Before that card, `propose_mcp_server` refuses a DM whose partner verdict from `users.info` is",
164
+ "external. That verdict does not read the share bits, but it does read the line. Without the line, a",
165
+ "full member at home in another workspace of the Grid is refused. The integration test",
166
+ "`tests/integration/approvals/grid-dm-card.test.ts` forges the Grid DM, with a Slack Connect DM as the",
167
+ "refused control. The live leg `S-DM-MCP-PROPOSE` posts the card in the board's own DM, which is not on",
168
+ "a Grid. The",
169
+ "**click** on that card is a member question, so it does read the line: a member-tier click from a",
170
+ "person whose home workspace is not the click's workspace counts as a guest click without it.",
171
+ "",
157
172
  "The member half needs no extra scope and no configuration beyond the line above: Open Everywhere",
158
173
  "reads the author's own workspace and Grid from the `users.info` lookup it already pays for, so it",
159
174
  "holds on every ingress, the channel-mention path included. A single-workspace install has no",
@@ -241,11 +256,13 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
241
256
  "[the control plane](../../security/control-plane.md)); a reopen keeps every stored credential, so the",
242
257
  "agent stays on Slack while you walk the page again.",
243
258
  "",
244
- "🔴 **If `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` is set in the environment, `/setup` answers 404.**",
245
- "The resolver is env-first — deliberately, so that landing this page cannot change which Slack app a",
246
- "working deployment speaks as — which means a run on such a deployment would store credentials nothing",
247
- "would ever read. Rather than let you complete a run that changes nothing, the page refuses. To move an",
248
- "existing deployment onto the credential store, unset both variables and redeploy; `/setup` then opens",
259
+ "🔴 **If `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` is set — as an operator secret (`oe secret set`)",
260
+ "or as its env var on-ramp — `/setup` answers 404.** The resolver takes the operator secret first",
261
+ "(OPE-2650; before it, the env var) — deliberately, so that landing this page cannot change which Slack",
262
+ "app a working deployment speaks as — which means a run on such a deployment would store credentials",
263
+ "nothing would ever read. Rather than let you complete a run that changes nothing, the page refuses. To",
264
+ "move an existing deployment onto the `/setup` values, run `oe secret rm` for both names, unset both",
265
+ "variables and redeploy; `/setup` then opens",
249
266
  "and the deployment logs a setup code on that start.",
250
267
  "",
251
268
  "This is also why an existing deployment does **not** get an open setup page when it upgrades: it is",
@@ -275,7 +292,7 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
275
292
  "",
276
293
  "**2. The `/setup` window.** The page is open from the first start with a valid",
277
294
  "`OE_CREDENTIAL_ENCRYPTION_KEY` until the first Slack install, unless `SLACK_BOT_TOKEN` or",
278
- "`SLACK_SIGNING_SECRET` is set in the environment. It answers 404 while the key is unset. In that",
295
+ "`SLACK_SIGNING_SECRET` is set (as an operator secret or its env var). It answers 404 while the key is unset. In that",
279
296
  "window, anyone who can read the deployment's logs can claim the page: every member of the Vercel",
280
297
  "team, and every reader of a log drain. The code is 128 bits, so a guess does not work. A claim lets",
281
298
  "the holder create a Slack app in their own workspace that points at this deployment, and lock",
@@ -666,20 +683,26 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
666
683
  "",
667
684
  "## Step 2 — Configure the environment",
668
685
  "",
669
- "Set these on your instance:",
686
+ "Both Slack values are **operator secrets** (OPE-2650): store them in the deployment's encrypted",
687
+ "credential store, from your own terminal, with the same `DATABASE_URL` and",
688
+ "`OE_CREDENTIAL_ENCRYPTION_KEY` the deployment reads:",
670
689
  "",
671
690
  "```bash",
672
- "SLACK_BOT_TOKEN=xoxb-… # Bot User OAuth Token from Step 1.4",
673
- "SLACK_SIGNING_SECRET=… # Signing Secret from Step 1.5",
691
+ "npx oe secret set SLACK_BOT_TOKEN # Bot User OAuth Token from Step 1.4 (value on stdin)",
692
+ "npx oe secret set SLACK_SIGNING_SECRET # Signing Secret from Step 1.5 (value on stdin)",
674
693
  "```",
675
694
  "",
695
+ "A stored value takes effect within thirty seconds, with no redeploy. The env vars of the same names",
696
+ "still work as the on-ramp: the deployment reads one only when nothing is stored under its name.",
697
+ "",
676
698
  "Optional, but relevant to the silo model:",
677
699
  "",
678
700
  "```bash",
679
- "OE_MEMORY_PROVIDER=git # memory backend — git | sqlite",
680
701
  "OE_MEMORY_DIR=/abs/path/to/store",
681
702
  "```",
682
703
  "",
704
+ "The memory backend (`git` | `sqlite`) is the `memory.provider` setting in `agent/settings/settings.ts`, not an env var.",
705
+ "",
683
706
  "**Identity binds automatically.** You do **not** set workspace, surface, mode, or visibility by hand. The Slack channel reads `team_id`, `channel_id`, `channel_type`, and `is_private` straight off each inbound event into the session auth context, and `bindSlack` derives workspace = the Slack workspace, surface = the channel, plus mode and visibility. The `deploymentIdentity.workspace` / `.surface` declaration in `agent/identity/surfaces.ts` is only the `eve dev` (no-surface HTTP) gap-filler — once Slack is wired it is ignored.",
684
707
  "",
685
708
  "## Step 3 — Invoke it",
@@ -733,7 +756,12 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
733
756
  " reaction via the `react` tool — a bare emoji ack (e.g. 👍) instead of a low-content reply in a",
734
757
  " busy thread. It's a genuine alternative response, not a status indicator, and is audited like",
735
758
  " any other tool call.",
736
- "4. Test a DM: open a direct message with the bot and send a plain message (no `@mention` needed in a DM). It responds the same way. The DM surface's memory is isolated and `private`.",
759
+ "4. Test a DM: open a direct message with the bot and send a plain message (no `@mention` needed in a DM). The DM surface's memory is isolated and `private`. While the turn works, the bot shows the same signs a mention gets. A group DM with the bot shows them too.",
760
+ " - **👀 on your message.** A message inside a DM thread gets it on the thread's first message, as in a channel. It goes away when the turn ends. A DM reply stays flat, as described above.",
761
+ " - **The status line** under your message: `Working...` at the start, then a live step label while a tool runs (`Reading the thread…`), from the same code-owned table a mention uses. It goes away when the turn ends.",
762
+ " - **🛑 when the turn fails for good.** The 👀 goes, 🛑 goes on your message, and the status line clears. In a 1:1 DM the agent session also leaves `processing`, so Slack takes its Stop button off the dead turn. The next turn on the same message takes the 🛑 off again.",
763
+ " - **A group DM** has no agent session, so it has no Stop button and no session state. It shows every other sign.",
764
+ " - **If a sign is missing**, the app lacks a scope or the agent view. The 👀 and the 🛑 need `reactions:write`. The status line needs `assistant:write` and the agent view (`features.agent_view`). All three come from the manifest: paste it again (App Manifest → Save Changes) and reinstall if Slack asks. The deployment logs the refused call once per process, with this fix in the line: search the logs for `[open-everywhere:slack-session]`.",
737
765
  "",
738
766
  "### What it can read on Slack",
739
767
  "",
@@ -990,6 +1018,7 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
990
1018
  "| **A title.** A conversation that opens with a top-level message shows a short title from the words the person typed. | The DM door creates the session with that title before the turn starts. The title is the first typed line, cut to 60 characters at a word. A member mention is dropped, a channel shows as `#name` and a link shows as its label, so the title holds no raw id. The lines of a forwarded or quoted message are another conversation's words, so they never make the title: a forward with no typed text gets no title. A voice clip's transcript is not typed text either, so a clip with no typed words gets no title. The host writes the title in code; no model writes it. |",
991
1019
  "| **Their own title.** The person can rename a session at any time. | Slack applies a title only when a call creates the session, so OE never overwrites a rename. |",
992
1020
  "| **Working, with a Stop button.** | A turn's start sets the session `processing`. Slack then shows its Stop button, because the app subscribes to `agent_session_stopped`. |",
1021
+ "| **👀 and the status line.** | The same start puts 👀 on the session's first message and shows `Working...` as Slack's assistant status there. The park removes both. The DM seat runs on the `unified` channel, so the Slack channel's own 👀 and status line (the mention door's) never reach it: these two calls are the DM's only working signs (OPE-2629). A forwarded job's settlement (next rows) sets the session state only and leaves the signs to the running turn. A turn that fails for good never parks, so its signs stay. |",
993
1022
  "| **Stop.** | A press takes the same road as the ✋ reaction and `/oe stop`: it is decided at ingress, before any model call, so it is free and works over a spend cap. It stops the turn of the session's thread. In the DM it falls back to the DM's newest turn, because a top-level DM message keys its turn by the channel alone. The audit row is `turn.cancel_requested` with `via: \"stop-button\"`. Slack does not move the status itself, so OE sets the session `active`. |",
994
1023
  "| **\"Needs you\".** | When the seat's turn reported through `update_status` that it waits on the person (`needs_input` or `blocked`), the agent session parks `suspended`. Otherwise it parks `active`. The person's next message clears it: that turn sets `processing`, then parks `active` unless it reports again that it waits on the person, or a job of the session still waits (next row). A job's result turn sets no state: the result wake names no agent session. |",
995
1024
  "| **\"Needs you\", for a job.** | A job the DM seat forwards can end waiting on the person: its worker asked a question in the DM (`ask_room`) and finished, or it needs the person's own sign-in. The work session has no `update_status`, so the host sets the agent session the job came from `suspended` when the job's task settles. While the job waits, every turn of that session parks `suspended`. When the person answers and the seat forwards the answer to the same task, the job moves on in the session it came from, whichever agent session of the DM the answer came from. That session then parks `active`, unless another of its jobs still waits. After a sign-in, the resume forward sets the session `active`. The audit row is `work.surface_wait`: ids, the state and the reason, never the question. |",
@@ -1033,7 +1062,11 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
1033
1062
  "test `tests/integration/channels/slack-agent-session.test.ts` drives a booted agent through the",
1034
1063
  "signed DM door: the title before `processing`, `suspended` after `update_status(needs_input)`,",
1035
1064
  "`active` after the answer, no title for a forward with no typed text, and a signed Stop press that",
1036
- "stops a held turn and sets the session `active`. The integration test",
1065
+ "stops a held turn and sets the session `active`. The same test proves the working signs: 👀 on the",
1066
+ "person's top-level message before the park, `Working...` on the same message, and both cleared at the",
1067
+ "park. The live e2e scenario `S-SESSION-STOP` reads `reactions.get` on the person's real DM message",
1068
+ "while its turn runs (👀 present), and again after the stop (👀 gone). No API reads the status line",
1069
+ "back, so no live leg proves it. The integration test",
1037
1070
  "`tests/integration/channels/slack-job-wait.test.ts` forwards a DM job whose worker asks with",
1038
1071
  "`ask_room`: the session goes `suspended` at the job's settlement, another message keeps it so, and the",
1039
1072
  "answer forwarded to the same task parks it `active`. The test holds the seat's result-wake",
@@ -1189,7 +1222,11 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
1189
1222
  "**What the tests prove.** The unit pack `test:slack-doc-write` (packages/core) pins the room fence, the",
1190
1223
  "cell encoding, the row match, the card text and the exact Slack calls an approval sends. The live e2e",
1191
1224
  "scenario `S-CANVAS-LIST` asks the agent for a canvas in a real channel, approves the card, and reads the",
1192
- "canvas back from Slack.",
1225
+ "canvas back from Slack. The live e2e scenario `S-LIST-WRITE` asks the agent to add a row to a fixture",
1226
+ "List in a real channel and then to change that row, approves each card, and reads the row back from",
1227
+ "Slack. It needs a List titled \"OE e2e List fixture\", with a text column named \"Note\", shared in the e2e",
1228
+ "channel, and a driver token with `lists:read`. The clean-up also needs `lists:write`: without it,",
1229
+ "each run leaves its row in the List.",
1193
1230
  "",
1194
1231
  "## Slash commands: `/oe` (zero-inference control-plane)",
1195
1232
  "",
package/agent/ops.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  configuredAgentName,
32
32
  declaredMcpServers,
33
33
  getSettings,
34
+ registerOperatorSecretNames,
34
35
  registerSurfaceAppManifest,
35
36
  } from "@open-everywhere/core/agent/adapter-api-ops.js";
36
37
  import { SLACK_AS_YOU_NAME } from "./slack-as-you.js";
@@ -60,6 +61,9 @@ export function slackManifestOptionsFromSettings(opts: { readonly appName?: stri
60
61
 
61
62
  /** Register the slack ops-plane arm (the app manifest). */
62
63
  export function registerSlackOps(): void {
64
+ // The two Slack credentials are operator secrets (OPE-2650): `scaffold_env` marks them, so the setup
65
+ // flow names `secret_set` as the step that stores them.
66
+ registerOperatorSecretNames(["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"]);
63
67
  // THE PASTE-READY APP MANIFEST (OPE-1603). Minting the Slack app stays human, so the ops plane's
64
68
  // job is to hand the operator the exact document. The bundle owns the document (agent/
65
69
  // app-manifest.ts); core substitutes this deployment's host into it.
package/agent/register.ts CHANGED
@@ -59,7 +59,7 @@ import {
59
59
  registerBotAskProbe,
60
60
  registerSurfaceSessionState,
61
61
  } from "@open-everywhere/core/agent/adapter-api.js";
62
- import { slackSurfaceSessionPin, slackSurfaceSessionSetter } from "./channels/slack-agent-session.js";
62
+ import { slackSurfaceSessionPin, slackSurfaceSessionSetter, slackSurfaceSessionStepper } from "./channels/slack-agent-session.js";
63
63
  import { slackFriendlyNameResolver } from "./channels/slack-friendly-names.js";
64
64
  import { PROVIDER_DOCS } from "./docs-generated.js";
65
65
  import { slackSurfaceStatus } from "./status.js";
@@ -125,7 +125,9 @@ export function registerSlackProvider(): void {
125
125
  // SLACK'S AGENT SESSION STATE (OPE-2519): `processing` while a turn runs (Slack then shows its Stop
126
126
  // button), and `active` or `suspended` at the park. Core's turn-anchor hook drives it; the setter
127
127
  // names the person's DM session from the wake's auth (channels/slack-agent-session.ts).
128
- registerSurfaceSessionState("slack", slackSurfaceSessionSetter(), slackSurfaceSessionPin);
128
+ // OPE-2632: the same setter and the stepper draw a DM-door turn's working signs (1:1 and group DM):
129
+ // 👀, the status line with its step labels, and 🛑 when the turn fails for good.
130
+ registerSurfaceSessionState("slack", slackSurfaceSessionSetter(), slackSurfaceSessionPin, slackSurfaceSessionStepper());
129
131
  // The bot's own Slack display name (OPE-587) — the arm the instruction assembler renders as a
130
132
  // HANDLE line on a Slack-pinned session ("your mention/handle here appears as X"), never as
131
133
  // identity since OPE-610 settled the naming model. `botDisplayName` memoises one auth.test +
@@ -63,7 +63,7 @@ import {
63
63
  } from "@open-everywhere/core/agent/adapter-api.js";
64
64
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
65
65
  import { SLACK_COOKIE_KEY_REF, SLACK_SETUP_CODE_REF, readInstallRecord, type SlackInstallStateDeps } from "./install-state.js";
66
- import { slackSecretEnvPresent } from "./credentials.js";
66
+ import { slackOperatorSecretPresent } from "./credentials.js";
67
67
 
68
68
  /** The cookie the page sets once the code matches. `__Host-` pins it to this exact host with no
69
69
  * domain and a `/` path, which a subdomain cannot overwrite. */
@@ -192,13 +192,14 @@ export async function slackSetupOpen(deps: SlackClaimDeps = {}): Promise<boolean
192
192
  // stored credentials, lock the page behind themselves, and have `slackInstallFacts()` publish their
193
193
  // workspace as this deployment's install.
194
194
  //
195
- // Closing on env presence costs nothing a real `/setup` operator wants: the resolver is env-first,
196
- // so a deployment with either var set would IGNORE everything a run stored anyway — the page would
197
- // have been a no-op with a credential write. An operator who wants to migrate onto the store unsets
198
- // the vars and redeploys, which is what the runbook says and what makes the run meaningful.
199
- // Screen 2's shadow notice still covers the case where one var is set after an install.
200
- const env = slackSecretEnvPresent({ ...(deps.env ? { env: deps.env } : {}) });
201
- if (env.botToken || env.signingSecret) return false;
195
+ // Closing on operator-secret presence costs nothing a real `/setup` operator wants: the resolver
196
+ // puts the operator secret first (OPE-2650 moved the two env vars there, with env as its on-ramp),
197
+ // so a deployment with either one set would IGNORE everything a run stored anyway — the page would
198
+ // have been a no-op with a credential write. An operator who wants to migrate onto the `/setup`
199
+ // values removes the operator secrets and the env vars, and redeploys. Screen 2's shadow notice
200
+ // still covers the case where one is set after an install.
201
+ const present = await slackOperatorSecretPresent({ ...(deps.env ? { env: deps.env } : {}) });
202
+ if (present.botToken || present.signingSecret) return false;
202
203
 
203
204
  // 🔴 ONLY AN ABSENT ROW OPENS THE PAGE, and the earlier draft got this backwards. It read through
204
205
  // `slackInstallState`, which collapses a store fault, a malformed row and an unknown record version