@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.
- package/NOTICE +9 -9
- package/README.md +1 -1
- package/agent/app-manifest.ts +44 -9
- package/agent/channels/slack-commands.ts +9 -5
- package/agent/channels/slack-events.ts +76 -13
- package/agent/channels/slack-front.ts +4 -80
- package/agent/channels/slack-post-arm.ts +1 -42
- package/agent/channels/slack-task-card.ts +182 -0
- package/agent/channels/slack.ts +518 -468
- package/agent/contributions.ts +1 -1
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +20 -2
- package/agent/setup/claim.ts +16 -1
- package/agent/setup/eve-credentials.ts +10 -2
- package/agent/setup/page.ts +79 -14
- package/agent/surface-parity.ts +0 -6
- package/dist/agent/app-manifest.d.ts +20 -4
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +29 -6
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +9 -6
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +28 -6
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +74 -14
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-front.d.ts +1 -15
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +4 -62
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-post-arm.d.ts +1 -8
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
- package/dist/agent/channels/slack-post-arm.js +1 -40
- package/dist/agent/channels/slack-post-arm.js.map +1 -1
- package/dist/agent/channels/slack-task-card.d.ts +38 -0
- package/dist/agent/channels/slack-task-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-task-card.js +156 -0
- package/dist/agent/channels/slack-task-card.js.map +1 -0
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +516 -466
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/contributions.d.ts +1 -1
- package/dist/agent/contributions.d.ts.map +1 -1
- package/dist/agent/contributions.js +1 -1
- package/dist/agent/contributions.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +5 -0
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +13 -2
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/setup/claim.d.ts.map +1 -1
- package/dist/agent/setup/claim.js +15 -2
- package/dist/agent/setup/claim.js.map +1 -1
- package/dist/agent/setup/eve-credentials.d.ts +9 -2
- package/dist/agent/setup/eve-credentials.d.ts.map +1 -1
- package/dist/agent/setup/eve-credentials.js +9 -1
- package/dist/agent/setup/eve-credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts +2 -0
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +71 -15
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +0 -6
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +10 -8
- package/agent/channels/slack-approval-card.ts +0 -275
package/agent/channels/slack.ts
CHANGED
|
@@ -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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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]
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
await
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
}
|
|
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
|
-
|
|
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));
|