@coffeexdev/openclaw-sentinel 0.4.4 → 0.4.5

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
@@ -18,19 +18,42 @@ Add/update `~/.openclaw/openclaw.json`:
18
18
 
19
19
  ```json5
20
20
  {
21
- sentinel: {
22
- // Required: watchers can only call endpoints on these hosts.
23
- allowedHosts: ["api.github.com", "api.coingecko.com"],
24
-
25
- // Default dispatch base for internal webhook callbacks.
26
- localDispatchBase: "http://127.0.0.1:18789",
27
-
28
- // Optional: where /hooks/sentinel events are queued in the LLM loop.
29
- hookSessionKey: "agent:main:main",
30
-
31
- // Optional: bearer token used for dispatch calls back to gateway.
32
- // Set this to your gateway auth token when gateway auth is enabled.
33
- // dispatchAuthToken: "<gateway-token>"
21
+ plugins: {
22
+ entries: {
23
+ "openclaw-sentinel": {
24
+ enabled: true,
25
+ config: {
26
+ // Required: watchers can only call endpoints on these hosts.
27
+ allowedHosts: ["api.github.com", "api.coingecko.com"],
28
+
29
+ // Default dispatch base for internal webhook callbacks.
30
+ localDispatchBase: "http://127.0.0.1:18789",
31
+
32
+ // Optional: base prefix for isolated /hooks/sentinel callback sessions.
33
+ // Sentinel appends :watcher:<id> by default (or :group:<key> when grouped).
34
+ hookSessionPrefix: "agent:main:hooks:sentinel",
35
+
36
+ // Optional: default group key for callbacks without explicit hookSessionGroup.
37
+ // hookSessionGroup: "ops-alerts",
38
+
39
+ // Optional: suppress duplicate relays by dedupe key within this time window.
40
+ hookRelayDedupeWindowMs: 120000,
41
+
42
+ // Optional: payload style for non-/hooks/sentinel deliveryTargets notifications.
43
+ // "none" suppresses delivery-target message fan-out (callback still fires).
44
+ // "concise" (default) sends human-friendly relay text only.
45
+ // "debug" appends a structured sentinel envelope block for diagnostics.
46
+ // notificationPayloadMode: "concise",
47
+
48
+ // Optional legacy alias for hookSessionPrefix (still supported).
49
+ // hookSessionKey: "agent:main:hooks:sentinel",
50
+
51
+ // Optional: bearer token used for dispatch calls back to gateway.
52
+ // Set this to your gateway auth token when gateway auth is enabled.
53
+ // dispatchAuthToken: "<gateway-token>"
54
+ },
55
+ },
56
+ },
34
57
  },
35
58
  }
36
59
  ```
@@ -41,6 +64,20 @@ Add/update `~/.openclaw/openclaw.json`:
41
64
  openclaw gateway restart
42
65
  ```
43
66
 
67
+ ### Troubleshooting: `Unrecognized key: "sentinel"`
68
+
69
+ If gateway startup/validation reports:
70
+
71
+ ```text
72
+ Unrecognized key: "sentinel"
73
+ ```
74
+
75
+ your config is using the old root-level shape. Move Sentinel config under:
76
+
77
+ - `plugins.entries.openclaw-sentinel.config`
78
+
79
+ Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
80
+
44
81
  ### 4) Create your first watcher (`sentinel_control`)
45
82
 
46
83
  ```json
@@ -65,6 +102,7 @@ openclaw gateway restart
65
102
  "workflow": "alerts"
66
103
  },
67
104
  "priority": "high",
105
+ "sessionGroup": "portfolio-risk",
68
106
  "deadlineTemplate": "${timestamp}",
69
107
  "payloadTemplate": {
70
108
  "event": "${event.name}",
@@ -97,11 +135,11 @@ Use `sentinel_control`:
97
135
  1. Sentinel evaluates conditions.
98
136
  2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
99
137
  3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
100
- 4. It also sends a notification message to each configured `deliveryTargets` destination (defaults to the current chat context when watcher is created from a channel session).
101
- 5. For `/hooks/sentinel`, the plugin route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
102
- 6. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
138
+ 4. For `/hooks/sentinel`, delivery to user chat is relayed from the hook route using `deliveryTargets` (or inferred chat context when available) with a concise alert message.
139
+ 5. The `/hooks/sentinel` route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
140
+ 6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
103
141
 
104
- The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
142
+ The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Relay notifications are dedupe-aware by callback dedupe key.
105
143
 
106
144
  Sample emitted envelope:
107
145
 
@@ -185,6 +223,91 @@ It **does not** execute user-authored code from watcher definitions.
185
223
 
186
224
  `deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
187
225
 
226
+ ## Notification payload delivery modes
227
+
228
+ Sentinel always dispatches the callback envelope to `localDispatchBase + webhookPath` on match.
229
+ `notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets`.
230
+
231
+ Global mode options:
232
+
233
+ - `none`: suppress delivery-target notification messages (callback dispatch still occurs)
234
+ - `concise` (default): send short relay text only
235
+ - `debug`: send relay text plus `SENTINEL_DEBUG_ENVELOPE_JSON` block
236
+
237
+ ### 1) Global notifications disabled (`none`)
238
+
239
+ ```json5
240
+ {
241
+ sentinel: {
242
+ allowedHosts: ["api.github.com"],
243
+ notificationPayloadMode: "none",
244
+ },
245
+ }
246
+ ```
247
+
248
+ ### 2) Global concise relay (default)
249
+
250
+ ```json5
251
+ {
252
+ sentinel: {
253
+ allowedHosts: ["api.github.com"],
254
+ notificationPayloadMode: "concise",
255
+ },
256
+ }
257
+ ```
258
+
259
+ ### 3) Global debug diagnostics
260
+
261
+ ```json5
262
+ {
263
+ sentinel: {
264
+ allowedHosts: ["api.github.com"],
265
+ notificationPayloadMode: "debug",
266
+ },
267
+ }
268
+ ```
269
+
270
+ In debug mode, delivery notifications include the same concise relay line plus a `SENTINEL_DEBUG_ENVELOPE_JSON` block for diagnostics.
271
+
272
+ ### 4) Per-watcher override (`watcher.fire.notificationPayloadMode`)
273
+
274
+ ```json
275
+ {
276
+ "action": "create",
277
+ "watcher": {
278
+ "id": "status-watch",
279
+ "skillId": "skills.ops",
280
+ "enabled": true,
281
+ "strategy": "http-poll",
282
+ "endpoint": "https://status.example.com/api/health",
283
+ "intervalMs": 10000,
284
+ "match": "all",
285
+ "conditions": [{ "path": "status", "op": "eq", "value": "degraded" }],
286
+ "fire": {
287
+ "webhookPath": "/hooks/agent",
288
+ "eventName": "service_degraded",
289
+ "notificationPayloadMode": "none",
290
+ "payloadTemplate": { "event": "${event.name}", "status": "${payload.status}" }
291
+ },
292
+ "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
293
+ }
294
+ }
295
+ ```
296
+
297
+ Allowed values:
298
+
299
+ - `inherit` (or omitted): follow global `notificationPayloadMode`
300
+ - `none`: suppress delivery-target notification messages for this watcher
301
+ - `concise`: force concise notification text for this watcher
302
+ - `debug`: force debug envelope output for this watcher
303
+
304
+ Precedence: **watcher override > global setting**.
305
+
306
+ ### Migration notes
307
+
308
+ - Existing installs keep default behavior (`concise`) unless you set `notificationPayloadMode` explicitly.
309
+ - If you want callback-only operation (wake LLM loop via `/hooks/sentinel` but no delivery-target chat message), set global or per-watcher mode to `none`.
310
+
188
311
  ## Runtime controls
189
312
 
190
313
  ```json
@@ -89,6 +89,7 @@ export function createCallbackEnvelope(args) {
89
89
  priority,
90
90
  ...(deadline ? { deadline } : {}),
91
91
  },
92
+ ...(watcher.fire.sessionGroup ? { hookSessionGroup: watcher.fire.sessionGroup } : {}),
92
93
  context: renderedContext ?? summarizePayload(payload),
93
94
  payload: truncatePayload(payload),
94
95
  deliveryTargets: watcher.deliveryTargets ?? [],
@@ -6,12 +6,21 @@ const LimitsSchema = Type.Object({
6
6
  maxConditionsPerWatcher: Type.Integer({ minimum: 1 }),
7
7
  maxIntervalMsFloor: Type.Integer({ minimum: 1 }),
8
8
  }, { additionalProperties: false });
9
+ const NotificationPayloadModeSchema = Type.Union([
10
+ Type.Literal("none"),
11
+ Type.Literal("concise"),
12
+ Type.Literal("debug"),
13
+ ]);
9
14
  const ConfigSchema = Type.Object({
10
15
  allowedHosts: Type.Array(Type.String()),
11
16
  localDispatchBase: Type.String({ minLength: 1 }),
12
17
  dispatchAuthToken: Type.Optional(Type.String()),
13
18
  hookSessionKey: Type.Optional(Type.String({ minLength: 1 })),
19
+ hookSessionPrefix: Type.Optional(Type.String({ minLength: 1 })),
20
+ hookSessionGroup: Type.Optional(Type.String({ minLength: 1 })),
21
+ hookRelayDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
14
22
  stateFilePath: Type.Optional(Type.String()),
23
+ notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
15
24
  limits: Type.Optional(LimitsSchema),
16
25
  }, { additionalProperties: false });
17
26
  function withDefaults(value) {
@@ -22,8 +31,18 @@ function withDefaults(value) {
22
31
  ? value.localDispatchBase
23
32
  : "http://127.0.0.1:18789",
24
33
  dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
25
- hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : "agent:main:main",
34
+ hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
35
+ hookSessionPrefix: typeof value.hookSessionPrefix === "string"
36
+ ? value.hookSessionPrefix
37
+ : "agent:main:hooks:sentinel",
38
+ hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
39
+ hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
26
40
  stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
41
+ notificationPayloadMode: value.notificationPayloadMode === "none"
42
+ ? "none"
43
+ : value.notificationPayloadMode === "debug"
44
+ ? "debug"
45
+ : "concise",
27
46
  limits: {
28
47
  maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
29
48
  maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
@@ -96,13 +115,33 @@ export const sentinelConfigSchema = {
96
115
  },
97
116
  hookSessionKey: {
98
117
  type: "string",
99
- description: "Session key used when /hooks/sentinel enqueues system events into the LLM loop",
100
- default: "agent:main:main",
118
+ description: "Deprecated alias for hookSessionPrefix. Sentinel always appends watcher/group segments to prevent a shared global callback session.",
119
+ },
120
+ hookSessionPrefix: {
121
+ type: "string",
122
+ description: "Base session key prefix used for isolated /hooks/sentinel callback sessions (default: agent:main:hooks:sentinel)",
123
+ default: "agent:main:hooks:sentinel",
124
+ },
125
+ hookSessionGroup: {
126
+ type: "string",
127
+ description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
128
+ },
129
+ hookRelayDedupeWindowMs: {
130
+ type: "number",
131
+ minimum: 0,
132
+ description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
133
+ default: 120000,
101
134
  },
102
135
  stateFilePath: {
103
136
  type: "string",
104
137
  description: "Custom path for the sentinel state persistence file",
105
138
  },
139
+ notificationPayloadMode: {
140
+ type: "string",
141
+ enum: ["none", "concise", "debug"],
142
+ description: "Controls delivery-target notifications: none (suppress message fan-out), concise relay text (default), or relay text with debug envelope payload",
143
+ default: "concise",
144
+ },
106
145
  limits: {
107
146
  type: "object",
108
147
  additionalProperties: false,
@@ -148,8 +187,23 @@ export const sentinelConfigSchema = {
148
187
  placeholder: "sk-...",
149
188
  },
150
189
  hookSessionKey: {
151
- label: "Sentinel Hook Session Key",
152
- help: "Session key that receives /hooks/sentinel callback events (default: agent:main:main)",
190
+ label: "Hook Session Key (Deprecated)",
191
+ help: "Deprecated alias for hookSessionPrefix. Sentinel appends watcher/group segments automatically.",
192
+ advanced: true,
193
+ },
194
+ hookSessionPrefix: {
195
+ label: "Hook Session Prefix",
196
+ help: "Base prefix for isolated callback sessions (default: agent:main:hooks:sentinel)",
197
+ advanced: true,
198
+ },
199
+ hookSessionGroup: {
200
+ label: "Default Hook Session Group",
201
+ help: "Optional default group key for callback sessions. Watchers with the same group share one isolated session.",
202
+ advanced: true,
203
+ },
204
+ hookRelayDedupeWindowMs: {
205
+ label: "Hook Relay Dedupe Window (ms)",
206
+ help: "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
153
207
  advanced: true,
154
208
  },
155
209
  stateFilePath: {
@@ -157,6 +211,11 @@ export const sentinelConfigSchema = {
157
211
  help: "Custom path for sentinel state persistence file",
158
212
  advanced: true,
159
213
  },
214
+ notificationPayloadMode: {
215
+ label: "Notification Payload Mode",
216
+ help: "Choose none (suppress delivery-target messages), concise relay text (default), or include debug envelope payload",
217
+ advanced: true,
218
+ },
160
219
  "limits.maxWatchersTotal": {
161
220
  label: "Max Watchers",
162
221
  help: "Maximum total watchers across all skills",
package/dist/index.js CHANGED
@@ -4,11 +4,21 @@ import { registerSentinelControl } from "./tool.js";
4
4
  import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
5
5
  import { WatcherManager } from "./watcherManager.js";
6
6
  const registeredWebhookPathsByRegistrar = new WeakMap();
7
- const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
7
+ const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
8
+ const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
8
9
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
9
10
  const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
10
11
  const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
11
12
  const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.";
13
+ const SUPPORTED_DELIVERY_CHANNELS = new Set([
14
+ "telegram",
15
+ "discord",
16
+ "slack",
17
+ "signal",
18
+ "imessage",
19
+ "whatsapp",
20
+ "line",
21
+ ]);
12
22
  function trimText(value, max) {
13
23
  return value.length <= max ? value : `${value.slice(0, max)}…`;
14
24
  }
@@ -28,6 +38,21 @@ function asIsoString(value) {
28
38
  function isRecord(value) {
29
39
  return !!value && typeof value === "object" && !Array.isArray(value);
30
40
  }
41
+ function resolveSentinelPluginConfig(api) {
42
+ const pluginConfig = isRecord(api.pluginConfig)
43
+ ? api.pluginConfig
44
+ : {};
45
+ const configRoot = isRecord(api.config) ? api.config : undefined;
46
+ const legacyRootConfig = configRoot?.sentinel;
47
+ if (legacyRootConfig === undefined)
48
+ return pluginConfig;
49
+ api.logger?.warn?.('[openclaw-sentinel] Detected deprecated root-level config key "sentinel". Move settings to plugins.entries.openclaw-sentinel.config. Root-level "sentinel" may fail with: Unrecognized key: "sentinel".');
50
+ if (!isRecord(legacyRootConfig))
51
+ return pluginConfig;
52
+ if (Object.keys(pluginConfig).length > 0)
53
+ return pluginConfig;
54
+ return legacyRootConfig;
55
+ }
31
56
  function isDeliveryTarget(value) {
32
57
  return (isRecord(value) &&
33
58
  typeof value.channel === "string" &&
@@ -41,6 +66,14 @@ function normalizePath(path) {
41
66
  const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
67
  return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
43
68
  }
69
+ function sanitizeSessionSegment(value) {
70
+ const sanitized = value
71
+ .trim()
72
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
73
+ .replace(/-+/g, "-")
74
+ .replace(/^-|-$/g, "");
75
+ return sanitized.length > 0 ? sanitized.slice(0, 64) : "unknown";
76
+ }
44
77
  function clipPayloadForPrompt(value) {
45
78
  const serialized = JSON.stringify(value);
46
79
  if (!serialized)
@@ -56,15 +89,30 @@ function clipPayloadForPrompt(value) {
56
89
  preview: `${clipped}…`,
57
90
  };
58
91
  }
92
+ function getNestedString(value, path) {
93
+ let cursor = value;
94
+ for (const segment of path) {
95
+ if (!isRecord(cursor))
96
+ return undefined;
97
+ cursor = cursor[segment];
98
+ }
99
+ return asString(cursor);
100
+ }
59
101
  function buildSentinelEventEnvelope(payload) {
60
102
  const watcherId = asString(payload.watcherId) ??
61
- (isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
103
+ getNestedString(payload, ["watcher", "id"]) ??
104
+ getNestedString(payload, ["context", "watcherId"]);
62
105
  const eventName = asString(payload.eventName) ??
63
- (isRecord(payload.event) ? asString(payload.event.name) : undefined);
106
+ getNestedString(payload, ["watcher", "eventName"]) ??
107
+ getNestedString(payload, ["event", "name"]);
64
108
  const skillId = asString(payload.skillId) ??
65
- (isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
109
+ getNestedString(payload, ["watcher", "skillId"]) ??
110
+ getNestedString(payload, ["context", "skillId"]) ??
66
111
  undefined;
67
- const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
112
+ const matchedAt = asIsoString(payload.matchedAt) ??
113
+ asIsoString(payload.timestamp) ??
114
+ asIsoString(getNestedString(payload, ["trigger", "matchedAt"])) ??
115
+ new Date().toISOString();
68
116
  const rawPayload = payload.payload ??
69
117
  (isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
70
118
  payload;
@@ -78,10 +126,16 @@ function buildSentinelEventEnvelope(payload) {
78
126
  const dedupeKey = asString(payload.dedupeKey) ??
79
127
  asString(payload.correlationId) ??
80
128
  asString(payload.correlationID) ??
129
+ getNestedString(payload, ["trigger", "dedupeKey"]) ??
81
130
  generatedDedupe;
82
131
  const deliveryTargets = Array.isArray(payload.deliveryTargets)
83
132
  ? payload.deliveryTargets.filter(isDeliveryTarget)
84
133
  : undefined;
134
+ const sourceRoute = getNestedString(payload, ["source", "route"]) ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
135
+ const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
136
+ const hookSessionGroup = asString(payload.hookSessionGroup) ??
137
+ asString(payload.sessionGroup) ??
138
+ getNestedString(payload, ["watcher", "sessionGroup"]);
85
139
  const envelope = {
86
140
  watcherId: watcherId ?? null,
87
141
  eventName: eventName ?? null,
@@ -90,22 +144,126 @@ function buildSentinelEventEnvelope(payload) {
90
144
  dedupeKey,
91
145
  correlationId: dedupeKey,
92
146
  source: {
93
- route: DEFAULT_SENTINEL_WEBHOOK_PATH,
94
- plugin: "openclaw-sentinel",
147
+ route: sourceRoute,
148
+ plugin: sourcePlugin,
95
149
  },
96
150
  };
97
151
  if (skillId)
98
152
  envelope.skillId = skillId;
153
+ if (hookSessionGroup)
154
+ envelope.hookSessionGroup = hookSessionGroup;
99
155
  if (deliveryTargets && deliveryTargets.length > 0)
100
156
  envelope.deliveryTargets = deliveryTargets;
101
157
  return envelope;
102
158
  }
103
- function buildSentinelSystemEvent(payload) {
104
- const envelope = buildSentinelEventEnvelope(payload);
159
+ function buildSentinelSystemEvent(envelope) {
105
160
  const jsonEnvelope = JSON.stringify(envelope, null, 2);
106
161
  const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
107
162
  return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
108
163
  }
164
+ function normalizeDeliveryTargets(targets) {
165
+ const deduped = new Map();
166
+ for (const target of targets) {
167
+ const channel = asString(target.channel);
168
+ const to = asString(target.to);
169
+ if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
170
+ continue;
171
+ const accountId = asString(target.accountId);
172
+ const key = `${channel}:${to}:${accountId ?? ""}`;
173
+ deduped.set(key, { channel, to, ...(accountId ? { accountId } : {}) });
174
+ }
175
+ return [...deduped.values()];
176
+ }
177
+ function inferTargetFromSessionKey(sessionKey, accountId) {
178
+ const segments = sessionKey
179
+ .split(":")
180
+ .map((part) => part.trim())
181
+ .filter(Boolean);
182
+ if (segments.length < 5)
183
+ return undefined;
184
+ const channel = segments[2];
185
+ const to = segments.at(-1);
186
+ if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
187
+ return undefined;
188
+ return {
189
+ channel,
190
+ to,
191
+ ...(accountId ? { accountId } : {}),
192
+ };
193
+ }
194
+ function inferRelayTargets(payload, envelope) {
195
+ if (envelope.deliveryTargets?.length) {
196
+ return normalizeDeliveryTargets(envelope.deliveryTargets);
197
+ }
198
+ const inferred = [];
199
+ if (isDeliveryTarget(payload.currentChat))
200
+ inferred.push(payload.currentChat);
201
+ const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
202
+ if (isDeliveryTarget(sourceCurrentChat))
203
+ inferred.push(sourceCurrentChat);
204
+ const messageChannel = asString(payload.messageChannel);
205
+ const requesterSenderId = asString(payload.requesterSenderId);
206
+ if (messageChannel && requesterSenderId && SUPPORTED_DELIVERY_CHANNELS.has(messageChannel)) {
207
+ inferred.push({ channel: messageChannel, to: requesterSenderId });
208
+ }
209
+ const fromSessionKey = asString(payload.sessionKey);
210
+ if (fromSessionKey) {
211
+ const target = inferTargetFromSessionKey(fromSessionKey, asString(payload.agentAccountId));
212
+ if (target)
213
+ inferred.push(target);
214
+ }
215
+ const sourceSessionKey = getNestedString(payload, ["source", "sessionKey"]);
216
+ if (sourceSessionKey) {
217
+ const sourceAccountId = getNestedString(payload, ["source", "accountId"]);
218
+ const target = inferTargetFromSessionKey(sourceSessionKey, sourceAccountId);
219
+ if (target)
220
+ inferred.push(target);
221
+ }
222
+ return normalizeDeliveryTargets(inferred);
223
+ }
224
+ function summarizeContext(value) {
225
+ if (!isRecord(value))
226
+ return undefined;
227
+ const entries = Object.entries(value).slice(0, 3);
228
+ if (entries.length === 0)
229
+ return undefined;
230
+ const chunks = entries.map(([key, val]) => {
231
+ if (typeof val === "string")
232
+ return `${key}=${trimText(val, 64)}`;
233
+ if (typeof val === "number" || typeof val === "boolean")
234
+ return `${key}=${String(val)}`;
235
+ return `${key}=${trimText(JSON.stringify(val), 64)}`;
236
+ });
237
+ return chunks.join(" · ");
238
+ }
239
+ function buildRelayMessage(envelope) {
240
+ const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
241
+ const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
242
+ const payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
243
+ const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
244
+ const lines = [title, `${watcher} · ${envelope.matchedAt}`];
245
+ if (contextSummary)
246
+ lines.push(contextSummary);
247
+ const text = lines.join("\n").trim();
248
+ return text.length > 0 ? text : "Sentinel callback received.";
249
+ }
250
+ function buildIsolatedHookSessionKey(envelope, config) {
251
+ const rawPrefix = asString(config.hookSessionKey) ??
252
+ asString(config.hookSessionPrefix) ??
253
+ DEFAULT_HOOK_SESSION_PREFIX;
254
+ const prefix = rawPrefix.replace(/:+$/g, "");
255
+ const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
256
+ if (group) {
257
+ return `${prefix}:group:${sanitizeSessionSegment(group)}`;
258
+ }
259
+ if (envelope.watcherId) {
260
+ return `${prefix}:watcher:${sanitizeSessionSegment(envelope.watcherId)}`;
261
+ }
262
+ if (envelope.dedupeKey) {
263
+ return `${prefix}:event:${sanitizeSessionSegment(envelope.dedupeKey.slice(0, 24))}`;
264
+ }
265
+ return `${prefix}:event:unknown`;
266
+ }
109
267
  async function readSentinelWebhookPayload(req) {
110
268
  const preParsed = req.body;
111
269
  if (isRecord(preParsed))
@@ -183,7 +341,9 @@ export function createSentinelPlugin(overrides) {
183
341
  allowedHosts: [],
184
342
  localDispatchBase: "http://127.0.0.1:18789",
185
343
  dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
186
- hookSessionKey: DEFAULT_HOOK_SESSION_KEY,
344
+ hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
345
+ hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
346
+ notificationPayloadMode: "concise",
187
347
  limits: {
188
348
  maxWatchersTotal: 200,
189
349
  maxWatchersPerSkill: 20,
@@ -192,6 +352,22 @@ export function createSentinelPlugin(overrides) {
192
352
  },
193
353
  ...overrides,
194
354
  };
355
+ const recentRelayByDedupe = new Map();
356
+ const shouldRelayForDedupe = (dedupeKey) => {
357
+ const windowMs = Math.max(0, config.hookRelayDedupeWindowMs ?? DEFAULT_RELAY_DEDUPE_WINDOW_MS);
358
+ if (windowMs === 0)
359
+ return true;
360
+ const now = Date.now();
361
+ for (const [key, ts] of recentRelayByDedupe.entries()) {
362
+ if (now - ts > windowMs)
363
+ recentRelayByDedupe.delete(key);
364
+ }
365
+ const prev = recentRelayByDedupe.get(dedupeKey);
366
+ if (typeof prev === "number" && now - prev <= windowMs)
367
+ return false;
368
+ recentRelayByDedupe.set(dedupeKey, now);
369
+ return true;
370
+ };
195
371
  const manager = new WatcherManager(config, {
196
372
  async dispatch(path, body) {
197
373
  const headers = { "content-type": "application/json" };
@@ -210,6 +386,9 @@ export function createSentinelPlugin(overrides) {
210
386
  await manager.init();
211
387
  },
212
388
  register(api) {
389
+ const runtimeConfig = resolveSentinelPluginConfig(api);
390
+ if (Object.keys(runtimeConfig).length > 0)
391
+ Object.assign(config, runtimeConfig);
213
392
  manager.setNotifier({
214
393
  async notify(target, message) {
215
394
  await notifyDeliveryTarget(api, target, message);
@@ -244,19 +423,46 @@ export function createSentinelPlugin(overrides) {
244
423
  }
245
424
  try {
246
425
  const payload = await readSentinelWebhookPayload(req);
247
- const sessionKey = config.hookSessionKey ?? DEFAULT_HOOK_SESSION_KEY;
248
- const text = buildSentinelSystemEvent(payload);
426
+ const envelope = buildSentinelEventEnvelope(payload);
427
+ const sessionKey = buildIsolatedHookSessionKey(envelope, config);
428
+ const text = buildSentinelSystemEvent(envelope);
249
429
  const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
250
430
  api.runtime.system.requestHeartbeatNow({
251
431
  reason: "hook:sentinel",
252
432
  sessionKey,
253
433
  });
434
+ const relayTargets = inferRelayTargets(payload, envelope);
435
+ const relayMessage = buildRelayMessage(envelope);
436
+ const relay = {
437
+ dedupeKey: envelope.dedupeKey,
438
+ attempted: relayTargets.length,
439
+ delivered: 0,
440
+ failed: 0,
441
+ deduped: false,
442
+ };
443
+ if (relayTargets.length > 0) {
444
+ if (!shouldRelayForDedupe(envelope.dedupeKey)) {
445
+ relay.deduped = true;
446
+ }
447
+ else {
448
+ await Promise.all(relayTargets.map(async (target) => {
449
+ try {
450
+ await notifyDeliveryTarget(api, target, relayMessage);
451
+ relay.delivered += 1;
452
+ }
453
+ catch {
454
+ relay.failed += 1;
455
+ }
456
+ }));
457
+ }
458
+ }
254
459
  res.writeHead(200, { "content-type": "application/json" });
255
460
  res.end(JSON.stringify({
256
461
  ok: true,
257
462
  route: path,
258
463
  sessionKey,
259
464
  enqueued,
465
+ relay,
260
466
  }));
261
467
  }
262
468
  catch (err) {
@@ -26,6 +26,8 @@ export declare const SentinelToolValidationSchema: import("@sinclair/typebox").T
26
26
  priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
27
27
  deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
28
28
  dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
+ notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
30
+ sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
31
  }>;
30
32
  retry: import("@sinclair/typebox").TObject<{
31
33
  maxRetries: import("@sinclair/typebox").TNumber;
@@ -74,6 +76,8 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
74
76
  priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
75
77
  deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
76
78
  dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
79
+ notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
80
+ sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
77
81
  }>;
78
82
  retry: import("@sinclair/typebox").TObject<{
79
83
  maxRetries: import("@sinclair/typebox").TNumber;
@@ -35,6 +35,17 @@ const FireConfigSchema = Type.Object({
35
35
  priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("normal"), Type.Literal("high"), Type.Literal("critical")], { description: "Callback urgency hint" })),
36
36
  deadlineTemplate: Type.Optional(Type.String({ description: "Optional templated deadline string for callback consumers" })),
37
37
  dedupeKeyTemplate: Type.Optional(Type.String({ description: "Optional template to derive deterministic trigger dedupe key" })),
38
+ notificationPayloadMode: Type.Optional(Type.Union([
39
+ Type.Literal("inherit"),
40
+ Type.Literal("none"),
41
+ Type.Literal("concise"),
42
+ Type.Literal("debug"),
43
+ ], {
44
+ description: "Notification payload mode override for deliveryTargets (inherit global default, suppress messages, concise relay text, or debug envelope block)",
45
+ })),
46
+ sessionGroup: Type.Optional(Type.String({
47
+ description: "Optional hook session group key. Watchers with the same key share one isolated callback-processing session.",
48
+ })),
38
49
  });
39
50
  const RetryPolicySchema = Type.Object({
40
51
  maxRetries: Type.Number({ description: "Maximum number of retry attempts" }),
package/dist/types.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface Condition {
7
7
  }
8
8
  export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
9
9
  export type PriorityLevel = "low" | "normal" | "high" | "critical";
10
+ export type NotificationPayloadMode = "none" | "concise" | "debug";
11
+ export type NotificationPayloadModeOverride = "inherit" | NotificationPayloadMode;
10
12
  export interface FireConfig {
11
13
  webhookPath?: string;
12
14
  eventName: string;
@@ -16,6 +18,8 @@ export interface FireConfig {
16
18
  priority?: PriorityLevel;
17
19
  deadlineTemplate?: string;
18
20
  dedupeKeyTemplate?: string;
21
+ notificationPayloadMode?: NotificationPayloadModeOverride;
22
+ sessionGroup?: string;
19
23
  }
20
24
  export interface RetryPolicy {
21
25
  maxRetries: number;
@@ -83,8 +87,13 @@ export interface SentinelConfig {
83
87
  allowedHosts: string[];
84
88
  localDispatchBase: string;
85
89
  dispatchAuthToken?: string;
90
+ /** @deprecated Backward-compatible alias for hookSessionPrefix. */
86
91
  hookSessionKey?: string;
92
+ hookSessionPrefix?: string;
93
+ hookSessionGroup?: string;
94
+ hookRelayDedupeWindowMs?: number;
87
95
  stateFilePath?: string;
96
+ notificationPayloadMode?: NotificationPayloadMode;
88
97
  limits: SentinelLimits;
89
98
  }
90
99
  export interface GatewayWebhookDispatcher {
@@ -25,6 +25,8 @@ export declare const WatcherSchema: import("@sinclair/typebox").TObject<{
25
25
  priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
26
26
  deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
27
27
  dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
28
+ notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
29
+ sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
28
30
  }>;
29
31
  retry: import("@sinclair/typebox").TObject<{
30
32
  maxRetries: import("@sinclair/typebox").TInteger;
package/dist/validator.js CHANGED
@@ -54,6 +54,13 @@ export const WatcherSchema = Type.Object({
54
54
  ])),
55
55
  deadlineTemplate: Type.Optional(Type.String({ minLength: 1 })),
56
56
  dedupeKeyTemplate: Type.Optional(Type.String({ minLength: 1 })),
57
+ notificationPayloadMode: Type.Optional(Type.Union([
58
+ Type.Literal("inherit"),
59
+ Type.Literal("none"),
60
+ Type.Literal("concise"),
61
+ Type.Literal("debug"),
62
+ ])),
63
+ sessionGroup: Type.Optional(Type.String({ minLength: 1 })),
57
64
  }, { additionalProperties: false }),
58
65
  retry: Type.Object({
59
66
  maxRetries: Type.Integer({ minimum: 0, maximum: 20 }),
@@ -10,6 +10,32 @@ import { sseStrategy } from "./strategies/sse.js";
10
10
  import { websocketStrategy } from "./strategies/websocket.js";
11
11
  import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
12
12
  export const RESET_BACKOFF_AFTER_MS = 60_000;
13
+ const MAX_DEBUG_NOTIFICATION_CHARS = 7000;
14
+ function trimForChat(text) {
15
+ if (text.length <= MAX_DEBUG_NOTIFICATION_CHARS)
16
+ return text;
17
+ return `${text.slice(0, MAX_DEBUG_NOTIFICATION_CHARS)}…`;
18
+ }
19
+ function resolveNotificationPayloadMode(config, watcher) {
20
+ const override = watcher.fire.notificationPayloadMode;
21
+ if (override === "none" || override === "concise" || override === "debug")
22
+ return override;
23
+ if (config.notificationPayloadMode === "none")
24
+ return "none";
25
+ return config.notificationPayloadMode === "debug" ? "debug" : "concise";
26
+ }
27
+ function buildDeliveryNotificationMessage(watcher, body, mode) {
28
+ const matchedAt = typeof body.trigger === "object" &&
29
+ body.trigger !== null &&
30
+ typeof body.trigger.matchedAt === "string"
31
+ ? body.trigger.matchedAt
32
+ : new Date().toISOString();
33
+ const concise = `Sentinel watcher "${watcher.id}" fired event "${watcher.fire.eventName}" at ${matchedAt}.`;
34
+ if (mode !== "debug")
35
+ return concise;
36
+ const envelopeJson = JSON.stringify(body, null, 2) ?? "{}";
37
+ return trimForChat(`${concise}\n\nSENTINEL_DEBUG_ENVELOPE_JSON:\n${envelopeJson}`);
38
+ }
13
39
  export const backoff = (base, max, failures) => {
14
40
  const raw = Math.min(max, base * 2 ** failures);
15
41
  const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
@@ -188,9 +214,14 @@ export class WatcherManager {
188
214
  webhookPath,
189
215
  });
190
216
  await this.dispatcher.dispatch(webhookPath, body);
191
- if (watcher.deliveryTargets?.length && this.notifier) {
217
+ const deliveryMode = resolveNotificationPayloadMode(this.config, watcher);
218
+ const isSentinelWebhook = webhookPath === DEFAULT_SENTINEL_WEBHOOK_PATH;
219
+ if (deliveryMode !== "none" &&
220
+ watcher.deliveryTargets?.length &&
221
+ this.notifier &&
222
+ !isSentinelWebhook) {
192
223
  const attemptedAt = new Date().toISOString();
193
- const message = JSON.stringify(body);
224
+ const message = buildDeliveryNotificationMessage(watcher, body, deliveryMode);
194
225
  const failures = [];
195
226
  let successCount = 0;
196
227
  await Promise.all(watcher.deliveryTargets.map(async (target) => {
@@ -21,13 +21,33 @@
21
21
  },
22
22
  "hookSessionKey": {
23
23
  "type": "string",
24
- "description": "Session key used when /hooks/sentinel enqueues system events into the LLM loop",
25
- "default": "agent:main:main"
24
+ "description": "Deprecated alias for hookSessionPrefix. Sentinel always appends watcher/group segments to prevent a shared global callback session."
25
+ },
26
+ "hookSessionPrefix": {
27
+ "type": "string",
28
+ "description": "Base session key prefix used for isolated /hooks/sentinel callback sessions (default: agent:main:hooks:sentinel)",
29
+ "default": "agent:main:hooks:sentinel"
30
+ },
31
+ "hookSessionGroup": {
32
+ "type": "string",
33
+ "description": "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session."
34
+ },
35
+ "hookRelayDedupeWindowMs": {
36
+ "type": "number",
37
+ "minimum": 0,
38
+ "description": "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
39
+ "default": 120000
26
40
  },
27
41
  "stateFilePath": {
28
42
  "type": "string",
29
43
  "description": "Custom path for the sentinel state persistence file"
30
44
  },
45
+ "notificationPayloadMode": {
46
+ "type": "string",
47
+ "enum": ["none", "concise", "debug"],
48
+ "description": "Controls delivery-target notifications: none (suppress message fan-out), concise relay text (default), or relay text with debug envelope payload",
49
+ "default": "concise"
50
+ },
31
51
  "limits": {
32
52
  "type": "object",
33
53
  "additionalProperties": false,
@@ -73,8 +93,23 @@
73
93
  "placeholder": "sk-..."
74
94
  },
75
95
  "hookSessionKey": {
76
- "label": "Sentinel Hook Session Key",
77
- "help": "Session key that receives /hooks/sentinel callback events (default: agent:main:main)",
96
+ "label": "Hook Session Key (Deprecated)",
97
+ "help": "Deprecated alias for hookSessionPrefix. Sentinel appends watcher/group segments automatically.",
98
+ "advanced": true
99
+ },
100
+ "hookSessionPrefix": {
101
+ "label": "Hook Session Prefix",
102
+ "help": "Base prefix for isolated callback sessions (default: agent:main:hooks:sentinel)",
103
+ "advanced": true
104
+ },
105
+ "hookSessionGroup": {
106
+ "label": "Default Hook Session Group",
107
+ "help": "Optional default group key for callback sessions. Watchers with the same group share one isolated session.",
108
+ "advanced": true
109
+ },
110
+ "hookRelayDedupeWindowMs": {
111
+ "label": "Hook Relay Dedupe Window (ms)",
112
+ "help": "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
78
113
  "advanced": true
79
114
  },
80
115
  "stateFilePath": {
@@ -82,6 +117,11 @@
82
117
  "help": "Custom path for sentinel state persistence file",
83
118
  "advanced": true
84
119
  },
120
+ "notificationPayloadMode": {
121
+ "label": "Notification Payload Mode",
122
+ "help": "Choose none (suppress delivery-target messages), concise relay text (default), or include debug envelope payload",
123
+ "advanced": true
124
+ },
85
125
  "limits.maxWatchersTotal": {
86
126
  "label": "Max Watchers",
87
127
  "help": "Maximum total watchers across all skills",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",