@velanir/openclaw-participation-gate 0.1.2 → 0.2.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/README.md CHANGED
@@ -8,9 +8,11 @@ The plugin is designed for the `requireMention: false` use case: the coworker ca
8
8
 
9
9
  - Package: `@velanir/openclaw-participation-gate`
10
10
  - OpenClaw plugin id: `velanir-participation-gate`
11
- - Runtime hooks: `before_dispatch`, `message_sent`, `reply_payload_sending`
11
+ - Runtime hooks: `before_dispatch`, `before_tool_call`, `message_sending`,
12
+ `message_sent`, `reply_payload_sending`
12
13
  - Default mode: `shadow`
13
- - Target OpenClaw plugin API: `>=2026.5.5`
14
+ - Default delivery mode: `passthrough`
15
+ - Target OpenClaw plugin API: `>=2026.6.6`
14
16
 
15
17
  The platform monorepo owns the source package in `packages/openclaw-plugins/participation-gate`. Customer machines install the built package into their OpenClaw runtime.
16
18
 
@@ -38,6 +40,44 @@ deduped before they reach classifier context.
38
40
 
39
41
  Any missing runtime dependency, missing context, invalid classifier output, classifier timeout, or classifier error fails open and lets the coworker respond. This is deliberate because false silence is worse than an occasional extra reply while the plugin is being rolled out.
40
42
 
43
+ ## Turn Delivery Coalescer
44
+
45
+ The `delivery` config controls what the user actually sees for one inbound
46
+ turn. It is independent of the participation decision above.
47
+
48
+ - `passthrough` (default): the plugin does not alter delivery at all. This is
49
+ the rollback value.
50
+ - `coalesce`: the plugin tracks each inbound turn by its exact session key
51
+ (falling back to provider+conversation, then channel) and enforces
52
+ final-only delivery for that turn:
53
+ - Non-final reply payloads (`kind: "tool"` / `"block"`) are cancelled.
54
+ - At most `maxProgressMessages` (0 or 1) generic progress updates may
55
+ deliver, and only after `quietWindowMs` of silence. The progress text is
56
+ always the configured `progressText`; model-authored narration is never
57
+ exposed. `maxProgressMessages: 0` means fully silent until the final.
58
+ - Exactly one final reply payload is admitted per turn. Duplicate finals for
59
+ the same turn or the same `runId` are cancelled.
60
+ - A `message` tool `send` call targeting the active inbound conversation is
61
+ blocked before execution (`before_tool_call`), so the model cannot bypass
62
+ the final-reply boundary by messaging the conversation directly. Sends to
63
+ unrelated destinations, other providers, or from sessions with no tracked
64
+ inbound turn (proactive and scheduled sends) are not affected.
65
+ - Direct outbound egress (`message_sending`) to the active conversation is
66
+ allowed only when it corresponds to the final payload the coalescer
67
+ already admitted.
68
+ - Turn state expires after `turnTtlMs` so an abandoned turn cannot silence a
69
+ conversation indefinitely.
70
+
71
+ Delivery state is process-shared across plugin re-registers, so a gateway
72
+ config reload does not reset duplicate-final protection for an in-flight turn.
73
+
74
+ ### Rollback
75
+
76
+ Set `delivery.mode` back to `passthrough` (or remove the `delivery` block) and
77
+ refresh the runtime config. No uninstall or version change is required; every
78
+ delivery hook becomes a no-op. Anything other than an explicit
79
+ `"coalesce"` value normalizes to `passthrough`.
80
+
41
81
  ## Classifier Contract
42
82
 
43
83
  The classifier returns a scored participation decision:
@@ -135,6 +175,12 @@ Runtime mode expects the same runtime identity environment used by `oct8-secrets
135
175
  "baseUrl": "https://api.velanir.ai",
136
176
  "coworkerId": "coworker_albus"
137
177
  },
178
+ "delivery": {
179
+ "mode": "passthrough",
180
+ "quietWindowMs": 45000,
181
+ "maxProgressMessages": 1,
182
+ "turnTtlMs": 600000
183
+ },
138
184
  "logging": {
139
185
  "decisions": true,
140
186
  "includeContent": false,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  type OpenClawPluginApi = {
2
- on: (hook: "before_dispatch" | "message_sent" | "reply_payload_sending", handler: (event: unknown, ctx: unknown) => unknown | Promise<unknown>, options?: {
2
+ on: (hook: "before_dispatch" | "before_tool_call" | "message_sending" | "message_sent" | "reply_payload_sending", handler: (event: unknown, ctx: unknown) => unknown | Promise<unknown>, options?: {
3
3
  priority?: number;
4
4
  timeoutMs?: number;
5
5
  }) => void;
package/dist/index.js CHANGED
@@ -1625,6 +1625,10 @@ var DEFAULT_CLASSIFIER_THRESHOLD = 0.7;
1625
1625
  var DEFAULT_MAX_MESSAGES = 5;
1626
1626
  var DEFAULT_REFRESH_MS = 5 * 60 * 1e3;
1627
1627
  var DEFAULT_PLATFORM_ENDPOINT_PATH = "/v1/runtime/coworkers/{coworkerId}/participation-context";
1628
+ var DEFAULT_DELIVERY_QUIET_WINDOW_MS = 45e3;
1629
+ var DEFAULT_DELIVERY_MAX_PROGRESS_MESSAGES = 1;
1630
+ var DEFAULT_DELIVERY_PROGRESS_TEXT = "Still working on this \u2014 I\u2019ll send the result when it\u2019s complete.";
1631
+ var DEFAULT_DELIVERY_TURN_TTL_MS = 10 * 60 * 1e3;
1628
1632
  function isRecord4(value) {
1629
1633
  return typeof value === "object" && value !== null && !Array.isArray(value);
1630
1634
  }
@@ -1646,6 +1650,22 @@ function normalizeContextSource(value) {
1646
1650
  function normalizePlatformAuthMode(value) {
1647
1651
  return value === "static-token" ? "static-token" : "runtime";
1648
1652
  }
1653
+ function normalizeDeliveryMode(value) {
1654
+ return value === "coalesce" ? "coalesce" : "passthrough";
1655
+ }
1656
+ function normalizeDelivery(value) {
1657
+ const record = isRecord4(value) ? value : {};
1658
+ return {
1659
+ mode: normalizeDeliveryMode(record.mode),
1660
+ quietWindowMs: readPositiveInteger(record.quietWindowMs, DEFAULT_DELIVERY_QUIET_WINDOW_MS),
1661
+ maxProgressMessages: Math.min(
1662
+ 1,
1663
+ readPositiveInteger(record.maxProgressMessages, DEFAULT_DELIVERY_MAX_PROGRESS_MESSAGES)
1664
+ ),
1665
+ progressText: readString2(record.progressText) ?? DEFAULT_DELIVERY_PROGRESS_TEXT,
1666
+ turnTtlMs: readPositiveInteger(record.turnTtlMs, DEFAULT_DELIVERY_TURN_TTL_MS) || DEFAULT_DELIVERY_TURN_TTL_MS
1667
+ };
1668
+ }
1649
1669
  function normalizeClassifier(value) {
1650
1670
  const record = isRecord4(value) ? value : {};
1651
1671
  return {
@@ -1719,6 +1739,7 @@ function normalizeConfig(pluginConfig, env = process.env) {
1719
1739
  },
1720
1740
  platform: normalizePlatform(record.platform, env),
1721
1741
  staticContext: normalizeStaticContext(record.staticContext),
1742
+ delivery: normalizeDelivery(record.delivery),
1722
1743
  logging: {
1723
1744
  decisions: loggingRecord.decisions !== false,
1724
1745
  includeContent: loggingRecord.includeContent === true,
@@ -1827,11 +1848,11 @@ function stableStringify(value) {
1827
1848
  return JSON.stringify(value);
1828
1849
  }
1829
1850
  function trimForPrompt(value, maxLength = 1200) {
1830
- const normalized = value.replace(/\s+/g, " ").trim();
1831
- if (normalized.length <= maxLength) {
1832
- return normalized;
1851
+ const normalized2 = value.replace(/\s+/g, " ").trim();
1852
+ if (normalized2.length <= maxLength) {
1853
+ return normalized2;
1833
1854
  }
1834
- return `${normalized.slice(0, maxLength - 3)}...`;
1855
+ return `${normalized2.slice(0, maxLength - 3)}...`;
1835
1856
  }
1836
1857
  function formatIdentity(identity) {
1837
1858
  const role = identity.roleSummary ? ` Role: ${identity.roleSummary}` : "";
@@ -2236,21 +2257,34 @@ function mergeRecentMessages(params) {
2236
2257
  }
2237
2258
  const merged = [];
2238
2259
  const seen = /* @__PURE__ */ new Set();
2260
+ const messageKey = (message2) => [
2261
+ message2.role ?? "user",
2262
+ message2.senderId ?? "",
2263
+ message2.senderName ?? "",
2264
+ message2.timestamp ?? "",
2265
+ message2.content
2266
+ ].join("\0");
2239
2267
  for (const message2 of [...params.localHistory, ...params.inboundHistory]) {
2240
- const key = [
2241
- message2.role ?? "user",
2242
- message2.senderId ?? "",
2243
- message2.senderName ?? "",
2244
- message2.timestamp ?? "",
2245
- message2.content
2246
- ].join("\0");
2268
+ const key = messageKey(message2);
2247
2269
  if (seen.has(key)) {
2248
2270
  continue;
2249
2271
  }
2250
2272
  seen.add(key);
2251
2273
  merged.push(message2);
2252
2274
  }
2253
- return merged.slice(-params.maxMessages);
2275
+ const selectedKeys = new Set(merged.slice(-params.maxMessages).map(messageKey));
2276
+ const localAssistantKeys = params.localHistory.filter((message2) => message2.role === "assistant").map(messageKey).filter((key, index, keys) => keys.indexOf(key) === index).slice(-params.maxMessages);
2277
+ for (const assistantKey of localAssistantKeys) {
2278
+ if (selectedKeys.has(assistantKey)) {
2279
+ continue;
2280
+ }
2281
+ const keyToDrop = merged.map(messageKey).find((key) => selectedKeys.has(key) && !localAssistantKeys.includes(key));
2282
+ if (keyToDrop) {
2283
+ selectedKeys.delete(keyToDrop);
2284
+ }
2285
+ selectedKeys.add(assistantKey);
2286
+ }
2287
+ return merged.filter((message2) => selectedKeys.has(messageKey(message2)));
2254
2288
  }
2255
2289
  function buildClassifierInput(params) {
2256
2290
  const inboundHistory = eventRecentMessages(params.event, params.maxMessages);
@@ -2387,6 +2421,203 @@ async function decideParticipation(params) {
2387
2421
  }
2388
2422
  }
2389
2423
 
2424
+ // src/delivery.ts
2425
+ function createTurnDeliveryState() {
2426
+ return {
2427
+ turns: /* @__PURE__ */ new Map(),
2428
+ deliveredRuns: /* @__PURE__ */ new Map()
2429
+ };
2430
+ }
2431
+ function sharedTurnDeliveryState() {
2432
+ const runtime = globalThis;
2433
+ runtime.__velanirParticipationGateDeliveryV1 ??= createTurnDeliveryState();
2434
+ return runtime.__velanirParticipationGateDeliveryV1;
2435
+ }
2436
+ function clean(value) {
2437
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2438
+ }
2439
+ function normalized(value) {
2440
+ return clean(value)?.toLowerCase();
2441
+ }
2442
+ function scopedKey(prefix, ...values) {
2443
+ const parts = values.map(clean);
2444
+ return parts.every(Boolean) ? `${prefix}:${parts.join(":")}` : void 0;
2445
+ }
2446
+ function normalizedTarget(value) {
2447
+ const target = normalized(value);
2448
+ if (!target) return void 0;
2449
+ return target.replace(/^msteams:conversation:/, "").replace(/^(?:conversation|user|channel):/, "").split(";messageid=", 1)[0];
2450
+ }
2451
+ function routeProvider(route) {
2452
+ return normalized(route.provider);
2453
+ }
2454
+ function targetMatchesRoute(target, route) {
2455
+ const candidate = normalizedTarget(target);
2456
+ if (!candidate) return true;
2457
+ return [route.conversationId, route.channelId, route.senderId].some(
2458
+ (value) => normalizedTarget(value) === candidate
2459
+ );
2460
+ }
2461
+ function deliveryRouteForInbound(event, ctx) {
2462
+ return {
2463
+ provider: clean(ctx.provider) ?? clean(event.provider) ?? clean(event.surface) ?? clean(event.channel),
2464
+ conversationId: clean(ctx.conversationId),
2465
+ channelId: clean(ctx.channelId) ?? clean(event.channel),
2466
+ senderId: clean(ctx.senderId) ?? clean(event.senderId)
2467
+ };
2468
+ }
2469
+ function deliveryScopeForInbound(event, ctx) {
2470
+ const sessionKey = clean(ctx.sessionKey) ?? clean(event.sessionKey);
2471
+ if (sessionKey) return `session:${sessionKey}`;
2472
+ const provider = clean(ctx.provider) ?? clean(event.provider) ?? clean(event.surface);
2473
+ const conversationId = clean(ctx.conversationId);
2474
+ if (provider && conversationId) {
2475
+ return scopedKey("conversation", provider, conversationId);
2476
+ }
2477
+ const channelId = clean(ctx.channelId) ?? clean(event.channel);
2478
+ return scopedKey("channel", provider, channelId);
2479
+ }
2480
+ function deliveryScopeForReply(event, ctx) {
2481
+ const sessionKey = clean(ctx.sessionKey) ?? clean(event.sessionKey);
2482
+ if (sessionKey) return `session:${sessionKey}`;
2483
+ const provider = clean(event.channel);
2484
+ const conversationId = clean(ctx.conversationId);
2485
+ if (provider && conversationId) {
2486
+ return scopedKey("conversation", provider, conversationId);
2487
+ }
2488
+ return scopedKey("channel", provider, ctx.channelId);
2489
+ }
2490
+ function deliveryScopeForTool(ctx) {
2491
+ const sessionKey = clean(ctx.sessionKey);
2492
+ return sessionKey ? `session:${sessionKey}` : void 0;
2493
+ }
2494
+ function deliveryScopeForMessage(ctx) {
2495
+ const sessionKey = clean(ctx.sessionKey);
2496
+ if (sessionKey) return `session:${sessionKey}`;
2497
+ return scopedKey("channel", ctx.channelId, ctx.conversationId);
2498
+ }
2499
+ function createTurnDeliveryStore(config, now = Date.now, state = createTurnDeliveryState()) {
2500
+ const { turns, deliveredRuns } = state;
2501
+ const prune = () => {
2502
+ const current = now();
2503
+ for (const [scope, turn] of turns) {
2504
+ if (current - turn.updatedAt > config.turnTtlMs) {
2505
+ turns.delete(scope);
2506
+ }
2507
+ }
2508
+ for (const [runId, deliveredAt] of deliveredRuns) {
2509
+ if (current - deliveredAt > config.turnTtlMs) {
2510
+ deliveredRuns.delete(runId);
2511
+ }
2512
+ }
2513
+ };
2514
+ return {
2515
+ beginTurn(scope, route = {}) {
2516
+ prune();
2517
+ if (!scope) return false;
2518
+ const timestamp = now();
2519
+ turns.set(scope, {
2520
+ startedAt: timestamp,
2521
+ updatedAt: timestamp,
2522
+ finalDelivered: false,
2523
+ finalEgressPending: false,
2524
+ progressMessages: 0,
2525
+ route
2526
+ });
2527
+ return true;
2528
+ },
2529
+ abandonTurn(scope) {
2530
+ prune();
2531
+ return scope ? turns.delete(scope) : false;
2532
+ },
2533
+ decide(event, ctx) {
2534
+ prune();
2535
+ const scope = deliveryScopeForReply(event, ctx);
2536
+ const turn = scope ? turns.get(scope) : void 0;
2537
+ const runId = clean(event.runId) ?? clean(ctx.runId);
2538
+ if (runId && deliveredRuns.has(runId)) {
2539
+ return {
2540
+ action: "suppress_duplicate_final",
2541
+ result: {
2542
+ cancel: true,
2543
+ reason: "participation_gate_duplicate_final"
2544
+ }
2545
+ };
2546
+ }
2547
+ if (!turn) {
2548
+ return { action: "allow_untracked" };
2549
+ }
2550
+ const timestamp = now();
2551
+ turn.updatedAt = timestamp;
2552
+ const isFinal = event.kind === void 0 || event.kind === "final";
2553
+ if (isFinal) {
2554
+ if (turn.finalDelivered) {
2555
+ return {
2556
+ action: "suppress_duplicate_final",
2557
+ result: {
2558
+ cancel: true,
2559
+ reason: "participation_gate_duplicate_final"
2560
+ }
2561
+ };
2562
+ }
2563
+ turn.finalDelivered = true;
2564
+ turn.finalEgressPending = true;
2565
+ if (runId) deliveredRuns.set(runId, timestamp);
2566
+ return { action: "allow_final" };
2567
+ }
2568
+ const canSendProgress = !turn.finalDelivered && timestamp - turn.startedAt >= config.quietWindowMs && turn.progressMessages < config.maxProgressMessages;
2569
+ if (canSendProgress) {
2570
+ turn.progressMessages += 1;
2571
+ const sourcePayload = event.payload;
2572
+ return {
2573
+ action: "allow_progress",
2574
+ result: {
2575
+ payload: {
2576
+ text: config.progressText,
2577
+ isStatusNotice: true,
2578
+ ...sourcePayload?.replyToId ? { replyToId: sourcePayload.replyToId } : {},
2579
+ ...sourcePayload?.replyToTag === true ? { replyToTag: true } : {},
2580
+ ...sourcePayload?.replyToCurrent === true ? { replyToCurrent: true } : {}
2581
+ }
2582
+ }
2583
+ };
2584
+ }
2585
+ return {
2586
+ action: "suppress_intermediate",
2587
+ result: {
2588
+ cancel: true,
2589
+ reason: "participation_gate_suppressed_intermediate"
2590
+ }
2591
+ };
2592
+ },
2593
+ isSameConversationSend(scope, send) {
2594
+ prune();
2595
+ if (!scope) return false;
2596
+ const turn = turns.get(scope);
2597
+ if (!turn) return false;
2598
+ const expectedProvider = routeProvider(turn.route);
2599
+ const actualProvider = normalized(send.provider);
2600
+ if (expectedProvider && actualProvider && expectedProvider !== actualProvider) {
2601
+ return false;
2602
+ }
2603
+ return targetMatchesRoute(send.target, turn.route);
2604
+ },
2605
+ consumeFinalEgress(scope) {
2606
+ prune();
2607
+ if (!scope) return false;
2608
+ const turn = turns.get(scope);
2609
+ if (!turn?.finalEgressPending) return false;
2610
+ turn.finalEgressPending = false;
2611
+ turn.updatedAt = now();
2612
+ return true;
2613
+ },
2614
+ activeTurnCount() {
2615
+ prune();
2616
+ return turns.size;
2617
+ }
2618
+ };
2619
+ }
2620
+
2390
2621
  // src/history.ts
2391
2622
  function normalizeKey2(value) {
2392
2623
  const trimmed = value?.trim();
@@ -2454,13 +2685,14 @@ function outboundKeys(event, ctx) {
2454
2685
  function publicMessages(messages, maxMessages) {
2455
2686
  return messages.sort((left, right) => left.sequence - right.sequence).slice(-maxMessages).map(({ sequence: _sequence, dedupeKey: _dedupeKey, ...message2 }) => message2);
2456
2687
  }
2457
- function outboundDedupeKey(event, content) {
2688
+ function outboundDedupeKey(event, ctx, content) {
2689
+ const primaryKey = outboundKeys(event, ctx)[0];
2690
+ if (primaryKey && primaryKey !== "unknown") {
2691
+ return ["conversation", primaryKey, content].join("\0");
2692
+ }
2458
2693
  if (event.runId) {
2459
2694
  return ["run", event.runId, content].join("\0");
2460
2695
  }
2461
- if (event.sessionKey) {
2462
- return ["session", event.sessionKey, content].join("\0");
2463
- }
2464
2696
  return ["message", event.messageId ?? "", event.to ?? "", content].join("\0");
2465
2697
  }
2466
2698
  function createParticipationHistoryStore() {
@@ -2546,7 +2778,7 @@ function createParticipationHistoryStore() {
2546
2778
  content
2547
2779
  },
2548
2780
  maxMessages,
2549
- outboundDedupeKey(event, content)
2781
+ outboundDedupeKey(event, ctx, content)
2550
2782
  );
2551
2783
  },
2552
2784
  record(event, ctx, maxMessages) {
@@ -2668,6 +2900,21 @@ function logParticipationHistoryEvent(params) {
2668
2900
 
2669
2901
  // src/index.ts
2670
2902
  var PLUGIN_ID = "velanir-participation-gate";
2903
+ var POST_EGRESS_REPLY_CAPTURE_PRIORITY = -100001;
2904
+ var PRE_EGRESS_DELIVERY_PRIORITY = 1e5;
2905
+ var SAME_CONVERSATION_MESSAGE_BLOCK_REASON = "Use the normal final reply for this conversation. Direct message sends to the active conversation are blocked.";
2906
+ function stringValue(value) {
2907
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2908
+ }
2909
+ function messageToolSend(event) {
2910
+ if (event.toolName.trim().toLowerCase() !== "message") return void 0;
2911
+ const action = stringValue(event.params.action)?.toLowerCase();
2912
+ if (action !== "send") return void 0;
2913
+ return {
2914
+ provider: stringValue(event.params.provider) ?? stringValue(event.params.channel),
2915
+ target: stringValue(event.params.to) ?? stringValue(event.params.target) ?? stringValue(event.params.recipient) ?? stringValue(event.params.conversationId)
2916
+ };
2917
+ }
2671
2918
  function replyPayloadContent(event) {
2672
2919
  const text = event.payload?.text;
2673
2920
  if (typeof text !== "string") {
@@ -2702,12 +2949,24 @@ var index_default = definePluginEntry({
2702
2949
  const config = normalizeConfig(runtimeApi.pluginConfig);
2703
2950
  const contextProvider = createParticipationContextProvider(config);
2704
2951
  const history = createParticipationHistoryStore();
2952
+ const deliveries = createTurnDeliveryStore(config.delivery, Date.now, sharedTurnDeliveryState());
2953
+ const beginDeliveryTurn = (event, ctx) => {
2954
+ if (config.delivery.mode !== "coalesce") return;
2955
+ const tracked = deliveries.beginTurn(
2956
+ deliveryScopeForInbound(event, ctx),
2957
+ deliveryRouteForInbound(event, ctx)
2958
+ );
2959
+ runtimeApi.logger?.info?.(
2960
+ `[${PLUGIN_ID}-delivery] action=begin_turn tracked=${tracked} active=${deliveries.activeTurnCount()}`
2961
+ );
2962
+ };
2705
2963
  api.on(
2706
2964
  "before_dispatch",
2707
2965
  async (event, ctx) => {
2708
2966
  const beforeDispatchEvent = event;
2709
2967
  const beforeDispatchContext = ctx;
2710
2968
  if (beforeDispatchEvent.wasMentioned === true || beforeDispatchContext.wasMentioned === true) {
2969
+ beginDeliveryTurn(beforeDispatchEvent, beforeDispatchContext);
2711
2970
  const recorded = history.recordInbound(
2712
2971
  beforeDispatchEvent,
2713
2972
  beforeDispatchContext,
@@ -2748,10 +3007,60 @@ var index_default = definePluginEntry({
2748
3007
  if (!decision2.shouldRespond && config.mode === "enforce") {
2749
3008
  return { handled: true };
2750
3009
  }
3010
+ beginDeliveryTurn(beforeDispatchEvent, beforeDispatchContext);
2751
3011
  return void 0;
2752
3012
  },
2753
3013
  { timeoutMs: config.classifier.timeoutMs + 2e3 }
2754
3014
  );
3015
+ api.on(
3016
+ "before_tool_call",
3017
+ (event, ctx) => {
3018
+ if (config.delivery.mode !== "coalesce") return void 0;
3019
+ const beforeToolCallEvent = event;
3020
+ const send = messageToolSend(beforeToolCallEvent);
3021
+ if (!send) return void 0;
3022
+ const beforeToolCallContext = ctx;
3023
+ const blocked = deliveries.isSameConversationSend(
3024
+ deliveryScopeForTool(beforeToolCallContext),
3025
+ send
3026
+ );
3027
+ if (!blocked) return void 0;
3028
+ runtimeApi.logger?.info?.(
3029
+ `[${PLUGIN_ID}-delivery] action=block_same_conversation_message_tool run=${beforeToolCallEvent.runId ?? beforeToolCallContext.runId ?? "unknown"} toolCall=${beforeToolCallEvent.toolCallId ?? beforeToolCallContext.toolCallId ?? "unknown"}`
3030
+ );
3031
+ return {
3032
+ block: true,
3033
+ blockReason: SAME_CONVERSATION_MESSAGE_BLOCK_REASON
3034
+ };
3035
+ },
3036
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3037
+ );
3038
+ api.on(
3039
+ "message_sending",
3040
+ (event, ctx) => {
3041
+ if (config.delivery.mode !== "coalesce") return void 0;
3042
+ const messageSendingEvent = event;
3043
+ const messageSendingContext = ctx;
3044
+ const scope = deliveryScopeForMessage(messageSendingContext);
3045
+ const sameConversation = deliveries.isSameConversationSend(scope, {
3046
+ provider: messageSendingEvent.metadata?.channel,
3047
+ target: messageSendingContext.conversationId ?? messageSendingEvent.to
3048
+ });
3049
+ if (!sameConversation) return void 0;
3050
+ if (deliveries.consumeFinalEgress(scope)) {
3051
+ runtimeApi.logger?.info?.(`[${PLUGIN_ID}-delivery] action=allow_final_message_egress`);
3052
+ return void 0;
3053
+ }
3054
+ runtimeApi.logger?.info?.(
3055
+ `[${PLUGIN_ID}-delivery] action=suppress_same_conversation_message_egress`
3056
+ );
3057
+ return {
3058
+ cancel: true,
3059
+ cancelReason: "participation_gate_suppressed_same_conversation_message"
3060
+ };
3061
+ },
3062
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3063
+ );
2755
3064
  api.on("message_sent", (event, ctx) => {
2756
3065
  const messageSentEvent = event;
2757
3066
  const messageSentContext = ctx;
@@ -2772,39 +3081,58 @@ var index_default = definePluginEntry({
2772
3081
  });
2773
3082
  return void 0;
2774
3083
  });
2775
- api.on("reply_payload_sending", (event, ctx) => {
2776
- const replyEvent = event;
2777
- if (replyEvent.kind !== void 0 && replyEvent.kind !== "final") {
2778
- return void 0;
2779
- }
2780
- const content = replyPayloadContent(replyEvent);
2781
- if (!content) {
3084
+ api.on(
3085
+ "reply_payload_sending",
3086
+ (event, ctx) => {
3087
+ if (config.delivery.mode !== "coalesce") return void 0;
3088
+ const decision2 = deliveries.decide(
3089
+ event,
3090
+ ctx
3091
+ );
3092
+ runtimeApi.logger?.info?.(
3093
+ `[${PLUGIN_ID}-delivery] action=${decision2.action} kind=${event.kind ?? "legacy_final"}`
3094
+ );
3095
+ return decision2.result;
3096
+ },
3097
+ { priority: PRE_EGRESS_DELIVERY_PRIORITY }
3098
+ );
3099
+ api.on(
3100
+ "reply_payload_sending",
3101
+ (event, ctx) => {
3102
+ const replyEvent = event;
3103
+ if (replyEvent.kind !== void 0 && replyEvent.kind !== "final") {
3104
+ return void 0;
3105
+ }
3106
+ const content = replyPayloadContent(replyEvent);
3107
+ if (!content) {
3108
+ return void 0;
3109
+ }
3110
+ const replyContext = ctx;
3111
+ const messageSentEvent = messageSentEventFromReplyPayload(replyEvent, replyContext, content);
3112
+ const messageSentContext = messageSentContextFromReplyPayload(replyEvent, replyContext);
3113
+ const recorded = history.recordOutbound(
3114
+ messageSentEvent,
3115
+ messageSentContext,
3116
+ config.context.maxMessages
3117
+ );
3118
+ logParticipationHistoryEvent({
3119
+ logger: runtimeApi.logger,
3120
+ config,
3121
+ event: "reply_payload_outbound",
3122
+ fields: {
3123
+ recorded,
3124
+ kind: replyEvent.kind,
3125
+ channel: messageSentContext.channelId,
3126
+ conversation: messageSentContext.conversationId ?? messageSentEvent.to,
3127
+ session: messageSentContext.sessionKey ?? messageSentEvent.sessionKey,
3128
+ runId: messageSentEvent.runId
3129
+ },
3130
+ content
3131
+ });
2782
3132
  return void 0;
2783
- }
2784
- const replyContext = ctx;
2785
- const messageSentEvent = messageSentEventFromReplyPayload(replyEvent, replyContext, content);
2786
- const messageSentContext = messageSentContextFromReplyPayload(replyEvent, replyContext);
2787
- const recorded = history.recordOutbound(
2788
- messageSentEvent,
2789
- messageSentContext,
2790
- config.context.maxMessages
2791
- );
2792
- logParticipationHistoryEvent({
2793
- logger: runtimeApi.logger,
2794
- config,
2795
- event: "reply_payload_outbound",
2796
- fields: {
2797
- recorded,
2798
- kind: replyEvent.kind,
2799
- channel: messageSentContext.channelId,
2800
- conversation: messageSentContext.conversationId ?? messageSentEvent.to,
2801
- session: messageSentContext.sessionKey ?? messageSentEvent.sessionKey,
2802
- runId: messageSentEvent.runId
2803
- },
2804
- content
2805
- });
2806
- return void 0;
2807
- });
3133
+ },
3134
+ { priority: POST_EGRESS_REPLY_CAPTURE_PRIORITY }
3135
+ );
2808
3136
  }
2809
3137
  });
2810
3138
  export {
@@ -83,6 +83,25 @@
83
83
  }
84
84
  }
85
85
  },
86
+ "delivery": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "properties": {
90
+ "mode": {
91
+ "type": "string",
92
+ "enum": ["passthrough", "coalesce"],
93
+ "default": "passthrough"
94
+ },
95
+ "quietWindowMs": { "type": "number", "minimum": 0, "default": 45000 },
96
+ "maxProgressMessages": { "type": "number", "minimum": 0, "maximum": 1, "default": 1 },
97
+ "progressText": {
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "default": "Still working on this — I’ll send the result when it’s complete."
101
+ },
102
+ "turnTtlMs": { "type": "number", "minimum": 1, "default": 600000 }
103
+ }
104
+ },
86
105
  "logging": {
87
106
  "type": "object",
88
107
  "additionalProperties": false,
@@ -151,6 +170,26 @@
151
170
  "logging.classifierDebug": {
152
171
  "label": "Log Classifier Debug",
153
172
  "help": "Default off. Emits structured classifier diagnostics; exact prompt/input/output still requires includeContent."
173
+ },
174
+ "delivery.mode": {
175
+ "label": "Delivery Mode",
176
+ "help": "coalesce withholds tool and progress narration for inbound turns and emits one final answer; passthrough preserves OpenClaw delivery."
177
+ },
178
+ "delivery.quietWindowMs": {
179
+ "label": "Delivery Quiet Window",
180
+ "help": "Milliseconds to keep an inbound turn silent before one bounded progress update may be delivered."
181
+ },
182
+ "delivery.maxProgressMessages": {
183
+ "label": "Maximum Progress Messages",
184
+ "help": "Maximum user-visible progress messages per inbound turn. Supported values are 0 or 1."
185
+ },
186
+ "delivery.progressText": {
187
+ "label": "Progress Message",
188
+ "help": "Generic text used for the single long-task progress update; model-authored tool narration is never exposed."
189
+ },
190
+ "delivery.turnTtlMs": {
191
+ "label": "Delivery Turn TTL",
192
+ "help": "Maximum lifetime for per-session delivery state and duplicate-final protection."
154
193
  }
155
194
  }
156
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-participation-gate",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw plugin that gates group/channel participation for Velanir digital coworkers.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "test": "pnpm --filter=@oct8/oct8-secrets build && vitest run"
35
35
  },
36
36
  "peerDependencies": {
37
- "openclaw": ">=2026.5.5"
37
+ "openclaw": ">=2026.6.6"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "openclaw": {
@@ -57,8 +57,8 @@
57
57
  "./dist/index.js"
58
58
  ],
59
59
  "compat": {
60
- "pluginApi": ">=2026.5.5",
61
- "minGatewayVersion": "2026.5.5"
60
+ "pluginApi": ">=2026.6.6",
61
+ "minGatewayVersion": "2026.6.6"
62
62
  }
63
63
  }
64
64
  }