@coffeexdev/openclaw-sentinel 0.4.5 → 0.5.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
@@ -39,6 +39,17 @@ Add/update `~/.openclaw/openclaw.json`:
39
39
  // Optional: suppress duplicate relays by dedupe key within this time window.
40
40
  hookRelayDedupeWindowMs: 120000,
41
41
 
42
+ // Optional: guarantee hook-response delivery contract for /hooks/sentinel callbacks.
43
+ // Wait this long for assistant-authored output before fallback behavior applies.
44
+ hookResponseTimeoutMs: 30000,
45
+
46
+ // Optional: timeout fallback relay mode for /hooks/sentinel response contracts.
47
+ // "none" = no fallback message, "concise" = send a fail-safe relay line.
48
+ hookResponseFallbackMode: "concise",
49
+
50
+ // Optional: dedupe repeated callback response contracts by dedupe key.
51
+ hookResponseDedupeWindowMs: 120000,
52
+
42
53
  // Optional: payload style for non-/hooks/sentinel deliveryTargets notifications.
43
54
  // "none" suppresses delivery-target message fan-out (callback still fires).
44
55
  // "concise" (default) sends human-friendly relay text only.
@@ -134,12 +145,13 @@ Use `sentinel_control`:
134
145
 
135
146
  1. Sentinel evaluates conditions.
136
147
  2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
137
- 3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
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.
148
+ 3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
149
+ 4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
150
+ 5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
140
151
  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.
152
+ 7. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
141
153
 
142
- The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Relay notifications are dedupe-aware by callback dedupe key.
154
+ The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
143
155
 
144
156
  Sample emitted envelope:
145
157
 
@@ -158,6 +170,12 @@ Sample emitted envelope:
158
170
  "context": { "asset": "ETH", "priceUsd": 5001, "workflow": "alerts" },
159
171
  "payload": { "ethereum": { "usd": 5001 } },
160
172
  "deliveryTargets": [{ "channel": "telegram", "to": "5613673222" }],
173
+ "deliveryContext": {
174
+ "sessionKey": "agent:main:telegram:direct:5613673222",
175
+ "messageChannel": "telegram",
176
+ "requesterSenderId": "5613673222",
177
+ "currentChat": { "channel": "telegram", "to": "5613673222" }
178
+ },
161
179
  "source": { "plugin": "openclaw-sentinel", "route": "/hooks/sentinel" }
162
180
  }
163
181
  ```
@@ -226,7 +244,8 @@ It **does not** execute user-authored code from watcher definitions.
226
244
  ## Notification payload delivery modes
227
245
 
228
246
  Sentinel always dispatches the callback envelope to `localDispatchBase + webhookPath` on match.
229
- `notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets`.
247
+ `notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets` for watcher dispatches (for example `/hooks/agent`).
248
+ It does **not** control `/hooks/sentinel` hook-response contracts or assistant-output relay behavior.
230
249
 
231
250
  Global mode options:
232
251
 
@@ -308,6 +327,38 @@ Precedence: **watcher override > global setting**.
308
327
  - Existing installs keep default behavior (`concise`) unless you set `notificationPayloadMode` explicitly.
309
328
  - 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
329
 
330
+ ## Hook-response delivery contract (`/hooks/sentinel`)
331
+
332
+ `/hooks/sentinel` now enforces a dedicated trigger → LLM → user-visible relay contract:
333
+
334
+ 1. Callback is enqueued to isolated hook session.
335
+ 2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
336
+ 3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
337
+ 4. If no assistant output arrives in time (`hookResponseTimeoutMs`), fallback is configurable:
338
+ - `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
339
+ - `hookResponseFallbackMode: "none"` suppresses fallback.
340
+ 5. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
341
+
342
+ Example config:
343
+
344
+ ```json5
345
+ {
346
+ plugins: {
347
+ entries: {
348
+ "openclaw-sentinel": {
349
+ enabled: true,
350
+ config: {
351
+ allowedHosts: ["api.github.com"],
352
+ hookResponseTimeoutMs: 30000,
353
+ hookResponseFallbackMode: "concise",
354
+ hookResponseDedupeWindowMs: 120000,
355
+ },
356
+ },
357
+ },
358
+ },
359
+ }
360
+ ```
361
+
311
362
  ## Runtime controls
312
363
 
313
364
  ```json
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
2
3
  import { renderTemplate } from "./template.js";
3
4
  const MAX_PAYLOAD_JSON_CHARS = 4000;
4
5
  function toIntent(eventName) {
@@ -30,6 +31,32 @@ function truncatePayload(payload) {
30
31
  preview: serialized.slice(0, MAX_PAYLOAD_JSON_CHARS),
31
32
  };
32
33
  }
34
+ function buildDeliveryContextFromMetadata(watcher) {
35
+ const metadata = watcher.metadata;
36
+ if (!metadata)
37
+ return undefined;
38
+ const sessionKey = metadata[SENTINEL_ORIGIN_SESSION_KEY_METADATA]?.trim();
39
+ const channel = metadata[SENTINEL_ORIGIN_CHANNEL_METADATA]?.trim();
40
+ const to = metadata[SENTINEL_ORIGIN_TARGET_METADATA]?.trim();
41
+ const accountId = metadata[SENTINEL_ORIGIN_ACCOUNT_METADATA]?.trim();
42
+ const context = {};
43
+ if (sessionKey)
44
+ context.sessionKey = sessionKey;
45
+ if (channel)
46
+ context.messageChannel = channel;
47
+ if (to)
48
+ context.requesterSenderId = to;
49
+ if (accountId)
50
+ context.agentAccountId = accountId;
51
+ if (channel && to) {
52
+ context.currentChat = {
53
+ channel,
54
+ to,
55
+ ...(accountId ? { accountId } : {}),
56
+ };
57
+ }
58
+ return Object.keys(context).length > 0 ? context : undefined;
59
+ }
33
60
  function getPath(obj, path) {
34
61
  return path.split(".").reduce((acc, part) => acc?.[part], obj);
35
62
  }
@@ -73,6 +100,7 @@ export function createCallbackEnvelope(args) {
73
100
  const dedupeSeed = getTemplateString(watcher.fire.dedupeKeyTemplate, context) ??
74
101
  `${watcher.id}|${watcher.fire.eventName}|${matchedAt}`;
75
102
  const dedupeKey = createHash("sha256").update(dedupeSeed).digest("hex");
103
+ const deliveryContext = buildDeliveryContextFromMetadata(watcher);
76
104
  return {
77
105
  type: "sentinel.callback",
78
106
  version: "1",
@@ -90,6 +118,7 @@ export function createCallbackEnvelope(args) {
90
118
  ...(deadline ? { deadline } : {}),
91
119
  },
92
120
  ...(watcher.fire.sessionGroup ? { hookSessionGroup: watcher.fire.sessionGroup } : {}),
121
+ ...(deliveryContext ? { deliveryContext } : {}),
93
122
  context: renderedContext ?? summarizePayload(payload),
94
123
  payload: truncatePayload(payload),
95
124
  deliveryTargets: watcher.deliveryTargets ?? [],
@@ -11,6 +11,7 @@ const NotificationPayloadModeSchema = Type.Union([
11
11
  Type.Literal("concise"),
12
12
  Type.Literal("debug"),
13
13
  ]);
14
+ const HookResponseFallbackModeSchema = Type.Union([Type.Literal("none"), Type.Literal("concise")]);
14
15
  const ConfigSchema = Type.Object({
15
16
  allowedHosts: Type.Array(Type.String()),
16
17
  localDispatchBase: Type.String({ minLength: 1 }),
@@ -19,6 +20,9 @@ const ConfigSchema = Type.Object({
19
20
  hookSessionPrefix: Type.Optional(Type.String({ minLength: 1 })),
20
21
  hookSessionGroup: Type.Optional(Type.String({ minLength: 1 })),
21
22
  hookRelayDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
23
+ hookResponseTimeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
24
+ hookResponseFallbackMode: Type.Optional(HookResponseFallbackModeSchema),
25
+ hookResponseDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
22
26
  stateFilePath: Type.Optional(Type.String()),
23
27
  notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
24
28
  limits: Type.Optional(LimitsSchema),
@@ -37,6 +41,11 @@ function withDefaults(value) {
37
41
  : "agent:main:hooks:sentinel",
38
42
  hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
39
43
  hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
44
+ hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" ? value.hookResponseTimeoutMs : 30000,
45
+ hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
46
+ hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
47
+ ? value.hookResponseDedupeWindowMs
48
+ : 120000,
40
49
  stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
41
50
  notificationPayloadMode: value.notificationPayloadMode === "none"
42
51
  ? "none"
@@ -132,6 +141,24 @@ export const sentinelConfigSchema = {
132
141
  description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
133
142
  default: 120000,
134
143
  },
144
+ hookResponseTimeoutMs: {
145
+ type: "number",
146
+ minimum: 0,
147
+ description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
148
+ default: 30000,
149
+ },
150
+ hookResponseFallbackMode: {
151
+ type: "string",
152
+ enum: ["none", "concise"],
153
+ description: "Fallback behavior when no assistant response arrives before hookResponseTimeoutMs: none (silent timeout) or concise fail-safe relay",
154
+ default: "concise",
155
+ },
156
+ hookResponseDedupeWindowMs: {
157
+ type: "number",
158
+ minimum: 0,
159
+ description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
160
+ default: 120000,
161
+ },
135
162
  stateFilePath: {
136
163
  type: "string",
137
164
  description: "Custom path for the sentinel state persistence file",
@@ -206,6 +233,21 @@ export const sentinelConfigSchema = {
206
233
  help: "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
207
234
  advanced: true,
208
235
  },
236
+ hookResponseTimeoutMs: {
237
+ label: "Hook Response Timeout (ms)",
238
+ help: "How long to wait for assistant-authored hook output before optional fallback relay",
239
+ advanced: true,
240
+ },
241
+ hookResponseFallbackMode: {
242
+ label: "Hook Response Fallback Mode",
243
+ help: "If timeout occurs, choose none (silent) or concise fail-safe relay",
244
+ advanced: true,
245
+ },
246
+ hookResponseDedupeWindowMs: {
247
+ label: "Hook Response Dedupe Window (ms)",
248
+ help: "Deduplicate hook-response delivery contracts by dedupe key within this window",
249
+ advanced: true,
250
+ },
209
251
  stateFilePath: {
210
252
  label: "State File Path",
211
253
  help: "Custom path for sentinel state persistence file",
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sentinelConfigSchema } from "./configSchema.js";
3
3
  import { registerSentinelControl } from "./tool.js";
4
- import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
4
+ import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
5
5
  import { WatcherManager } from "./watcherManager.js";
6
6
  const registeredWebhookPathsByRegistrar = new WeakMap();
7
7
  const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
8
8
  const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
9
+ const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
9
11
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
10
12
  const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
11
13
  const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
@@ -98,6 +100,39 @@ function getNestedString(value, path) {
98
100
  }
99
101
  return asString(cursor);
100
102
  }
103
+ function extractDeliveryContext(payload) {
104
+ const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
105
+ if (!raw)
106
+ return undefined;
107
+ const sessionKey = asString(raw.sessionKey) ??
108
+ asString(raw.sourceSessionKey) ??
109
+ getNestedString(raw, ["source", "sessionKey"]);
110
+ const messageChannel = asString(raw.messageChannel);
111
+ const requesterSenderId = asString(raw.requesterSenderId);
112
+ const agentAccountId = asString(raw.agentAccountId);
113
+ const currentChat = isDeliveryTarget(raw.currentChat)
114
+ ? raw.currentChat
115
+ : isDeliveryTarget(raw.deliveryTarget)
116
+ ? raw.deliveryTarget
117
+ : undefined;
118
+ const deliveryTargets = Array.isArray(raw.deliveryTargets)
119
+ ? raw.deliveryTargets.filter(isDeliveryTarget)
120
+ : undefined;
121
+ const context = {};
122
+ if (sessionKey)
123
+ context.sessionKey = sessionKey;
124
+ if (messageChannel)
125
+ context.messageChannel = messageChannel;
126
+ if (requesterSenderId)
127
+ context.requesterSenderId = requesterSenderId;
128
+ if (agentAccountId)
129
+ context.agentAccountId = agentAccountId;
130
+ if (currentChat)
131
+ context.currentChat = currentChat;
132
+ if (deliveryTargets && deliveryTargets.length > 0)
133
+ context.deliveryTargets = deliveryTargets;
134
+ return Object.keys(context).length > 0 ? context : undefined;
135
+ }
101
136
  function buildSentinelEventEnvelope(payload) {
102
137
  const watcherId = asString(payload.watcherId) ??
103
138
  getNestedString(payload, ["watcher", "id"]) ??
@@ -136,6 +171,7 @@ function buildSentinelEventEnvelope(payload) {
136
171
  const hookSessionGroup = asString(payload.hookSessionGroup) ??
137
172
  asString(payload.sessionGroup) ??
138
173
  getNestedString(payload, ["watcher", "sessionGroup"]);
174
+ const deliveryContext = extractDeliveryContext(payload);
139
175
  const envelope = {
140
176
  watcherId: watcherId ?? null,
141
177
  eventName: eventName ?? null,
@@ -154,6 +190,8 @@ function buildSentinelEventEnvelope(payload) {
154
190
  envelope.hookSessionGroup = hookSessionGroup;
155
191
  if (deliveryTargets && deliveryTargets.length > 0)
156
192
  envelope.deliveryTargets = deliveryTargets;
193
+ if (deliveryContext)
194
+ envelope.deliveryContext = deliveryContext;
157
195
  return envelope;
158
196
  }
159
197
  function buildSentinelSystemEvent(envelope) {
@@ -192,10 +230,30 @@ function inferTargetFromSessionKey(sessionKey, accountId) {
192
230
  };
193
231
  }
194
232
  function inferRelayTargets(payload, envelope) {
195
- if (envelope.deliveryTargets?.length) {
196
- return normalizeDeliveryTargets(envelope.deliveryTargets);
197
- }
198
233
  const inferred = [];
234
+ if (envelope.deliveryTargets?.length)
235
+ inferred.push(...envelope.deliveryTargets);
236
+ if (envelope.deliveryContext?.deliveryTargets?.length) {
237
+ inferred.push(...envelope.deliveryContext.deliveryTargets);
238
+ }
239
+ if (envelope.deliveryContext?.currentChat)
240
+ inferred.push(envelope.deliveryContext.currentChat);
241
+ if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
242
+ if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
243
+ inferred.push({
244
+ channel: envelope.deliveryContext.messageChannel,
245
+ to: envelope.deliveryContext.requesterSenderId,
246
+ ...(envelope.deliveryContext.agentAccountId
247
+ ? { accountId: envelope.deliveryContext.agentAccountId }
248
+ : {}),
249
+ });
250
+ }
251
+ }
252
+ if (envelope.deliveryContext?.sessionKey) {
253
+ const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
254
+ if (target)
255
+ inferred.push(target);
256
+ }
199
257
  if (isDeliveryTarget(payload.currentChat))
200
258
  inferred.push(payload.currentChat);
201
259
  const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
@@ -247,6 +305,27 @@ function buildRelayMessage(envelope) {
247
305
  const text = lines.join("\n").trim();
248
306
  return text.length > 0 ? text : "Sentinel callback received.";
249
307
  }
308
+ function normalizeAssistantRelayText(assistantTexts) {
309
+ if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
310
+ return undefined;
311
+ const parts = assistantTexts.map((value) => value.trim()).filter(Boolean);
312
+ if (parts.length === 0)
313
+ return undefined;
314
+ return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
315
+ }
316
+ function resolveHookResponseDedupeWindowMs(config) {
317
+ const candidate = config.hookResponseDedupeWindowMs ??
318
+ config.hookRelayDedupeWindowMs ??
319
+ DEFAULT_RELAY_DEDUPE_WINDOW_MS;
320
+ return Math.max(0, candidate);
321
+ }
322
+ function resolveHookResponseTimeoutMs(config) {
323
+ const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
324
+ return Math.max(0, candidate);
325
+ }
326
+ function resolveHookResponseFallbackMode(config) {
327
+ return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
328
+ }
250
329
  function buildIsolatedHookSessionKey(envelope, config) {
251
330
  const rawPrefix = asString(config.hookSessionKey) ??
252
331
  asString(config.hookSessionPrefix) ??
@@ -336,6 +415,166 @@ async function notifyDeliveryTarget(api, target, message) {
336
415
  throw new Error(`Unsupported delivery target channel: ${target.channel}`);
337
416
  }
338
417
  }
418
+ async function deliverMessageToTargets(api, targets, message) {
419
+ if (targets.length === 0)
420
+ return { delivered: 0, failed: 0 };
421
+ const results = await Promise.all(targets.map(async (target) => {
422
+ try {
423
+ await notifyDeliveryTarget(api, target, message);
424
+ return true;
425
+ }
426
+ catch {
427
+ return false;
428
+ }
429
+ }));
430
+ const delivered = results.filter(Boolean).length;
431
+ return {
432
+ delivered,
433
+ failed: results.length - delivered,
434
+ };
435
+ }
436
+ class HookResponseRelayManager {
437
+ config;
438
+ api;
439
+ recentByDedupe = new Map();
440
+ pendingByDedupe = new Map();
441
+ pendingQueueBySession = new Map();
442
+ constructor(config, api) {
443
+ this.config = config;
444
+ this.api = api;
445
+ }
446
+ register(args) {
447
+ const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
448
+ const now = Date.now();
449
+ if (dedupeWindowMs > 0) {
450
+ for (const [key, ts] of this.recentByDedupe.entries()) {
451
+ if (now - ts > dedupeWindowMs) {
452
+ this.recentByDedupe.delete(key);
453
+ this.pendingByDedupe.delete(key);
454
+ }
455
+ }
456
+ }
457
+ const existingTs = this.recentByDedupe.get(args.dedupeKey);
458
+ if (dedupeWindowMs > 0 &&
459
+ typeof existingTs === "number" &&
460
+ now - existingTs <= dedupeWindowMs) {
461
+ return {
462
+ dedupeKey: args.dedupeKey,
463
+ attempted: args.relayTargets.length,
464
+ delivered: 0,
465
+ failed: 0,
466
+ deduped: true,
467
+ pending: false,
468
+ timeoutMs: resolveHookResponseTimeoutMs(this.config),
469
+ fallbackMode: resolveHookResponseFallbackMode(this.config),
470
+ };
471
+ }
472
+ this.recentByDedupe.set(args.dedupeKey, now);
473
+ const timeoutMs = resolveHookResponseTimeoutMs(this.config);
474
+ const fallbackMode = resolveHookResponseFallbackMode(this.config);
475
+ if (args.relayTargets.length === 0) {
476
+ return {
477
+ dedupeKey: args.dedupeKey,
478
+ attempted: 0,
479
+ delivered: 0,
480
+ failed: 0,
481
+ deduped: false,
482
+ pending: false,
483
+ timeoutMs,
484
+ fallbackMode,
485
+ };
486
+ }
487
+ const pending = {
488
+ dedupeKey: args.dedupeKey,
489
+ sessionKey: args.sessionKey,
490
+ relayTargets: args.relayTargets,
491
+ fallbackMessage: args.fallbackMessage,
492
+ createdAt: now,
493
+ timeoutMs,
494
+ fallbackMode,
495
+ state: "pending",
496
+ };
497
+ this.pendingByDedupe.set(args.dedupeKey, pending);
498
+ const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
499
+ queue.push(args.dedupeKey);
500
+ this.pendingQueueBySession.set(args.sessionKey, queue);
501
+ if (timeoutMs === 0) {
502
+ void this.handleTimeout(args.dedupeKey);
503
+ }
504
+ else {
505
+ pending.timer = setTimeout(() => {
506
+ void this.handleTimeout(args.dedupeKey);
507
+ }, timeoutMs);
508
+ }
509
+ return {
510
+ dedupeKey: args.dedupeKey,
511
+ attempted: args.relayTargets.length,
512
+ delivered: 0,
513
+ failed: 0,
514
+ deduped: false,
515
+ pending: true,
516
+ timeoutMs,
517
+ fallbackMode,
518
+ };
519
+ }
520
+ async handleLlmOutput(sessionKey, assistantTexts) {
521
+ if (!sessionKey)
522
+ return;
523
+ const assistantMessage = normalizeAssistantRelayText(assistantTexts);
524
+ if (!assistantMessage)
525
+ return;
526
+ const dedupeKey = this.popNextPendingDedupe(sessionKey);
527
+ if (!dedupeKey)
528
+ return;
529
+ const pending = this.pendingByDedupe.get(dedupeKey);
530
+ if (!pending || pending.state !== "pending")
531
+ return;
532
+ await this.completeWithMessage(pending, assistantMessage, "assistant");
533
+ }
534
+ popNextPendingDedupe(sessionKey) {
535
+ const queue = this.pendingQueueBySession.get(sessionKey);
536
+ if (!queue || queue.length === 0)
537
+ return undefined;
538
+ while (queue.length > 0) {
539
+ const next = queue.shift();
540
+ if (!next)
541
+ continue;
542
+ const pending = this.pendingByDedupe.get(next);
543
+ if (pending && pending.state === "pending") {
544
+ if (queue.length === 0)
545
+ this.pendingQueueBySession.delete(sessionKey);
546
+ else
547
+ this.pendingQueueBySession.set(sessionKey, queue);
548
+ return next;
549
+ }
550
+ }
551
+ this.pendingQueueBySession.delete(sessionKey);
552
+ return undefined;
553
+ }
554
+ async handleTimeout(dedupeKey) {
555
+ const pending = this.pendingByDedupe.get(dedupeKey);
556
+ if (!pending || pending.state !== "pending")
557
+ return;
558
+ if (pending.fallbackMode === "none") {
559
+ this.markClosed(pending, "timed_out");
560
+ return;
561
+ }
562
+ await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
563
+ }
564
+ async completeWithMessage(pending, message, source) {
565
+ const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
566
+ this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
567
+ this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
568
+ }
569
+ markClosed(pending, state) {
570
+ pending.state = state;
571
+ if (pending.timer) {
572
+ clearTimeout(pending.timer);
573
+ pending.timer = undefined;
574
+ }
575
+ this.pendingByDedupe.set(pending.dedupeKey, pending);
576
+ }
577
+ }
339
578
  export function createSentinelPlugin(overrides) {
340
579
  const config = {
341
580
  allowedHosts: [],
@@ -343,6 +582,9 @@ export function createSentinelPlugin(overrides) {
343
582
  dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
344
583
  hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
345
584
  hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
585
+ hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
586
+ hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
587
+ hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
346
588
  notificationPayloadMode: "concise",
347
589
  limits: {
348
590
  maxWatchersTotal: 200,
@@ -352,22 +594,6 @@ export function createSentinelPlugin(overrides) {
352
594
  },
353
595
  ...overrides,
354
596
  };
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
- };
371
597
  const manager = new WatcherManager(config, {
372
598
  async dispatch(path, body) {
373
599
  const headers = { "content-type": "application/json" };
@@ -389,6 +615,12 @@ export function createSentinelPlugin(overrides) {
389
615
  const runtimeConfig = resolveSentinelPluginConfig(api);
390
616
  if (Object.keys(runtimeConfig).length > 0)
391
617
  Object.assign(config, runtimeConfig);
618
+ const hookResponseRelayManager = new HookResponseRelayManager(config, api);
619
+ if (typeof api.on === "function") {
620
+ api.on("llm_output", (event, ctx) => {
621
+ void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
622
+ });
623
+ }
392
624
  manager.setNotifier({
393
625
  async notify(target, message) {
394
626
  await notifyDeliveryTarget(api, target, message);
@@ -432,30 +664,12 @@ export function createSentinelPlugin(overrides) {
432
664
  sessionKey,
433
665
  });
434
666
  const relayTargets = inferRelayTargets(payload, envelope);
435
- const relayMessage = buildRelayMessage(envelope);
436
- const relay = {
667
+ const relay = hookResponseRelayManager.register({
437
668
  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
- }
669
+ sessionKey,
670
+ relayTargets,
671
+ fallbackMessage: buildRelayMessage(envelope),
672
+ });
459
673
  res.writeHead(200, { "content-type": "application/json" });
460
674
  res.end(JSON.stringify({
461
675
  ok: true,
package/dist/tool.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "openclaw/plugin-sdk";
2
2
  import { Value } from "@sinclair/typebox/value";
3
+ import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
3
4
  import { SentinelToolSchema, SentinelToolValidationSchema } from "./toolSchema.js";
4
5
  import { TemplateValueSchema } from "./templateValueSchema.js";
5
6
  function validateParams(params) {
@@ -63,6 +64,30 @@ function inferDefaultDeliveryTargets(ctx) {
63
64
  }
64
65
  return [];
65
66
  }
67
+ function maybeSetMetadata(metadata, key, value) {
68
+ const trimmed = value?.trim();
69
+ if (!trimmed)
70
+ return;
71
+ if (!metadata[key])
72
+ metadata[key] = trimmed;
73
+ }
74
+ function addOriginDeliveryMetadata(watcher, ctx) {
75
+ const metadataRaw = watcher.metadata;
76
+ const metadata = metadataRaw && typeof metadataRaw === "object" && !Array.isArray(metadataRaw)
77
+ ? { ...metadataRaw }
78
+ : {};
79
+ const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
80
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_SESSION_KEY_METADATA, ctx.sessionKey);
81
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_CHANNEL_METADATA, ctx.messageChannel);
82
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_TARGET_METADATA, ctx.requesterSenderId ?? sessionPeer);
83
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_ACCOUNT_METADATA, ctx.agentAccountId);
84
+ if (Object.keys(metadata).length === 0)
85
+ return watcher;
86
+ return {
87
+ ...watcher,
88
+ metadata,
89
+ };
90
+ }
66
91
  export function registerSentinelControl(registerTool, manager) {
67
92
  registerTool((ctx) => ({
68
93
  name: "sentinel_control",
@@ -73,10 +98,12 @@ export function registerSentinelControl(registerTool, manager) {
73
98
  const payload = validateParams(params);
74
99
  switch (payload.action) {
75
100
  case "create":
76
- case "add":
77
- return normalizeToolResultText(await manager.create(payload.watcher, {
101
+ case "add": {
102
+ const watcherWithContext = addOriginDeliveryMetadata(payload.watcher, ctx);
103
+ return normalizeToolResultText(await manager.create(watcherWithContext, {
78
104
  deliveryTargets: inferDefaultDeliveryTargets(ctx),
79
105
  }), "Watcher created");
106
+ }
80
107
  case "enable":
81
108
  await manager.enable(payload.id);
82
109
  return normalizeToolResultText(undefined, `Enabled watcher: ${payload.id}`);
package/dist/types.d.ts CHANGED
@@ -9,6 +9,11 @@ export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
9
9
  export type PriorityLevel = "low" | "normal" | "high" | "critical";
10
10
  export type NotificationPayloadMode = "none" | "concise" | "debug";
11
11
  export type NotificationPayloadModeOverride = "inherit" | NotificationPayloadMode;
12
+ export type HookResponseFallbackMode = "none" | "concise";
13
+ export declare const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
14
+ export declare const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
15
+ export declare const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
16
+ export declare const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
12
17
  export interface FireConfig {
13
18
  webhookPath?: string;
14
19
  eventName: string;
@@ -92,6 +97,9 @@ export interface SentinelConfig {
92
97
  hookSessionPrefix?: string;
93
98
  hookSessionGroup?: string;
94
99
  hookRelayDedupeWindowMs?: number;
100
+ hookResponseTimeoutMs?: number;
101
+ hookResponseFallbackMode?: HookResponseFallbackMode;
102
+ hookResponseDedupeWindowMs?: number;
95
103
  stateFilePath?: string;
96
104
  notificationPayloadMode?: NotificationPayloadMode;
97
105
  limits: SentinelLimits;
package/dist/types.js CHANGED
@@ -1 +1,5 @@
1
1
  export const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
2
+ export const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
3
+ export const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
4
+ export const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
5
+ export const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
@@ -6,7 +6,9 @@
6
6
  "properties": {
7
7
  "allowedHosts": {
8
8
  "type": "array",
9
- "items": { "type": "string" },
9
+ "items": {
10
+ "type": "string"
11
+ },
10
12
  "description": "Hostnames the watchers are permitted to connect to. Must be explicitly configured — no hosts are allowed by default.",
11
13
  "default": []
12
14
  },
@@ -74,6 +76,24 @@
74
76
  "default": 1000
75
77
  }
76
78
  }
79
+ },
80
+ "hookResponseTimeoutMs": {
81
+ "type": "number",
82
+ "minimum": 0,
83
+ "description": "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
84
+ "default": 30000
85
+ },
86
+ "hookResponseFallbackMode": {
87
+ "type": "string",
88
+ "enum": ["none", "concise"],
89
+ "description": "Fallback behavior when no assistant response arrives before hookResponseTimeoutMs: none (silent timeout) or concise fail-safe relay",
90
+ "default": "concise"
91
+ },
92
+ "hookResponseDedupeWindowMs": {
93
+ "type": "number",
94
+ "minimum": 0,
95
+ "description": "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
96
+ "default": 120000
77
97
  }
78
98
  }
79
99
  },
@@ -141,6 +161,21 @@
141
161
  "label": "Min Poll Interval (ms)",
142
162
  "help": "Minimum allowed polling interval in milliseconds",
143
163
  "advanced": true
164
+ },
165
+ "hookResponseTimeoutMs": {
166
+ "label": "Hook Response Timeout (ms)",
167
+ "help": "How long to wait for assistant-authored hook output before optional fallback relay",
168
+ "advanced": true
169
+ },
170
+ "hookResponseFallbackMode": {
171
+ "label": "Hook Response Fallback Mode",
172
+ "help": "If timeout occurs, choose none (silent) or concise fail-safe relay",
173
+ "advanced": true
174
+ },
175
+ "hookResponseDedupeWindowMs": {
176
+ "label": "Hook Response Dedupe Window (ms)",
177
+ "help": "Deduplicate hook-response delivery contracts by dedupe key within this window",
178
+ "advanced": true
144
179
  }
145
180
  },
146
181
  "install": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",