@coffeexdev/openclaw-sentinel 0.4.5 → 0.5.1

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 with a cron-tagged callback context, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
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,14 +1,19 @@
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;
14
+ const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
15
+ const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
16
+ const HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
12
17
  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
18
  const SUPPORTED_DELIVERY_CHANNELS = new Set([
14
19
  "telegram",
@@ -98,6 +103,39 @@ function getNestedString(value, path) {
98
103
  }
99
104
  return asString(cursor);
100
105
  }
106
+ function extractDeliveryContext(payload) {
107
+ const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
108
+ if (!raw)
109
+ return undefined;
110
+ const sessionKey = asString(raw.sessionKey) ??
111
+ asString(raw.sourceSessionKey) ??
112
+ getNestedString(raw, ["source", "sessionKey"]);
113
+ const messageChannel = asString(raw.messageChannel);
114
+ const requesterSenderId = asString(raw.requesterSenderId);
115
+ const agentAccountId = asString(raw.agentAccountId);
116
+ const currentChat = isDeliveryTarget(raw.currentChat)
117
+ ? raw.currentChat
118
+ : isDeliveryTarget(raw.deliveryTarget)
119
+ ? raw.deliveryTarget
120
+ : undefined;
121
+ const deliveryTargets = Array.isArray(raw.deliveryTargets)
122
+ ? raw.deliveryTargets.filter(isDeliveryTarget)
123
+ : undefined;
124
+ const context = {};
125
+ if (sessionKey)
126
+ context.sessionKey = sessionKey;
127
+ if (messageChannel)
128
+ context.messageChannel = messageChannel;
129
+ if (requesterSenderId)
130
+ context.requesterSenderId = requesterSenderId;
131
+ if (agentAccountId)
132
+ context.agentAccountId = agentAccountId;
133
+ if (currentChat)
134
+ context.currentChat = currentChat;
135
+ if (deliveryTargets && deliveryTargets.length > 0)
136
+ context.deliveryTargets = deliveryTargets;
137
+ return Object.keys(context).length > 0 ? context : undefined;
138
+ }
101
139
  function buildSentinelEventEnvelope(payload) {
102
140
  const watcherId = asString(payload.watcherId) ??
103
141
  getNestedString(payload, ["watcher", "id"]) ??
@@ -136,6 +174,7 @@ function buildSentinelEventEnvelope(payload) {
136
174
  const hookSessionGroup = asString(payload.hookSessionGroup) ??
137
175
  asString(payload.sessionGroup) ??
138
176
  getNestedString(payload, ["watcher", "sessionGroup"]);
177
+ const deliveryContext = extractDeliveryContext(payload);
139
178
  const envelope = {
140
179
  watcherId: watcherId ?? null,
141
180
  eventName: eventName ?? null,
@@ -154,6 +193,8 @@ function buildSentinelEventEnvelope(payload) {
154
193
  envelope.hookSessionGroup = hookSessionGroup;
155
194
  if (deliveryTargets && deliveryTargets.length > 0)
156
195
  envelope.deliveryTargets = deliveryTargets;
196
+ if (deliveryContext)
197
+ envelope.deliveryContext = deliveryContext;
157
198
  return envelope;
158
199
  }
159
200
  function buildSentinelSystemEvent(envelope) {
@@ -192,10 +233,30 @@ function inferTargetFromSessionKey(sessionKey, accountId) {
192
233
  };
193
234
  }
194
235
  function inferRelayTargets(payload, envelope) {
195
- if (envelope.deliveryTargets?.length) {
196
- return normalizeDeliveryTargets(envelope.deliveryTargets);
197
- }
198
236
  const inferred = [];
237
+ if (envelope.deliveryTargets?.length)
238
+ inferred.push(...envelope.deliveryTargets);
239
+ if (envelope.deliveryContext?.deliveryTargets?.length) {
240
+ inferred.push(...envelope.deliveryContext.deliveryTargets);
241
+ }
242
+ if (envelope.deliveryContext?.currentChat)
243
+ inferred.push(envelope.deliveryContext.currentChat);
244
+ if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
245
+ if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
246
+ inferred.push({
247
+ channel: envelope.deliveryContext.messageChannel,
248
+ to: envelope.deliveryContext.requesterSenderId,
249
+ ...(envelope.deliveryContext.agentAccountId
250
+ ? { accountId: envelope.deliveryContext.agentAccountId }
251
+ : {}),
252
+ });
253
+ }
254
+ }
255
+ if (envelope.deliveryContext?.sessionKey) {
256
+ const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
257
+ if (target)
258
+ inferred.push(target);
259
+ }
199
260
  if (isDeliveryTarget(payload.currentChat))
200
261
  inferred.push(payload.currentChat);
201
262
  const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
@@ -245,7 +306,32 @@ function buildRelayMessage(envelope) {
245
306
  if (contextSummary)
246
307
  lines.push(contextSummary);
247
308
  const text = lines.join("\n").trim();
248
- return text.length > 0 ? text : "Sentinel callback received.";
309
+ return text.length > 0
310
+ ? text
311
+ : "Sentinel callback received, but no assistant detail was generated.";
312
+ }
313
+ function normalizeAssistantRelayText(assistantTexts) {
314
+ if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
315
+ return undefined;
316
+ const parts = assistantTexts
317
+ .map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
318
+ .filter(Boolean);
319
+ if (parts.length === 0)
320
+ return undefined;
321
+ return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
322
+ }
323
+ function resolveHookResponseDedupeWindowMs(config) {
324
+ const candidate = config.hookResponseDedupeWindowMs ??
325
+ config.hookRelayDedupeWindowMs ??
326
+ DEFAULT_RELAY_DEDUPE_WINDOW_MS;
327
+ return Math.max(0, candidate);
328
+ }
329
+ function resolveHookResponseTimeoutMs(config) {
330
+ const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
331
+ return Math.max(0, candidate);
332
+ }
333
+ function resolveHookResponseFallbackMode(config) {
334
+ return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
249
335
  }
250
336
  function buildIsolatedHookSessionKey(envelope, config) {
251
337
  const rawPrefix = asString(config.hookSessionKey) ??
@@ -336,6 +422,166 @@ async function notifyDeliveryTarget(api, target, message) {
336
422
  throw new Error(`Unsupported delivery target channel: ${target.channel}`);
337
423
  }
338
424
  }
425
+ async function deliverMessageToTargets(api, targets, message) {
426
+ if (targets.length === 0)
427
+ return { delivered: 0, failed: 0 };
428
+ const results = await Promise.all(targets.map(async (target) => {
429
+ try {
430
+ await notifyDeliveryTarget(api, target, message);
431
+ return true;
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ }));
437
+ const delivered = results.filter(Boolean).length;
438
+ return {
439
+ delivered,
440
+ failed: results.length - delivered,
441
+ };
442
+ }
443
+ class HookResponseRelayManager {
444
+ config;
445
+ api;
446
+ recentByDedupe = new Map();
447
+ pendingByDedupe = new Map();
448
+ pendingQueueBySession = new Map();
449
+ constructor(config, api) {
450
+ this.config = config;
451
+ this.api = api;
452
+ }
453
+ register(args) {
454
+ const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
455
+ const now = Date.now();
456
+ if (dedupeWindowMs > 0) {
457
+ for (const [key, ts] of this.recentByDedupe.entries()) {
458
+ if (now - ts > dedupeWindowMs) {
459
+ this.recentByDedupe.delete(key);
460
+ this.pendingByDedupe.delete(key);
461
+ }
462
+ }
463
+ }
464
+ const existingTs = this.recentByDedupe.get(args.dedupeKey);
465
+ if (dedupeWindowMs > 0 &&
466
+ typeof existingTs === "number" &&
467
+ now - existingTs <= dedupeWindowMs) {
468
+ return {
469
+ dedupeKey: args.dedupeKey,
470
+ attempted: args.relayTargets.length,
471
+ delivered: 0,
472
+ failed: 0,
473
+ deduped: true,
474
+ pending: false,
475
+ timeoutMs: resolveHookResponseTimeoutMs(this.config),
476
+ fallbackMode: resolveHookResponseFallbackMode(this.config),
477
+ };
478
+ }
479
+ this.recentByDedupe.set(args.dedupeKey, now);
480
+ const timeoutMs = resolveHookResponseTimeoutMs(this.config);
481
+ const fallbackMode = resolveHookResponseFallbackMode(this.config);
482
+ if (args.relayTargets.length === 0) {
483
+ return {
484
+ dedupeKey: args.dedupeKey,
485
+ attempted: 0,
486
+ delivered: 0,
487
+ failed: 0,
488
+ deduped: false,
489
+ pending: false,
490
+ timeoutMs,
491
+ fallbackMode,
492
+ };
493
+ }
494
+ const pending = {
495
+ dedupeKey: args.dedupeKey,
496
+ sessionKey: args.sessionKey,
497
+ relayTargets: args.relayTargets,
498
+ fallbackMessage: args.fallbackMessage,
499
+ createdAt: now,
500
+ timeoutMs,
501
+ fallbackMode,
502
+ state: "pending",
503
+ };
504
+ this.pendingByDedupe.set(args.dedupeKey, pending);
505
+ const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
506
+ queue.push(args.dedupeKey);
507
+ this.pendingQueueBySession.set(args.sessionKey, queue);
508
+ if (timeoutMs === 0) {
509
+ void this.handleTimeout(args.dedupeKey);
510
+ }
511
+ else {
512
+ pending.timer = setTimeout(() => {
513
+ void this.handleTimeout(args.dedupeKey);
514
+ }, timeoutMs);
515
+ }
516
+ return {
517
+ dedupeKey: args.dedupeKey,
518
+ attempted: args.relayTargets.length,
519
+ delivered: 0,
520
+ failed: 0,
521
+ deduped: false,
522
+ pending: true,
523
+ timeoutMs,
524
+ fallbackMode,
525
+ };
526
+ }
527
+ async handleLlmOutput(sessionKey, assistantTexts) {
528
+ if (!sessionKey)
529
+ return;
530
+ const assistantMessage = normalizeAssistantRelayText(assistantTexts);
531
+ if (!assistantMessage)
532
+ return;
533
+ const dedupeKey = this.popNextPendingDedupe(sessionKey);
534
+ if (!dedupeKey)
535
+ return;
536
+ const pending = this.pendingByDedupe.get(dedupeKey);
537
+ if (!pending || pending.state !== "pending")
538
+ return;
539
+ await this.completeWithMessage(pending, assistantMessage, "assistant");
540
+ }
541
+ popNextPendingDedupe(sessionKey) {
542
+ const queue = this.pendingQueueBySession.get(sessionKey);
543
+ if (!queue || queue.length === 0)
544
+ return undefined;
545
+ while (queue.length > 0) {
546
+ const next = queue.shift();
547
+ if (!next)
548
+ continue;
549
+ const pending = this.pendingByDedupe.get(next);
550
+ if (pending && pending.state === "pending") {
551
+ if (queue.length === 0)
552
+ this.pendingQueueBySession.delete(sessionKey);
553
+ else
554
+ this.pendingQueueBySession.set(sessionKey, queue);
555
+ return next;
556
+ }
557
+ }
558
+ this.pendingQueueBySession.delete(sessionKey);
559
+ return undefined;
560
+ }
561
+ async handleTimeout(dedupeKey) {
562
+ const pending = this.pendingByDedupe.get(dedupeKey);
563
+ if (!pending || pending.state !== "pending")
564
+ return;
565
+ if (pending.fallbackMode === "none") {
566
+ this.markClosed(pending, "timed_out");
567
+ return;
568
+ }
569
+ await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
570
+ }
571
+ async completeWithMessage(pending, message, source) {
572
+ const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
573
+ this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
574
+ this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
575
+ }
576
+ markClosed(pending, state) {
577
+ pending.state = state;
578
+ if (pending.timer) {
579
+ clearTimeout(pending.timer);
580
+ pending.timer = undefined;
581
+ }
582
+ this.pendingByDedupe.set(pending.dedupeKey, pending);
583
+ }
584
+ }
339
585
  export function createSentinelPlugin(overrides) {
340
586
  const config = {
341
587
  allowedHosts: [],
@@ -343,6 +589,9 @@ export function createSentinelPlugin(overrides) {
343
589
  dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
344
590
  hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
345
591
  hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
592
+ hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
593
+ hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
594
+ hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
346
595
  notificationPayloadMode: "concise",
347
596
  limits: {
348
597
  maxWatchersTotal: 200,
@@ -352,22 +601,6 @@ export function createSentinelPlugin(overrides) {
352
601
  },
353
602
  ...overrides,
354
603
  };
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
604
  const manager = new WatcherManager(config, {
372
605
  async dispatch(path, body) {
373
606
  const headers = { "content-type": "application/json" };
@@ -389,6 +622,12 @@ export function createSentinelPlugin(overrides) {
389
622
  const runtimeConfig = resolveSentinelPluginConfig(api);
390
623
  if (Object.keys(runtimeConfig).length > 0)
391
624
  Object.assign(config, runtimeConfig);
625
+ const hookResponseRelayManager = new HookResponseRelayManager(config, api);
626
+ if (typeof api.on === "function") {
627
+ api.on("llm_output", (event, ctx) => {
628
+ void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
629
+ });
630
+ }
392
631
  manager.setNotifier({
393
632
  async notify(target, message) {
394
633
  await notifyDeliveryTarget(api, target, message);
@@ -426,36 +665,21 @@ export function createSentinelPlugin(overrides) {
426
665
  const envelope = buildSentinelEventEnvelope(payload);
427
666
  const sessionKey = buildIsolatedHookSessionKey(envelope, config);
428
667
  const text = buildSentinelSystemEvent(envelope);
429
- const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
668
+ const enqueued = api.runtime.system.enqueueSystemEvent(text, {
669
+ sessionKey,
670
+ contextKey: SENTINEL_CALLBACK_CONTEXT_KEY,
671
+ });
430
672
  api.runtime.system.requestHeartbeatNow({
431
- reason: "hook:sentinel",
673
+ reason: SENTINEL_CALLBACK_WAKE_REASON,
432
674
  sessionKey,
433
675
  });
434
676
  const relayTargets = inferRelayTargets(payload, envelope);
435
- const relayMessage = buildRelayMessage(envelope);
436
- const relay = {
677
+ const relay = hookResponseRelayManager.register({
437
678
  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
- }
679
+ sessionKey,
680
+ relayTargets,
681
+ fallbackMessage: buildRelayMessage(envelope),
682
+ });
459
683
  res.writeHead(200, { "content-type": "application/json" });
460
684
  res.end(JSON.stringify({
461
685
  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.1",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",