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