@open-everywhere/provider-slack 0.1.5 → 0.2.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 (52) hide show
  1. package/NOTICE +9 -9
  2. package/README.md +1 -1
  3. package/agent/app-manifest.ts +44 -9
  4. package/agent/channels/slack-commands.ts +9 -5
  5. package/agent/channels/slack-events.ts +73 -13
  6. package/agent/channels/slack-front.ts +2 -2
  7. package/agent/channels/slack-task-card.ts +182 -0
  8. package/agent/channels/slack.ts +518 -468
  9. package/agent/contributions.ts +1 -1
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/ops.ts +20 -2
  12. package/agent/setup/eve-credentials.ts +10 -2
  13. package/agent/setup/page.ts +12 -9
  14. package/dist/agent/app-manifest.d.ts +20 -4
  15. package/dist/agent/app-manifest.d.ts.map +1 -1
  16. package/dist/agent/app-manifest.js +29 -6
  17. package/dist/agent/app-manifest.js.map +1 -1
  18. package/dist/agent/channels/slack-commands.d.ts.map +1 -1
  19. package/dist/agent/channels/slack-commands.js +9 -6
  20. package/dist/agent/channels/slack-commands.js.map +1 -1
  21. package/dist/agent/channels/slack-events.d.ts +28 -6
  22. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-events.js +71 -14
  24. package/dist/agent/channels/slack-events.js.map +1 -1
  25. package/dist/agent/channels/slack-front.d.ts +1 -1
  26. package/dist/agent/channels/slack-front.js +1 -1
  27. package/dist/agent/channels/slack-front.js.map +1 -1
  28. package/dist/agent/channels/slack-task-card.d.ts +38 -0
  29. package/dist/agent/channels/slack-task-card.d.ts.map +1 -0
  30. package/dist/agent/channels/slack-task-card.js +156 -0
  31. package/dist/agent/channels/slack-task-card.js.map +1 -0
  32. package/dist/agent/channels/slack.d.ts.map +1 -1
  33. package/dist/agent/channels/slack.js +516 -466
  34. package/dist/agent/channels/slack.js.map +1 -1
  35. package/dist/agent/contributions.d.ts +1 -1
  36. package/dist/agent/contributions.d.ts.map +1 -1
  37. package/dist/agent/contributions.js +1 -1
  38. package/dist/agent/contributions.js.map +1 -1
  39. package/dist/agent/docs-generated.js +1 -1
  40. package/dist/agent/docs-generated.js.map +1 -1
  41. package/dist/agent/ops.d.ts +5 -0
  42. package/dist/agent/ops.d.ts.map +1 -1
  43. package/dist/agent/ops.js +13 -2
  44. package/dist/agent/ops.js.map +1 -1
  45. package/dist/agent/setup/eve-credentials.d.ts +9 -2
  46. package/dist/agent/setup/eve-credentials.d.ts.map +1 -1
  47. package/dist/agent/setup/eve-credentials.js +9 -1
  48. package/dist/agent/setup/eve-credentials.js.map +1 -1
  49. package/dist/agent/setup/page.d.ts.map +1 -1
  50. package/dist/agent/setup/page.js +12 -10
  51. package/dist/agent/setup/page.js.map +1 -1
  52. package/package.json +10 -3
@@ -89,12 +89,14 @@ import {
89
89
  swallowRoomOp,
90
90
  domainRoster,
91
91
  } from "@open-everywhere/core/agent/adapter-api.js";
92
+ import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
92
93
  import { loadConversationContext } from "./slack-context.js";
93
94
  import { slackSpendGate } from "./slack-spend-gate.js";
94
95
  import { slackTenantAdmission } from "./slack-tenant-fence.js";
95
96
  import { holdRouteWork } from "./slack-route-hold.js";
96
97
  import { frontSlackChannel, INTERNAL_SLACK_ROUTE } from "./slack-front.js";
97
98
  import { admitSlackInputResponse } from "./slack-input-response.js";
99
+ import { taskCardConfidentiality } from "./slack-task-card.js";
98
100
  import { turnEffectsFor, botDisplayName, slackForm, isSupervisorReplyTurn, deliverSupervisorReply, dmWakeThreadOverride, replyThreadTsForState, type SupervisorReplyTarget } from "./slack-events.js";
99
101
  import { markdownToSlackMrkdwn } from "./slack-mrkdwn.js";
100
102
 
@@ -787,488 +789,536 @@ const slackMentionChannel = slackChannel({
787
789
  // session-limit prompt. The admission runs the SAME room floor the approval click runs
788
790
  // (approvals/clicker-floor.ts); see channels/slack-input-response.ts for the shape.
789
791
  onInputResponse: (ctx, submission) => admitSlackInputResponse(ctx, submission),
790
- events: {
791
- // Snapshot the supervisor's REAL per-turn reply target, then run eve's default
792
- // turn.started body. eve wraps turn.started specially (verified in the compiled
793
- // slackChannel): it always sets `triggeringUserId` from the auth, THEN calls the
794
- // authored handler IN PLACE OF `defaultEvents["turn.started"]` — so authoring this
795
- // handler REPLACES the default's state-clear + "Working…" typing. We therefore
796
- // replicate that default body verbatim below so NON-supervisor turns stay
797
- // byte-identical to today; the supervisor snapshot is the only addition, gated on the
798
- // `oeSupervisor` flag (T1.3's wake state).
799
- //
800
- // Why snapshot here (T1.4 / C1): a supervisor session's `state.threadTs` is a NON-EMPTY
801
- // sentinel (amendment A), so it can't carry the turn's real target. But eve rewrites
802
- // `session.auth.current` from the DELIVERY's auth on every delivery, and Slack auth
803
- // attributes carry `channel_id`/`thread_ts` — so at turn.started they are THIS turn's
804
- // real trigger. We copy them into `channel.state.oeReplyTarget` for message.completed to
805
- // post to. `thread_ts` present ⇔ the trigger was threaded (buildSlackAuth only sets it
806
- // when the message had a thread_ts); absent ⇔ a top-level channel message.
807
- "turn.started": async (_data, channel, ctx) => {
808
- // Supervisor target snapshot FIRST — must not depend on the typing call succeeding.
809
- if (isSupervisorReplyTurn(channel.state)) {
810
- const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
811
- const channelId = typeof attrs?.channel_id === "string" ? attrs.channel_id : undefined;
812
- const threadTs = typeof attrs?.thread_ts === "string" ? attrs.thread_ts : undefined;
813
- // channelId missing ⇒ leave no stale target from a prior turn (fail safe: no target
814
- // means message.completed posts NOTHING, never to a guessed channel).
815
- (channel.state as unknown as Record<string, unknown>).oeReplyTarget = channelId
816
- ? ({ channelId, threadTs } satisfies SupervisorReplyTarget)
817
- : undefined;
818
- }
819
- // OPE-645: REPLY PRINCIPAL snapshot — same T1.4 pattern as the supervisor target above, for the
820
- // same reason: message.completed has no live auth ctx, so the canonical row it writes cannot
821
- // derive WHO from ctx at that point. Since OPE-2006 that row is the DROP row, never a
822
- // `reply.post`. authPrincipalId(ctx) is the exact read bindFrom(ctx).principalId falls back to
823
- // for an ordinary human turn (identity/binding.ts) — snapshotting it here means the drop row
824
- // names the same person the turn's egress.policy rows already do.
825
- (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId = authPrincipalId(ctx);
826
- // OPE-98: DM-SEAT per-wake anchor. A DM seat (OPE-90) is ONE per-channel eve session spanning
827
- // many assistant threads, but eve freezes the session's reply target to the FIRST wake's
828
- // thread (`state.threadTs`, seeded once at session birth — see slack-events.ts's
829
- // dmWakeThreadOverride header). Snapshot THIS wake's thread from the delivery auth (rewritten
830
- // per delivery ⇒ the waking message's thread) so the reply + thinking-status ride it. SCOPED
831
- // to `im` DMs — every channel/supervisor/mpim turn leaves `oeWakeThreadTs` unset, so their
832
- // targeting stays byte-identical.
833
- {
834
- const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
835
- if (attrs?.channel_type === "im") {
836
- (channel.state as unknown as Record<string, unknown>).oeWakeThreadTs =
837
- typeof attrs.thread_ts === "string" ? attrs.thread_ts : undefined;
838
- }
839
- }
840
- // eve's default turn.started body, byte-preserved (see header above).
841
- channel.state.pendingToolCallMessage = null;
842
- channel.state.lastReasoningTypingAtMs = null;
843
- channel.state.lastReasoningTypingStatus = null;
844
- await setWakeTyping(channel, "Working...");
845
- },
846
- // Keep the model's REASONING out of the channel. GLM-5.2 runs with reasoning:"medium",
847
- // and Eve's default `reasoning.appended` handler pipes the chain-of-thought straight into
848
- // the Slack typing-status line (startTyping(reasoningText)). That leaks internal thinking
849
- // into the surface. We override it to refresh a GENERIC status instead — the indicator
850
- // stays alive (so a long reasoning phase doesn't look stalled) without echoing the
851
- // reasoning itself. Throttled to the default's ~5s cadence to avoid spamming startTyping.
852
- // The clean final answer leaves by the `reply` TOOL and by nothing else: this channel authors
853
- // its own `message.completed`, and since OPE-2006 that handler DROPS the turn's closing prose
854
- // and records it (`channels/closing-prose-drop.ts`). Re-delegating to eve's default poster here
855
- // would re-open the road this ticket removed, on the surface OPE-771 measured the loss on.
856
- "reasoning.appended": async (_data, channel) => {
857
- const now = Date.now();
858
- const last = channel.state.lastReasoningTypingAtMs;
859
- if (last != null && now - last >= 0 && now - last < 5000) return;
860
- channel.state.lastReasoningTypingAtMs = now;
861
- await refreshTypingStatus(channel);
862
- },
863
- // Eve's default falls back to "Running bash, read_channel..." for a tool-call
864
- // step, which means nothing to non-engineers and advertises the toolbox to the whole
865
- // channel — so we override with a generic status. We ALSO used to show the model's own
866
- // preamble text here (its line before the tool call, written for humans, Tag-style) —
867
- // dropped 2026-07 (Tommy, live): that text streams in mid-sentence, so the typing
868
- // indicator would flash a truncated fragment ("I'll go away and look to setu…") for a
869
- // moment before reverting, which read as broken. Always the clean status verb now,
870
- // same as reasoning.appended.
871
- "actions.requested": async (_data, channel) => {
872
- await refreshTypingStatus(channel);
873
- },
874
- // Override the reply-posting handler for TWO additions: (1) a woken (non-mention) turn
875
- // is told to judge whether a reply is warranted and to end with NO_REPLY when it isn't
876
- // (slack-events.ts renderWakeMessage) — that sentinel must be swallowed, not posted;
877
- // (2) a turn where a propose tool already posted a code-rendered approval card
878
- // (approvals/flow.ts's requestApproval, signalled via approvals/card-signal.ts)
879
- // suppresses the model's redundant text — the card already said it (Tommy, live: "the
880
- // card should suffice"). Everything else mirrors Eve's default handler byte-for-byte
881
- // (tool-calls is an intermediate step with no turn-final effects; empty final message
882
- // posts nothing), via the pure, unit-tested finalMessageAction()/turnEffectsFor().
883
- "message.completed": async (data, channel, ctx) => {
884
- // The eve session id for this turn — stamped into the terminal audit rows below so an
885
- // audited reply/silence/dropped-prose joins back to its transcript (control-read transcript).
886
- const sessionId = ctx?.session?.id;
887
- // OPE-1647: the TURN ID, so the stand-down silence mark (channels/stand-down-silence.ts) can
888
- // prove it belongs to THIS turn. Same extraction idiom this file already uses for the §POSTED
889
- // consume below.
890
- // 🔴 SINCE OPE-2006 IT SERVES THE SILENCE DECISION ALONE. It is read once here and handed to
891
- // `finalMessageActionForTurn`, whose `consumeStandDownSilenceThisTurn(turnId)` is its only
892
- // reader on this path. There is no reply audit below to stamp it onto: this surface DROPS its
893
- // closing prose, so the durable latch that guarded the old post has no door left to shut here.
894
- const turnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
895
- const action = finalMessageActionForTurn(data.finishReason, data.message, turnId);
896
- if (action.kind === "buffer-tool-status") {
897
- // An intermediate tool-call step, not the turn's end — nothing to decide yet. In
898
- // particular: don't read the card-posted flag here, the tool this step is about to
899
- // run may be the one that posts the card.
900
- return;
901
- }
902
- // Read-and-reset whether an approval card already posted THIS turn — consumed once,
903
- // right before the turn's terminal effects are decided, so the next turn always
904
- // starts clean (mirrors how oeSilentTurn/oeUninvited below are REASSIGNED on every
905
- // terminal call rather than cleared as a separate step).
906
- const cardPostedThisTurn = consumeCardPostedThisTurn();
907
- // Same read-and-reset contract, for the bare-stop check right below. A turn that
908
- // reacted OR posted mid-turn via the reply-tool family (reply-signal.ts) acted, so
909
- // both signals are consumed here and OR'd into the bare-stop input.
910
- const reactedThisTurn = consumeReactedThisTurn();
911
- // TURN-ID KEYED (OPE-805): a bare read-and-reset let a mark this turn never consumed answer a
912
- // LATER turn's read — see reply-signal.ts's header.
913
- const repliedThisTurn = consumeRepliedThisTurn((ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id);
914
- if (action.kind === "silent") {
915
- // Remember the turn ended silent so session.waiting doesn't stamp a ❓ status
916
- // reaction on a conversation the agent chose NOT to join (live-observed: a
917
- // judged-silent name-drop left a ❓ on the triggering message).
918
- (channel.state as unknown as Record<string, unknown>).oeSilentTurn = true;
919
- // Silence is designed to leave no trace ON THE SURFACE — but the operator
920
- // needs to distinguish "judged NO_REPLY" from "never woke" (bit us live).
921
- // action.reason distinguishes the sentinel route from the no_reply TOOL route
922
- // (finalMessageActionForTurn folds both into "silent").
923
- console.log(
924
- `[open-everywhere:slack] judged silence (${action.reason}) — nothing posted (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`,
925
- );
926
- // ACTIONS-ONLY posture (audit-wedge build plan §1c, 2026-07-14): a judged silence on an
927
- // ambient chat surface is NOT an audited action — an agent meant to reply to ~1% of channel
928
- // messages would otherwise produce a table that is ~99% silence rows. "Why did it stay
929
- // quiet?" is answered by the session transcript (GET /control/v1/session/:id/transcript),
930
- // not the audit log. The console line above stays (operator-legible, free); the bare-stop
931
- // row below stays (an ANOMALY — a judgment went missing — not a judged silence).
792
+ // 🔴 eve 0.69.0 REPLACED `events` WITH A RENDERER CHAIN, AND NOT CALLING `next` IS THE PORT
793
+ // (OPE-2247). Through 0.68.x, `slackChannel({ events })` REPLACED eve's own rendering for every
794
+ // event it named — each handler below says so in its own words, and several replicate eve's
795
+ // default body verbatim because of it. 0.69.0 turns the same handlers into one link of a chain
796
+ // that ENDS in eve's default, and the default runs only when a handler awaits `next`. So the
797
+ // behaviour-preserving port is this wrapper and nothing else: no handler here calls `next`, which
798
+ // keeps every rendering decision below exactly as it reads today.
799
+ //
800
+ // 🔴 THE TASK CARD IS EVE'S OWN, BEHIND A CONFIDENTIALITY LINK (OPE-2255). OPE-2247 ported the
801
+ // card OFF (a `taskCard` link that returned null) and left the content ruling to this ticket. eve's
802
+ // default card now runs, so a turn that starts a task shows eve's live card. The FIRST link,
803
+ // `taskCardConfidentiality` (channels/slack-task-card.ts), changes the view eve draws from, and
804
+ // nothing else:
805
+ // * a row shows its own title (the worker BRIEF) only in a 1:1 DM with the turn's own caller;
806
+ // every other room shows a generic label made from the task's name;
807
+ // * no row ever shows the worker's result excerpt, which would bypass `approvals/review-surface.ts`.
808
+ // eve builds the notification `text` from the titles, so the redaction covers it too. The link must
809
+ // stay FIRST: its event handlers record the room and then call `next`, and the next link's handlers
810
+ // do not call `next`, so a recorder placed after them would never run.
811
+ //
812
+ // Also eve's default, by ruling (OPE-2255): the `received` acknowledgement (`Thinking...`, then this
813
+ // file's own "Working..." from `turn.started`), and the private-to-principal approval feedback. This
814
+ // channel names no `approvalChannel`, so an eve-native approval card (`post_file`) posts once, in the
815
+ // thread, and only a refused responder's feedback goes privately to that responder.
816
+ renderers: [
817
+ // FIRST, always. See the 🔴 note above before moving it.
818
+ taskCardConfidentiality,
819
+ {
820
+ events: {
821
+ // Snapshot the supervisor's REAL per-turn reply target, then run eve's default
822
+ // turn.started body. eve wraps turn.started specially (verified in the compiled
823
+ // slackChannel): it always sets `triggeringUserId` from the auth, THEN calls the
824
+ // authored handler IN PLACE OF `defaultEvents["turn.started"]` — so authoring this
825
+ // handler REPLACES the default's state-clear + "Working…" typing. We therefore
826
+ // replicate that default body's state-clear below, with this channel's own typing call;
827
+ // the supervisor snapshot is the only addition, gated on the `oeSupervisor` flag (T1.3's
828
+ // wake state).
932
829
  //
933
- // ONE exception (OPE-890): the model ended the turn on the bare NO_REPLY sentinel — the
934
- // TOOL route already carries an always-on `reply.no_reply_observed` row (tools/no_reply.ts,
935
- // OPE-649); this is the sentinel route's twin, same action name, best-effort.
936
- if (action.reason === NO_REPLY_SENTINEL_REASON) {
937
- try {
938
- const p = resolvePartition({
939
- channel: { kind: "slack" },
940
- session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channel.state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
941
- });
942
- await auditNoReplySentinelObserved(p, sessionId);
943
- } catch (err) {
944
- console.warn(`[open-everywhere:slack] sentinel no_reply audit write failed: ${String(err)}`);
830
+ // Why snapshot here (T1.4 / C1): a supervisor session's `state.threadTs` is a NON-EMPTY
831
+ // sentinel (amendment A), so it can't carry the turn's real target. But eve rewrites
832
+ // `session.auth.current` from the DELIVERY's auth on every delivery, and Slack auth
833
+ // attributes carry `channel_id`/`thread_ts` — so at turn.started they are THIS turn's
834
+ // real trigger. We copy them into `channel.state.oeReplyTarget` for message.completed to
835
+ // post to. `thread_ts` present ⇔ the trigger was threaded (buildSlackAuth only sets it
836
+ // when the message had a thread_ts); absent ⇔ a top-level channel message.
837
+ "turn.started": async (_data, channel, ctx) => {
838
+ // Supervisor target snapshot FIRST — must not depend on the typing call succeeding.
839
+ if (isSupervisorReplyTurn(channel.state)) {
840
+ const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
841
+ const channelId = typeof attrs?.channel_id === "string" ? attrs.channel_id : undefined;
842
+ const threadTs = typeof attrs?.thread_ts === "string" ? attrs.thread_ts : undefined;
843
+ // channelId missing ⇒ leave no stale target from a prior turn (fail safe: no target
844
+ // means message.completed posts NOTHING, never to a guessed channel).
845
+ (channel.state as unknown as Record<string, unknown>).oeReplyTarget = channelId
846
+ ? ({ channelId, threadTs } satisfies SupervisorReplyTarget)
847
+ : undefined;
945
848
  }
946
- }
947
- // Bare-stop enforcement (feature 2's investigation — see slack-events.ts's
948
- // isBareStop header for why this is DETECT-AND-LOG, not a hard gate): a turn
949
- // that went silent with NO explanation (no sentinel, no no_reply tool) and
950
- // NO react either did, as far as anyone can tell, nothing at all. Loud warn
951
- // + a best-effort audit row; never a post — silence stays silence.
952
- if (isBareStop(action, reactedThisTurn || repliedThisTurn)) {
953
- const channelId = channel.state.channelId ?? undefined;
954
- const threadTs = channel.state.threadTs ?? undefined;
955
- console.warn(
956
- `[open-everywhere:slack] BARE STOP — turn ended with no reply, no react, and no no_reply ` +
957
- `(${channelId ?? "?"}/${threadTs ?? "?"}) — this usually means a judgment call went missing, not that one was made.`,
958
- );
959
- try {
960
- const p = resolvePartition({
961
- channel: { kind: "slack" },
962
- session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
963
- });
964
- await getOperationalStore().appendAudit({
965
- ts: new Date().toISOString(),
966
- org: p.org,
967
- platform: p.platform,
968
- workspace: p.workspace,
969
- surface: p.surface,
970
- action: "turn.bare_stop",
971
- detail: { threadTs, ...(sessionId ? { sessionId } : {}) },
972
- });
973
- } catch (err) {
974
- console.warn(`[open-everywhere:slack] bare-stop audit write failed: ${String(err)}`);
849
+ // OPE-645: REPLY PRINCIPAL snapshot — same T1.4 pattern as the supervisor target above, for the
850
+ // same reason: message.completed has no live auth ctx, so the canonical row it writes cannot
851
+ // derive WHO from ctx at that point. Since OPE-2006 that row is the DROP row, never a
852
+ // `reply.post`. authPrincipalId(ctx) is the exact read bindFrom(ctx).principalId falls back to
853
+ // for an ordinary human turn (identity/binding.ts) — snapshotting it here means the drop row
854
+ // names the same person the turn's egress.policy rows already do.
855
+ (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId = authPrincipalId(ctx);
856
+ // OPE-98: DM-SEAT per-wake anchor. A DM seat (OPE-90) is ONE per-channel eve session spanning
857
+ // many assistant threads, but eve freezes the session's reply target to the FIRST wake's
858
+ // thread (`state.threadTs`, seeded once at session birth — see slack-events.ts's
859
+ // dmWakeThreadOverride header). Snapshot THIS wake's thread from the delivery auth (rewritten
860
+ // per delivery ⇒ the waking message's thread) so the reply + thinking-status ride it. SCOPED
861
+ // to `im` DMs — every channel/supervisor/mpim turn leaves `oeWakeThreadTs` unset, so their
862
+ // targeting stays byte-identical.
863
+ {
864
+ const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
865
+ if (attrs?.channel_type === "im") {
866
+ (channel.state as unknown as Record<string, unknown>).oeWakeThreadTs =
867
+ typeof attrs.thread_ts === "string" ? attrs.thread_ts : undefined;
868
+ }
975
869
  }
976
- }
977
- // Warm auto-disengage: a WARM (non-participating) thread that keeps judging silence
978
- // is shed after WARM_SILENT_LIMIT turns, so a one-off name-drop doesn't leave the
979
- // bot quietly following forever. PARTICIPATING threads are exempt — they're sticky,
980
- // only an explicit stop disengages them — so a missing record OR participating:true
981
- // is a no-op here. Build the partition exactly like the post branch does; all
982
- // best-effort, engagement bookkeeping never throws out of the handler.
983
- try {
984
- const { channelId, teamId, threadTs } = channel.state;
985
- if (channelId && threadTs) {
986
- const p = resolvePartition({
987
- channel: { kind: "slack" },
988
- session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
989
- });
990
- const rec = await getThreadEngagement(p, threadTs);
991
- if (rec && rec.participating !== true) {
992
- const streak = await bumpThreadSilentStreak(p, threadTs);
993
- if (streak >= WARM_SILENT_LIMIT) {
994
- await disengageThread(p, threadTs);
995
- console.log(
996
- `[open-everywhere:slack] warm thread shed after ${streak} silent turns — disengaged (${channelId}/${threadTs})`,
997
- );
870
+ // eve's default turn.started body, minus its thread-status renderer (see header above).
871
+ // eve 0.71.0's default clears `pendingTaskResults` and `pendingToolCallMessage`, then shows
872
+ // "Thinking..." through its module-private `showStatus`, which records `state.threadStatus`.
873
+ // This channel keeps its own "Working..." typing call and its own reasoning throttle key
874
+ // below. Adoption of eve's persistent thread status is a separate decision (OPE-2289).
875
+ channel.state.pendingTaskResults = null;
876
+ channel.state.pendingToolCallMessage = null;
877
+ (channel.state as unknown as Record<string, unknown>).oeReasoningTypingAtMs = null;
878
+ await setWakeTyping(channel, "Working...");
879
+ },
880
+ // Keep the model's REASONING out of the channel. GLM-5.2 runs with reasoning:"medium",
881
+ // and Eve's default `reasoning.appended` handler pipes the chain-of-thought straight into
882
+ // the Slack typing-status line (startTyping(reasoningText)). That leaks internal thinking
883
+ // into the surface. We override it to refresh a GENERIC status instead — the indicator
884
+ // stays alive (so a long reasoning phase doesn't look stalled) without echoing the
885
+ // reasoning itself. Throttled to one refresh per 5 s to avoid spamming startTyping. eve 0.71.0
886
+ // dropped the state field this throttle used (`lastReasoningTypingAtMs`), so the timestamp
887
+ // lives on this channel's own `oeReasoningTypingAtMs` state key.
888
+ // The clean final answer leaves by the `reply` TOOL and by nothing else: this channel authors
889
+ // its own `message.completed`, and since OPE-2006 that handler DROPS the turn's closing prose
890
+ // and records it (`channels/closing-prose-drop.ts`). Re-delegating to eve's default poster here
891
+ // would re-open the road this ticket removed, on the surface OPE-771 measured the loss on.
892
+ "reasoning.appended": async (_data, channel) => {
893
+ const now = Date.now();
894
+ const state = channel.state as unknown as Record<string, unknown>;
895
+ const last = state.oeReasoningTypingAtMs;
896
+ if (typeof last === "number" && now - last >= 0 && now - last < 5000) return;
897
+ state.oeReasoningTypingAtMs = now;
898
+ await refreshTypingStatus(channel);
899
+ },
900
+ // 🔴 THE SAME FENCE, ON THE SECOND EVENT (OPE-2289). Through eve 0.70.0 the default
901
+ // `reasoning.completed` only cleared state. eve 0.71.0's default calls `showReasoning`: it takes
902
+ // the last `**heading**` or prose sentence of the reasoning, shows it as the typing status and
903
+ // records it in `state.threadStatus`, which the default `step.started` then shows again. So this
904
+ // channel must own this event too, with the same generic status. `slack-reasoning-fence-test.ts`
905
+ // drives both events through the exported adapter.
906
+ "reasoning.completed": async (_data, channel) => {
907
+ await refreshTypingStatus(channel);
908
+ },
909
+ // Eve's default falls back to "Running bash, read_channel..." for a tool-call
910
+ // step, which means nothing to non-engineers and advertises the toolbox to the whole
911
+ // channel — so we override with a generic status. We ALSO used to show the model's own
912
+ // preamble text here (its line before the tool call, written for humans, Tag-style) —
913
+ // dropped 2026-07 (Tommy, live): that text streams in mid-sentence, so the typing
914
+ // indicator would flash a truncated fragment ("I'll go away and look to setu…") for a
915
+ // moment before reverting, which read as broken. Always the clean status verb now,
916
+ // same as reasoning.appended.
917
+ "actions.requested": async (_data, channel) => {
918
+ await refreshTypingStatus(channel);
919
+ },
920
+ // Override the reply-posting handler for TWO additions: (1) a woken (non-mention) turn
921
+ // is told to judge whether a reply is warranted and to end with NO_REPLY when it isn't
922
+ // (slack-events.ts renderWakeMessage) — that sentinel must be swallowed, not posted;
923
+ // (2) a turn where a propose tool already posted a code-rendered approval card
924
+ // (approvals/flow.ts's requestApproval, signalled via approvals/card-signal.ts)
925
+ // suppresses the model's redundant text — the card already said it (Tommy, live: "the
926
+ // card should suffice"). Everything else mirrors Eve's default handler byte-for-byte
927
+ // (tool-calls is an intermediate step with no turn-final effects; empty final message
928
+ // posts nothing), via the pure, unit-tested finalMessageAction()/turnEffectsFor().
929
+ "message.completed": async (data, channel, ctx) => {
930
+ // The eve session id for this turn — stamped into the terminal audit rows below so an
931
+ // audited reply/silence/dropped-prose joins back to its transcript (control-read transcript).
932
+ const sessionId = ctx?.session?.id;
933
+ // OPE-1647: the TURN ID, so the stand-down silence mark (channels/stand-down-silence.ts) can
934
+ // prove it belongs to THIS turn. Same extraction idiom this file already uses for the §POSTED
935
+ // consume below.
936
+ // 🔴 SINCE OPE-2006 IT SERVES THE SILENCE DECISION ALONE. It is read once here and handed to
937
+ // `finalMessageActionForTurn`, whose `consumeStandDownSilenceThisTurn(turnId)` is its only
938
+ // reader on this path. There is no reply audit below to stamp it onto: this surface DROPS its
939
+ // closing prose, so the durable latch that guarded the old post has no door left to shut here.
940
+ const turnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
941
+ const action = finalMessageActionForTurn(data.finishReason, data.message, turnId);
942
+ if (action.kind === "buffer-tool-status") {
943
+ // An intermediate tool-call step, not the turn's end — nothing to decide yet. In
944
+ // particular: don't read the card-posted flag here, the tool this step is about to
945
+ // run may be the one that posts the card.
946
+ return;
947
+ }
948
+ // Read-and-reset whether an approval card already posted THIS turn — consumed once,
949
+ // right before the turn's terminal effects are decided, so the next turn always
950
+ // starts clean (mirrors how oeSilentTurn/oeUninvited below are REASSIGNED on every
951
+ // terminal call rather than cleared as a separate step).
952
+ const cardPostedThisTurn = consumeCardPostedThisTurn();
953
+ // Same read-and-reset contract, for the bare-stop check right below. A turn that
954
+ // reacted OR posted mid-turn via the reply-tool family (reply-signal.ts) acted, so
955
+ // both signals are consumed here and OR'd into the bare-stop input.
956
+ const reactedThisTurn = consumeReactedThisTurn();
957
+ // TURN-ID KEYED (OPE-805): a bare read-and-reset let a mark this turn never consumed answer a
958
+ // LATER turn's read — see reply-signal.ts's header.
959
+ const repliedThisTurn = consumeRepliedThisTurn((ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id);
960
+ if (action.kind === "silent") {
961
+ // Remember the turn ended silent so session.waiting doesn't stamp a ❓ status
962
+ // reaction on a conversation the agent chose NOT to join (live-observed: a
963
+ // judged-silent name-drop left a ❓ on the triggering message).
964
+ (channel.state as unknown as Record<string, unknown>).oeSilentTurn = true;
965
+ // Silence is designed to leave no trace ON THE SURFACE — but the operator
966
+ // needs to distinguish "judged NO_REPLY" from "never woke" (bit us live).
967
+ // action.reason distinguishes the sentinel route from the no_reply TOOL route
968
+ // (finalMessageActionForTurn folds both into "silent").
969
+ console.log(
970
+ `[open-everywhere:slack] judged silence (${action.reason}) — nothing posted (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`,
971
+ );
972
+ // ACTIONS-ONLY posture (audit-wedge build plan §1c, 2026-07-14): a judged silence on an
973
+ // ambient chat surface is NOT an audited action — an agent meant to reply to ~1% of channel
974
+ // messages would otherwise produce a table that is ~99% silence rows. "Why did it stay
975
+ // quiet?" is answered by the session transcript (GET /control/v1/session/:id/transcript),
976
+ // not the audit log. The console line above stays (operator-legible, free); the bare-stop
977
+ // row below stays (an ANOMALY — a judgment went missing — not a judged silence).
978
+ //
979
+ // ONE exception (OPE-890): the model ended the turn on the bare NO_REPLY sentinel — the
980
+ // TOOL route already carries an always-on `reply.no_reply_observed` row (tools/no_reply.ts,
981
+ // OPE-649); this is the sentinel route's twin, same action name, best-effort.
982
+ if (action.reason === NO_REPLY_SENTINEL_REASON) {
983
+ try {
984
+ const p = resolvePartition({
985
+ channel: { kind: "slack" },
986
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channel.state.channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
987
+ });
988
+ await auditNoReplySentinelObserved(p, sessionId);
989
+ } catch (err) {
990
+ console.warn(`[open-everywhere:slack] sentinel no_reply audit write failed: ${String(err)}`);
998
991
  }
999
992
  }
1000
- }
1001
- } catch (err) {
1002
- console.warn(`[open-everywhere:slack] warm silent-streak bookkeeping failed: ${String(err)}`);
1003
- }
1004
- try {
1005
- await setWakeTyping(channel);
1006
- } catch {
1007
- /* typing reset is best-effort */
1008
- }
1009
- return;
1010
- }
1011
- // action.kind === "post" here — the pure decision (unit-tested in slack-events.ts)
1012
- // gates ONLY whether the text itself posts; every other effect below is unconditional.
1013
- const effects = turnEffectsFor(action, cardPostedThisTurn);
1014
- if (effects.kind !== "reply") {
1015
- // Unreachable: turnEffectsFor only returns "silent" for a "silent" input action,
1016
- // and action.kind === "post" is established above. Kept as a narrowing guard.
1017
- return;
1018
- }
1019
- (channel.state as unknown as Record<string, unknown>).oeSilentTurn = false;
1020
- // --- SUPERVISOR host-side reply targeting (T1.4 / C1 workaround) ----------------
1021
- // A channel-supervisor session's `state.threadTs` is a NON-EMPTY sentinel (T1.3 /
1022
- // amendment A), so eve's default `channel.thread.post` (bound to state.threadTs) would
1023
- // post the answer INTO the sentinel pseudo-thread — a leak. The supervisor MUST post
1024
- // host-side via `slackForm` to the REAL per-turn target snapshotted at turn.started,
1025
- // and MUST NEVER call `channel.thread.post` (that path also re-anchors an empty binding).
1026
- // Gated strictly on `oeSupervisor` (isSupervisorReplyTurn) so non-supervisor turns fall
1027
- // through to the byte-identical default path below. NO_REPLY/no_reply/react/card are all
1028
- // upstream (the `silent` branch already returned; a supervisor NO_REPLY posts nothing,
1029
- // same as today). FAIL SAFE: no resolvable target ⇒ deliverSupervisorReply posts NOTHING.
1030
- //
1031
- // UNIFIED cross-surface note (formerly TODO(unified-reply) — the non-Slack legs are wired,
1032
- // 2026-07-11): this leg is Slack-specific (posts host-side via slackForm to a Slack target)
1033
- // and that is CORRECT — each surface's own completion handler fires for its own deliveries
1034
- // (eve namespaces continuation tokens per channel, so a GitHub/Linear/Notion wake completes
1035
- // on THAT channel's message.completed, which posts via its own arm to its own per-wake
1036
- // snapshot: github.ts / linear.ts / notion.ts supervisor branches, all behind the same
1037
- // message-tool-only gate). See unified-supervisor.ts's cross-surface-reply header for the
1038
- // whole model + the per-platform session-namespacing caveat pending a live run.
1039
- if (isSupervisorReplyTurn(channel.state)) {
1040
- const target = (channel.state as unknown as Record<string, unknown>).oeReplyTarget as SupervisorReplyTarget | undefined;
1041
- // The channel/org is always known (the supervisor owns one channel) — build its
1042
- // partition from the session state, independent of whether a reply target resolved.
1043
- const channelId = channel.state.channelId ?? undefined;
1044
- const teamId = channel.state.teamId;
1045
- const partition = channelId
1046
- ? resolvePartition({
1047
- channel: { kind: "slack" },
1048
- session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1049
- })
1050
- : undefined;
1051
- // --- MESSAGE-TOOL-ONLY hard-silence gate (OpenClaw steal, slack-events.ts) -----------
1052
- // A supervisor's terminal completion text is NOT a reply: a visible reply must come from
1053
- // the `reply` tool (which posted mid-turn via reply-post.ts + marked repliedThisTurn).
1054
- // Force-drop the terminal prose so a judge that emits stray narration leaks NOTHING to the
1055
- // watched channel; binding below is unaffected (deliverSupervisorReply binds regardless of
1056
- // postText). Fail-open only where the surface has no reply tool (never true for Slack).
1057
- const delivery = supervisorTextDelivery(true /* action.kind==="post" ⇒ non-empty prose */, capabilitiesForPlatform("slack").midTurnPost);
1058
- if (delivery.droppedProse && isSupervisorProseLeak(true, repliedThisTurn, cardPostedThisTurn)) {
1059
- // The load-bearing leak case: the judge produced terminal prose but took NO positive
1060
- // action to post it (no `reply`, no card). Drop it (never post), but make the silence
1061
- // auditable — mirrors OpenClaw's suppressed-delivery metadata (`didSendViaMessagingTool:
1062
- // false`) so "why did the judge go quiet on prose it wrote" always has an answer.
1063
- console.warn(
1064
- `[open-everywhere:slack] supervisor message-tool-only: DROPPED terminal prose (judge emitted text without calling reply) — nothing posted (${channelId ?? "?"}/${target?.threadTs ?? "top-level"})`,
1065
- );
1066
- if (partition) {
993
+ // Bare-stop enforcement (feature 2's investigation — see slack-events.ts's
994
+ // isBareStop header for why this is DETECT-AND-LOG, not a hard gate): a turn
995
+ // that went silent with NO explanation (no sentinel, no no_reply tool) and
996
+ // NO react either did, as far as anyone can tell, nothing at all. Loud warn
997
+ // + a best-effort audit row; never a post — silence stays silence.
998
+ if (isBareStop(action, reactedThisTurn || repliedThisTurn)) {
999
+ const channelId = channel.state.channelId ?? undefined;
1000
+ const threadTs = channel.state.threadTs ?? undefined;
1001
+ console.warn(
1002
+ `[open-everywhere:slack] BARE STOP — turn ended with no reply, no react, and no no_reply ` +
1003
+ `(${channelId ?? "?"}/${threadTs ?? "?"}) — this usually means a judgment call went missing, not that one was made.`,
1004
+ );
1005
+ try {
1006
+ const p = resolvePartition({
1007
+ channel: { kind: "slack" },
1008
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId ?? "" }, principalId: "", principalType: "slack-user" } } },
1009
+ });
1010
+ await getOperationalStore().appendAudit({
1011
+ ts: new Date().toISOString(),
1012
+ org: p.org,
1013
+ platform: p.platform,
1014
+ workspace: p.workspace,
1015
+ surface: p.surface,
1016
+ action: "turn.bare_stop",
1017
+ detail: { threadTs, ...(sessionId ? { sessionId } : {}) },
1018
+ });
1019
+ } catch (err) {
1020
+ console.warn(`[open-everywhere:slack] bare-stop audit write failed: ${String(err)}`);
1021
+ }
1022
+ }
1023
+ // Warm auto-disengage: a WARM (non-participating) thread that keeps judging silence
1024
+ // is shed after WARM_SILENT_LIMIT turns, so a one-off name-drop doesn't leave the
1025
+ // bot quietly following forever. PARTICIPATING threads are exempt — they're sticky,
1026
+ // only an explicit stop disengages them — so a missing record OR participating:true
1027
+ // is a no-op here. Build the partition exactly like the post branch does; all
1028
+ // best-effort, engagement bookkeeping never throws out of the handler.
1067
1029
  try {
1068
- await getOperationalStore().appendAudit({
1069
- ts: new Date().toISOString(),
1070
- org: partition.org,
1071
- platform: partition.platform,
1072
- workspace: partition.workspace,
1073
- surface: partition.surface,
1074
- action: "turn.supervisor_prose_dropped",
1075
- detail: { threadTs: target?.threadTs, chars: action.text.length, ...(sessionId ? { sessionId } : {}) },
1076
- });
1030
+ const { channelId, teamId, threadTs } = channel.state;
1031
+ if (channelId && threadTs) {
1032
+ const p = resolvePartition({
1033
+ channel: { kind: "slack" },
1034
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1035
+ });
1036
+ const rec = await getThreadEngagement(p, threadTs);
1037
+ if (rec && rec.participating !== true) {
1038
+ const streak = await bumpThreadSilentStreak(p, threadTs);
1039
+ if (streak >= WARM_SILENT_LIMIT) {
1040
+ await disengageThread(p, threadTs);
1041
+ console.log(
1042
+ `[open-everywhere:slack] warm thread shed after ${streak} silent turns — disengaged (${channelId}/${threadTs})`,
1043
+ );
1044
+ }
1045
+ }
1046
+ }
1077
1047
  } catch (err) {
1078
- console.warn(`[open-everywhere:slack] supervisor prose-dropped audit write failed: ${String(err)}`);
1048
+ console.warn(`[open-everywhere:slack] warm silent-streak bookkeeping failed: ${String(err)}`);
1079
1049
  }
1050
+ try {
1051
+ await setWakeTyping(channel);
1052
+ } catch {
1053
+ /* typing reset is best-effort */
1054
+ }
1055
+ return;
1080
1056
  }
1081
- }
1082
- await deliverSupervisorReply({
1083
- postText: delivery.postText,
1084
- text: action.text,
1085
- target,
1086
- async post(cid, tts, text) {
1087
- // Idempotency (operational/post-once.ts): this raw supervisor-reply post bypasses
1088
- // eve's durable reply primitive, so a `deliver`-step re-run after it lands would
1089
- // double-post. postOnce (org-partitioned, short TTL) skips the resume duplicate. No
1090
- // stableId — a deliver hook has no eve callId — so the key is channel+thread+text.
1091
- // Fail-open (incl. an unresolved partition ⇒ undefined org): still posts.
1092
- await postOnce({
1093
- org: partition?.org,
1094
- key: outboundKey({ surface: "slack", channel: cid, threadTs: tts, action: "slack.supervisor-reply", payload: text }),
1095
- run: async () => {
1096
- await slackForm("chat.postMessage", supervisorPostFormBody(cid, tts, text));
1057
+ // action.kind === "post" here — the pure decision (unit-tested in slack-events.ts)
1058
+ // gates ONLY whether the text itself posts; every other effect below is unconditional.
1059
+ const effects = turnEffectsFor(action, cardPostedThisTurn);
1060
+ if (effects.kind !== "reply") {
1061
+ // Unreachable: turnEffectsFor only returns "silent" for a "silent" input action,
1062
+ // and action.kind === "post" is established above. Kept as a narrowing guard.
1063
+ return;
1064
+ }
1065
+ (channel.state as unknown as Record<string, unknown>).oeSilentTurn = false;
1066
+ // --- SUPERVISOR host-side reply targeting (T1.4 / C1 workaround) ----------------
1067
+ // A channel-supervisor session's `state.threadTs` is a NON-EMPTY sentinel (T1.3 /
1068
+ // amendment A), so eve's default `channel.thread.post` (bound to state.threadTs) would
1069
+ // post the answer INTO the sentinel pseudo-thread — a leak. The supervisor MUST post
1070
+ // host-side via `slackForm` to the REAL per-turn target snapshotted at turn.started,
1071
+ // and MUST NEVER call `channel.thread.post` (that path also re-anchors an empty binding).
1072
+ // Gated strictly on `oeSupervisor` (isSupervisorReplyTurn) so non-supervisor turns fall
1073
+ // through to the byte-identical default path below. NO_REPLY/no_reply/react/card are all
1074
+ // upstream (the `silent` branch already returned; a supervisor NO_REPLY posts nothing,
1075
+ // same as today). FAIL SAFE: no resolvable target ⇒ deliverSupervisorReply posts NOTHING.
1076
+ //
1077
+ // UNIFIED cross-surface note (formerly TODO(unified-reply) — the non-Slack legs are wired,
1078
+ // 2026-07-11): this leg is Slack-specific (posts host-side via slackForm to a Slack target)
1079
+ // and that is CORRECT — each surface's own completion handler fires for its own deliveries
1080
+ // (eve namespaces continuation tokens per channel, so a GitHub/Linear/Notion wake completes
1081
+ // on THAT channel's message.completed, which posts via its own arm to its own per-wake
1082
+ // snapshot: github.ts / linear.ts / notion.ts supervisor branches, all behind the same
1083
+ // message-tool-only gate). See unified-supervisor.ts's cross-surface-reply header for the
1084
+ // whole model + the per-platform session-namespacing caveat pending a live run.
1085
+ if (isSupervisorReplyTurn(channel.state)) {
1086
+ const target = (channel.state as unknown as Record<string, unknown>).oeReplyTarget as SupervisorReplyTarget | undefined;
1087
+ // The channel/org is always known (the supervisor owns one channel) — build its
1088
+ // partition from the session state, independent of whether a reply target resolved.
1089
+ const channelId = channel.state.channelId ?? undefined;
1090
+ const teamId = channel.state.teamId;
1091
+ const partition = channelId
1092
+ ? resolvePartition({
1093
+ channel: { kind: "slack" },
1094
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1095
+ })
1096
+ : undefined;
1097
+ // --- MESSAGE-TOOL-ONLY hard-silence gate (OpenClaw steal, slack-events.ts) -----------
1098
+ // A supervisor's terminal completion text is NOT a reply: a visible reply must come from
1099
+ // the `reply` tool (which posted mid-turn via reply-post.ts + marked repliedThisTurn).
1100
+ // Force-drop the terminal prose so a judge that emits stray narration leaks NOTHING to the
1101
+ // watched channel; binding below is unaffected (deliverSupervisorReply binds regardless of
1102
+ // postText). Fail-open only where the surface has no reply tool (never true for Slack).
1103
+ const delivery = supervisorTextDelivery(true /* action.kind==="post" ⇒ non-empty prose */, capabilitiesForPlatform("slack").midTurnPost);
1104
+ if (delivery.droppedProse && isSupervisorProseLeak(true, repliedThisTurn, cardPostedThisTurn)) {
1105
+ // The load-bearing leak case: the judge produced terminal prose but took NO positive
1106
+ // action to post it (no `reply`, no card). Drop it (never post), but make the silence
1107
+ // auditable — mirrors OpenClaw's suppressed-delivery metadata (`didSendViaMessagingTool:
1108
+ // false`) so "why did the judge go quiet on prose it wrote" always has an answer.
1109
+ console.warn(
1110
+ `[open-everywhere:slack] supervisor message-tool-only: DROPPED terminal prose (judge emitted text without calling reply) — nothing posted (${channelId ?? "?"}/${target?.threadTs ?? "top-level"})`,
1111
+ );
1112
+ if (partition) {
1113
+ try {
1114
+ await getOperationalStore().appendAudit({
1115
+ ts: new Date().toISOString(),
1116
+ org: partition.org,
1117
+ platform: partition.platform,
1118
+ workspace: partition.workspace,
1119
+ surface: partition.surface,
1120
+ action: "turn.supervisor_prose_dropped",
1121
+ detail: { threadTs: target?.threadTs, chars: action.text.length, ...(sessionId ? { sessionId } : {}) },
1122
+ });
1123
+ } catch (err) {
1124
+ console.warn(`[open-everywhere:slack] supervisor prose-dropped audit write failed: ${String(err)}`);
1125
+ }
1126
+ }
1127
+ }
1128
+ await deliverSupervisorReply({
1129
+ postText: delivery.postText,
1130
+ text: action.text,
1131
+ target,
1132
+ async post(cid, tts, text) {
1133
+ // Idempotency (operational/post-once.ts): this raw supervisor-reply post bypasses
1134
+ // eve's durable reply primitive, so a `deliver`-step re-run after it lands would
1135
+ // double-post. postOnce (org-partitioned, short TTL) skips the resume duplicate. No
1136
+ // stableId — a deliver hook has no eve callId — so the key is channel+thread+text.
1137
+ // Fail-open (incl. an unresolved partition ⇒ undefined org): still posts.
1138
+ await postOnce({
1139
+ org: partition?.org,
1140
+ key: outboundKey({ surface: "slack", channel: cid, threadTs: tts, action: "slack.supervisor-reply", payload: text }),
1141
+ run: async () => {
1142
+ await slackForm("chat.postMessage", supervisorPostFormBody(cid, tts, text));
1143
+ },
1144
+ });
1145
+ },
1146
+ async resolveOwnerToken() {
1147
+ if (!partition) return undefined;
1148
+ try {
1149
+ return (await getOperationalStore().getChannelSession(partition.org, partition.surfaceKey))?.token;
1150
+ } catch {
1151
+ return undefined;
1152
+ }
1153
+ },
1154
+ async bind(tts, ownerToken) {
1155
+ if (!partition) return;
1156
+ // ownerToken = the supervisor's registry token → the thread is now owned by the
1157
+ // supervisor session (Tag: "the channel session answers a cheap thread and now
1158
+ // owns it"). markThreadEngaged is best-effort (never fails a delivered reply).
1159
+ // `byOwnPost` for the same reason as the delivery promotion below (OPE-1669): the
1160
+ // trigger here is the agent's own delivered reply, never a person inviting it back, so
1161
+ // it must not lift a stand-down. The ownership bind itself is unaffected.
1162
+ await markThreadEngaged(partition, tts, new Date().toISOString(), { participating: true, ownerToken, byOwnPost: true });
1097
1163
  },
1098
1164
  });
1099
- },
1100
- async resolveOwnerToken() {
1101
- if (!partition) return undefined;
1102
- try {
1103
- return (await getOperationalStore().getChannelSession(partition.org, partition.surfaceKey))?.token;
1104
- } catch {
1105
- return undefined;
1165
+ // Clear the uninvited flag only on ACTUAL participation — the supervisor replied via the
1166
+ // `reply` tool, or a card narrated for it. Under message-tool-only the terminal text is
1167
+ // dropped, so stray prose is NOT participation and must not promote the session (a judge
1168
+ // that leaked prose it never posted stays uninvited, so its later errors keep quiet).
1169
+ if (repliedThisTurn || cardPostedThisTurn) {
1170
+ (channel.state as unknown as Record<string, unknown>).oeUninvited = false;
1106
1171
  }
1107
- },
1108
- async bind(tts, ownerToken) {
1109
- if (!partition) return;
1110
- // ownerToken = the supervisor's registry token → the thread is now owned by the
1111
- // supervisor session (Tag: "the channel session answers a cheap thread and now
1112
- // owns it"). markThreadEngaged is best-effort (never fails a delivered reply).
1113
- // `byOwnPost` for the same reason as the delivery promotion below (OPE-1669): the
1114
- // trigger here is the agent's own delivered reply, never a person inviting it back, so
1115
- // it must not lift a stand-down. The ownership bind itself is unaffected.
1116
- await markThreadEngaged(partition, tts, new Date().toISOString(), { participating: true, ownerToken, byOwnPost: true });
1117
- },
1118
- });
1119
- // Clear the uninvited flag only on ACTUAL participation — the supervisor replied via the
1120
- // `reply` tool, or a card narrated for it. Under message-tool-only the terminal text is
1121
- // dropped, so stray prose is NOT participation and must not promote the session (a judge
1122
- // that leaked prose it never posted stays uninvited, so its later errors keep quiet).
1123
- if (repliedThisTurn || cardPostedThisTurn) {
1124
- (channel.state as unknown as Record<string, unknown>).oeUninvited = false;
1125
- }
1126
- return; // NEVER fall through to channel.thread.post — that would post into the sentinel.
1127
- }
1128
- if (effects.postText) {
1129
- // 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
1130
- // engaged-thread terminal reply through `postTerminalProse`, with a raw `channel.thread.post`
1131
- // fallback for a state that named no channel. Both are gone: the `reply` tool is the only
1132
- // channel a seat's words leave by, so a turn that ended in prose and never called `reply`
1133
- // has said nothing here. The row is the record — no re-send, no queue, no host-side re-post.
1134
- // WHAT TELLS THE MODEL is the seat prompt, not this handler: Slack declares
1135
- // `closingProseDropped`, so a conversational seat here reads the `reply-only` delivery arm,
1136
- // whose copy states that the turn-final text is discarded. It reaches a supervisor-shaped
1137
- // seat inside its persona and a `native`-grain @mention thread — the seat this branch mostly
1138
- // serves — through the `delivery` slot (instructions/access.ts `renderDeliverySlot`).
1139
- const channelId = channel.state.channelId ?? undefined;
1140
- const replyThreadTs = replyThreadTsForState(channel.state);
1141
- if (channelId) {
1142
- const p = resolvePartition({
1143
- channel: { kind: "slack" },
1144
- session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1145
- });
1146
- await dropClosingProse({
1147
- row: {
1148
- org: p.org,
1149
- platform: p.platform,
1150
- workspace: p.workspace,
1151
- surface: p.surface,
1152
- principalId: (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId as string | undefined,
1153
- },
1154
- chars: action.text.length,
1155
- ...(sessionId ? { sessionId } : {}),
1156
- // OPE-98: the PER-WAKE thread (dmWakeThreadOverride ?? state.threadTs) — the anchor the
1157
- // post would have used, so the row names the conversation the answer was owed to.
1158
- anchor: { threadTs: replyThreadTs ?? null },
1159
- log: (m) => console.warn(`[open-everywhere:slack] ${m} (${channelId}/${replyThreadTs ?? "?"})`),
1160
- });
1161
- } else {
1162
- // 🔴 NO CHANNEL ID, SO NO PARTITION TO FILE IT UNDER, SO NO ROW. `resolvePartition` answers
1163
- // `surface: "default"` for a blank channel, and a lost answer filed against a surface that
1164
- // names no room pollutes the drop count for everything else that resolves to `default`.
1165
- // The drop still happens and the log line is the whole record — the same honest degradation
1166
- // the three sibling handlers take, and the same one the deleted poster's fallback took.
1167
- console.warn(
1168
- `[open-everywhere:slack] closing prose DROPPED (${action.text.length}c) — nothing posted, and no partition to audit it under (?/${replyThreadTs ?? "?"})`,
1169
- );
1170
- }
1171
- } else {
1172
- // A card already posted this turn — suppress the redundant narration, but this is
1173
- // STILL a participating reply for everything below (👀 clears, thread engages,
1174
- // oeUninvited clears), same as an ordinary post.
1175
- console.log(
1176
- `[open-everywhere:slack] approval card already posted this turn — suppressing the redundant text reply (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`,
1177
- );
1178
- }
1179
- // DID THIS TURN ACTUALLY DELIVER ANYTHING? Since OPE-2006 the closing prose is dropped, so
1180
- // reaching this point no longer means a message left. Only a `reply` tool call or an approval
1181
- // card put bytes in the room, and the two promotions below are what a delivery earns.
1182
- // 🔴 IT IS THE RULE THE SUPERVISOR BRANCH ABOVE ALREADY STATES — "stray prose is NOT
1183
- // participation and must not promote the session" — applied to the seat that used to be the
1184
- // exception because its prose posted. A turn whose only output was dropped prose stays
1185
- // uninvited and leaves the thread's engagement exactly as it found it.
1186
- const deliveredThisTurn = repliedThisTurn || cardPostedThisTurn;
1187
- // The agent is now a PARTICIPANT: an uninvited (name-drop) session that posts
1188
- // earns normal standing, so later failures go loud like any engaged thread's.
1189
- if (deliveredThisTurn) (channel.state as unknown as Record<string, unknown>).oeUninvited = false;
1190
- // Remove the 👀 WITH the reply, not at park: session.waiting fires a workflow
1191
- // step later and can lag the post by many seconds (live-observed), leaving a
1192
- // stale "working" mark on an already-answered message. The waiting/completed
1193
- // removals below stay as the idempotent safety net (e.g. for turns whose final
1194
- // delivery goes through a path that skips this handler).
1195
- await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1196
- // A posted reply = the agent is participating in this thread → follow it. For
1197
- // mention threads this is an idempotent refresh (onInbound already engaged); for a
1198
- // name-drop reply it's what ESTABLISHES engagement, so follow-ups flow without a
1199
- // mention. Best-effort — engagement must never fail a delivered reply.
1200
- try {
1201
- const { channelId, teamId, threadTs } = channel.state;
1202
- if (deliveredThisTurn && channelId && threadTs) {
1203
- const p = resolvePartition({
1204
- channel: { kind: "slack" },
1205
- session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1206
- });
1207
- // A posted reply PROMOTES the thread: participating:true so a warm (name-drop)
1208
- // thread becomes an invited participant (sticky, streak cleared, failures loud).
1209
- // 🔴 `byOwnPost` IS WHAT KEEPS A STAND-DOWN STOOD DOWN (OPE-1669). This promotion carries
1210
- // no promoter id, so without the flag it CLEARS the durable `stopped` marker — and the
1211
- // acknowledgement `stand_down` asks for travels by the agent's own `reply`, so it reaches
1212
- // this promotion and would lift the very stop it was acknowledging. See
1213
- // channels/engagement.ts.
1214
- await markThreadEngaged(p, threadTs, new Date().toISOString(), { participating: true, byOwnPost: true });
1215
- }
1216
- } catch {
1217
- /* best-effort */
1218
- }
1219
- },
1220
- // Safe to own (no built-in default): turn's over → REMOVE the 👀 (absence = done;
1221
- // the reply is the result). Silent turns skip entirely — no trace, no API calls, on
1222
- // a conversation the agent declined to join.
1223
- "session.completed": async (_data, channel) => {
1224
- if ((channel.state as unknown as Record<string, unknown>).oeSilentTurn !== true) {
1225
- await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1226
- }
1227
- await syncPinnedStatus(channel);
1228
- },
1229
- // Parked waiting for the next human message — same removal: a parked session is the
1230
- // steady state of a durable thread, not a "needs input" condition worth a ❓.
1231
- "session.waiting": async (_data, channel) => {
1232
- if ((channel.state as unknown as Record<string, unknown>).oeSilentTurn === true) return;
1233
- await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1234
- },
1235
- // Eve's default posts "I hit an error while handling your request…" into the
1236
- // thread. Keep that for INVITED turns (failures must stay visible to whoever asked)
1237
- // but suppress it for uninvited ones — a name-drop turn that would have judged
1238
- // NO_REPLY must not spam the conversation with an error instead (log + audit only).
1239
- "turn.failed": async (data, channel) => {
1240
- if (isUninvited(channel.state)) {
1241
- await recordQuietFailure(channel.state, "turn.failed", data.message);
1242
- return;
1243
- }
1244
- await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1245
- try {
1246
- await postWakeText(
1247
- channel,
1248
- `:octagonal_sign: I hit an error while handling your request (${data.message}). Please try again or rephrase.`,
1249
- );
1250
- } catch {
1251
- /* best-effort */
1252
- }
1253
- },
1254
- // Override carefully: 🛑 blocked + re-post the error the default would have,
1255
- // so failures stay visible. Same uninvited-turn suppression as turn.failed.
1256
- "session.failed": async (data, channel) => {
1257
- if (isUninvited(channel.state)) {
1258
- await recordQuietFailure(channel.state, "session.failed", data.message);
1259
- return;
1260
- }
1261
- await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1262
- try {
1263
- await postWakeText(channel, `:octagonal_sign: Task failed: ${data.message}`);
1264
- } catch {
1265
- /* best-effort */
1266
- }
1267
- await syncPinnedStatus(channel);
1172
+ return; // NEVER fall through to channel.thread.post — that would post into the sentinel.
1173
+ }
1174
+ if (effects.postText) {
1175
+ // 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
1176
+ // engaged-thread terminal reply through `postTerminalProse`, with a raw `channel.thread.post`
1177
+ // fallback for a state that named no channel. Both are gone: the `reply` tool is the only
1178
+ // channel a seat's words leave by, so a turn that ended in prose and never called `reply`
1179
+ // has said nothing here. The row is the record — no re-send, no queue, no host-side re-post.
1180
+ // WHAT TELLS THE MODEL is the seat prompt, not this handler: Slack declares
1181
+ // `closingProseDropped`, so a conversational seat here reads the `reply-only` delivery arm,
1182
+ // whose copy states that the turn-final text is discarded. It reaches a supervisor-shaped
1183
+ // seat inside its persona and a `native`-grain @mention thread — the seat this branch mostly
1184
+ // serves — through the `delivery` slot (instructions/access.ts `renderDeliverySlot`).
1185
+ const channelId = channel.state.channelId ?? undefined;
1186
+ const replyThreadTs = replyThreadTsForState(channel.state);
1187
+ if (channelId) {
1188
+ const p = resolvePartition({
1189
+ channel: { kind: "slack" },
1190
+ session: { auth: { current: { attributes: { team_id: channel.state.teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1191
+ });
1192
+ await dropClosingProse({
1193
+ row: {
1194
+ org: p.org,
1195
+ platform: p.platform,
1196
+ workspace: p.workspace,
1197
+ surface: p.surface,
1198
+ principalId: (channel.state as unknown as Record<string, unknown>).oeReplyPrincipalId as string | undefined,
1199
+ },
1200
+ chars: action.text.length,
1201
+ ...(sessionId ? { sessionId } : {}),
1202
+ // OPE-98: the PER-WAKE thread (dmWakeThreadOverride ?? state.threadTs) — the anchor the
1203
+ // post would have used, so the row names the conversation the answer was owed to.
1204
+ anchor: { threadTs: replyThreadTs ?? null },
1205
+ log: (m) => console.warn(`[open-everywhere:slack] ${m} (${channelId}/${replyThreadTs ?? "?"})`),
1206
+ });
1207
+ } else {
1208
+ // 🔴 NO CHANNEL ID, SO NO PARTITION TO FILE IT UNDER, SO NO ROW. `resolvePartition` answers
1209
+ // `surface: "default"` for a blank channel, and a lost answer filed against a surface that
1210
+ // names no room pollutes the drop count for everything else that resolves to `default`.
1211
+ // The drop still happens and the log line is the whole record — the same honest degradation
1212
+ // the three sibling handlers take, and the same one the deleted poster's fallback took.
1213
+ console.warn(
1214
+ `[open-everywhere:slack] closing prose DROPPED (${action.text.length}c) — nothing posted, and no partition to audit it under (?/${replyThreadTs ?? "?"})`,
1215
+ );
1216
+ }
1217
+ } else {
1218
+ // A card already posted this turn — suppress the redundant narration, but this is
1219
+ // STILL a participating reply for everything below (👀 clears, thread engages,
1220
+ // oeUninvited clears), same as an ordinary post.
1221
+ console.log(
1222
+ `[open-everywhere:slack] approval card already posted this turn — suppressing the redundant text reply (${channel.state.channelId ?? "?"}/${channel.state.threadTs ?? "?"})`,
1223
+ );
1224
+ }
1225
+ // DID THIS TURN ACTUALLY DELIVER ANYTHING? Since OPE-2006 the closing prose is dropped, so
1226
+ // reaching this point no longer means a message left. Only a `reply` tool call or an approval
1227
+ // card put bytes in the room, and the two promotions below are what a delivery earns.
1228
+ // 🔴 IT IS THE RULE THE SUPERVISOR BRANCH ABOVE ALREADY STATES — "stray prose is NOT
1229
+ // participation and must not promote the session" — applied to the seat that used to be the
1230
+ // exception because its prose posted. A turn whose only output was dropped prose stays
1231
+ // uninvited and leaves the thread's engagement exactly as it found it.
1232
+ const deliveredThisTurn = repliedThisTurn || cardPostedThisTurn;
1233
+ // The agent is now a PARTICIPANT: an uninvited (name-drop) session that posts
1234
+ // earns normal standing, so later failures go loud like any engaged thread's.
1235
+ if (deliveredThisTurn) (channel.state as unknown as Record<string, unknown>).oeUninvited = false;
1236
+ // Remove the 👀 WITH the reply, not at park: session.waiting fires a workflow
1237
+ // step later and can lag the post by many seconds (live-observed), leaving a
1238
+ // stale "working" mark on an already-answered message. The waiting/completed
1239
+ // removals below stay as the idempotent safety net (e.g. for turns whose final
1240
+ // delivery goes through a path that skips this handler).
1241
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1242
+ // A posted reply = the agent is participating in this thread → follow it. For
1243
+ // mention threads this is an idempotent refresh (onInbound already engaged); for a
1244
+ // name-drop reply it's what ESTABLISHES engagement, so follow-ups flow without a
1245
+ // mention. Best-effort — engagement must never fail a delivered reply.
1246
+ try {
1247
+ const { channelId, teamId, threadTs } = channel.state;
1248
+ if (deliveredThisTurn && channelId && threadTs) {
1249
+ const p = resolvePartition({
1250
+ channel: { kind: "slack" },
1251
+ session: { auth: { current: { attributes: { team_id: teamId ?? "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
1252
+ });
1253
+ // A posted reply PROMOTES the thread: participating:true so a warm (name-drop)
1254
+ // thread becomes an invited participant (sticky, streak cleared, failures loud).
1255
+ // 🔴 `byOwnPost` IS WHAT KEEPS A STAND-DOWN STOOD DOWN (OPE-1669). This promotion carries
1256
+ // no promoter id, so without the flag it CLEARS the durable `stopped` marker — and the
1257
+ // acknowledgement `stand_down` asks for travels by the agent's own `reply`, so it reaches
1258
+ // this promotion and would lift the very stop it was acknowledging. See
1259
+ // channels/engagement.ts.
1260
+ await markThreadEngaged(p, threadTs, new Date().toISOString(), { participating: true, byOwnPost: true });
1261
+ }
1262
+ } catch {
1263
+ /* best-effort */
1264
+ }
1265
+ },
1266
+ // Safe to own (no built-in default): turn's over → REMOVE the 👀 (absence = done;
1267
+ // the reply is the result). Silent turns skip entirely — no trace, no API calls, on
1268
+ // a conversation the agent declined to join.
1269
+ "session.completed": async (_data, channel) => {
1270
+ if ((channel.state as unknown as Record<string, unknown>).oeSilentTurn !== true) {
1271
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1272
+ }
1273
+ await syncPinnedStatus(channel);
1274
+ },
1275
+ // Parked waiting for the next human message — same removal: a parked session is the
1276
+ // steady state of a durable thread, not a "needs input" condition worth a ❓.
1277
+ "session.waiting": async (_data, channel) => {
1278
+ if ((channel.state as unknown as Record<string, unknown>).oeSilentTurn === true) return;
1279
+ await setStatusReaction(channel.slack, "none", dmWakeThreadOverride(channel.state));
1280
+ },
1281
+ // Eve's default posts "I hit an error while handling your request…" into the
1282
+ // thread. Keep that for INVITED turns (failures must stay visible to whoever asked)
1283
+ // but suppress it for uninvited ones — a name-drop turn that would have judged
1284
+ // NO_REPLY must not spam the conversation with an error instead (log + audit only).
1285
+ "turn.failed": async (data, channel) => {
1286
+ if (isUninvited(channel.state)) {
1287
+ await recordQuietFailure(channel.state, "turn.failed", data.message);
1288
+ return;
1289
+ }
1290
+ await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1291
+ try {
1292
+ await postWakeText(
1293
+ channel,
1294
+ `:octagonal_sign: I hit an error while handling your request (${data.message}). Please try again or rephrase.`,
1295
+ );
1296
+ } catch {
1297
+ /* best-effort */
1298
+ }
1299
+ },
1300
+ // Override carefully: 🛑 blocked + re-post the error the default would have,
1301
+ // so failures stay visible. Same uninvited-turn suppression as turn.failed.
1302
+ "session.failed": async (data, channel) => {
1303
+ if (isUninvited(channel.state)) {
1304
+ await recordQuietFailure(channel.state, "session.failed", data.message);
1305
+ return;
1306
+ }
1307
+ await setStatusReaction(channel.slack, "blocked", dmWakeThreadOverride(channel.state));
1308
+ try {
1309
+ await postWakeText(channel, `:octagonal_sign: Task failed: ${data.message}`);
1310
+ } catch {
1311
+ /* best-effort */
1312
+ }
1313
+ await syncPinnedStatus(channel);
1314
+ },
1315
+ },
1268
1316
  },
1269
- },
1317
+ ],
1270
1318
  });
1271
1319
 
1272
1320
  // Front the public Slack URL with the engagement route so plain (non-mention) thread replies
1273
1321
  // wake the engaged thread's session, while everything slackChannel owns is forwarded through.
1274
- export default frontSlackChannel(slackMentionChannel);
1322
+ // OPE-2252 — `reply`/`no_reply` end the turn natively (`endsTurn`) and eve then emits no final message,
1323
+ // so the wrapper decides such a turn at `turn.completed` (core `channels/ends-turn-final.ts`).
1324
+ export default withEndsTurnFinalChannel(frontSlackChannel(slackMentionChannel));