@coffeexdev/openclaw-sentinel 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,11 +88,24 @@ Use `sentinel_control`:
88
88
 
89
89
  1. Sentinel evaluates conditions.
90
90
  2. On match, it dispatches to `localDispatchBase + webhookPath`.
91
- 3. For `/hooks/sentinel`, the plugin route enqueues a system event and requests heartbeat wake.
92
- 4. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
91
+ 3. 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).
92
+ 4. For `/hooks/sentinel`, the plugin route enqueues a system event (instruction prefix + structured JSON envelope) and requests heartbeat wake.
93
+ 5. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
93
94
 
94
95
  The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
95
96
 
97
+ ### `/hooks/sentinel` wake event format
98
+
99
+ Sentinel enqueues deterministic system-event text in this shape:
100
+
101
+ ```text
102
+ SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.
103
+ SENTINEL_ENVELOPE_JSON:
104
+ { ...stable JSON envelope... }
105
+ ```
106
+
107
+ Envelope keys: `watcherId`, `eventName`, `skillId` (if present), `matchedAt`, `payload` (bounded with truncation marker when clipped), `dedupeKey`, `correlationId`, `deliveryTargets` (if present), `source` (`route`, `plugin`).
108
+
96
109
  ## Why Sentinel
97
110
 
98
111
  Sentinel runs watcher lifecycles inside the gateway with fixed strategies and declarative conditions.
@@ -143,11 +156,17 @@ It **does not** execute user-authored code from watcher definitions.
143
156
  "ts": "${timestamp}"
144
157
  }
145
158
  },
146
- "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
159
+ "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 },
160
+ "deliveryTargets": [
161
+ { "channel": "telegram", "to": "5613673222" },
162
+ { "channel": "discord", "to": "123456789012345678", "accountId": "main" }
163
+ ]
147
164
  }
148
165
  }
149
166
  ```
150
167
 
168
+ `deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
169
+
151
170
  ## Runtime controls
152
171
 
153
172
  ```json
@@ -185,10 +204,10 @@ openclaw-sentinel audit
185
204
  ## Development
186
205
 
187
206
  ```bash
188
- npm i
189
- npm run lint
190
- npm run test
191
- npm run build
207
+ pnpm install
208
+ pnpm run lint
209
+ pnpm test
210
+ pnpm run build
192
211
  ```
193
212
 
194
213
  ## License
package/dist/cli.js CHANGED
File without changes
@@ -87,7 +87,6 @@ export const sentinelConfigSchema = {
87
87
  },
88
88
  localDispatchBase: {
89
89
  type: "string",
90
- format: "uri",
91
90
  description: "Base URL for internal webhook dispatch",
92
91
  default: "http://127.0.0.1:18789",
93
92
  },
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { sentinelConfigSchema } from "./configSchema.js";
2
3
  import { registerSentinelControl } from "./tool.js";
3
4
  import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
@@ -5,14 +6,9 @@ import { WatcherManager } from "./watcherManager.js";
5
6
  const registeredWebhookPathsByRegistrar = new WeakMap();
6
7
  const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
7
8
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
8
- const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 2000;
9
- function normalizePath(path) {
10
- const trimmed = path.trim();
11
- if (!trimmed)
12
- return DEFAULT_SENTINEL_WEBHOOK_PATH;
13
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
14
- return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
15
- }
9
+ const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
10
+ const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
11
+ 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.";
16
12
  function trimText(value, max) {
17
13
  return value.length <= max ? value : `${value.slice(0, max)}…`;
18
14
  }
@@ -22,22 +18,93 @@ function asString(value) {
22
18
  const trimmed = value.trim();
23
19
  return trimmed.length > 0 ? trimmed : undefined;
24
20
  }
21
+ function asIsoString(value) {
22
+ const text = asString(value);
23
+ if (!text)
24
+ return undefined;
25
+ const timestamp = Date.parse(text);
26
+ return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString();
27
+ }
25
28
  function isRecord(value) {
26
29
  return !!value && typeof value === "object" && !Array.isArray(value);
27
30
  }
28
- function buildSentinelSystemEvent(payload) {
29
- const text = asString(payload.text) ?? asString(payload.message);
30
- if (text)
31
- return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
32
- const watcherId = asString(payload.watcherId);
31
+ function isDeliveryTarget(value) {
32
+ return (isRecord(value) &&
33
+ typeof value.channel === "string" &&
34
+ typeof value.to === "string" &&
35
+ (value.accountId === undefined || typeof value.accountId === "string"));
36
+ }
37
+ function normalizePath(path) {
38
+ const trimmed = path.trim();
39
+ if (!trimmed)
40
+ return DEFAULT_SENTINEL_WEBHOOK_PATH;
41
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
+ return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
43
+ }
44
+ function clipPayloadForPrompt(value) {
45
+ const serialized = JSON.stringify(value);
46
+ if (!serialized)
47
+ return value;
48
+ if (serialized.length <= MAX_SENTINEL_PAYLOAD_JSON_CHARS)
49
+ return value;
50
+ const clipped = serialized.slice(0, MAX_SENTINEL_PAYLOAD_JSON_CHARS);
51
+ const overflow = serialized.length - clipped.length;
52
+ return {
53
+ __truncated: true,
54
+ truncatedChars: overflow,
55
+ maxChars: MAX_SENTINEL_PAYLOAD_JSON_CHARS,
56
+ preview: `${clipped}…`,
57
+ };
58
+ }
59
+ function buildSentinelEventEnvelope(payload) {
60
+ const watcherId = asString(payload.watcherId) ??
61
+ (isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
33
62
  const eventName = asString(payload.eventName) ??
34
63
  (isRecord(payload.event) ? asString(payload.event.name) : undefined);
35
- const labels = ["Sentinel webhook received"];
36
- if (eventName)
37
- labels.push(`event=${eventName}`);
38
- if (watcherId)
39
- labels.push(`watcher=${watcherId}`);
40
- return trimText(labels.join(" "), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
64
+ const skillId = asString(payload.skillId) ??
65
+ (isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
66
+ undefined;
67
+ const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
68
+ const rawPayload = payload.payload ??
69
+ (isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
70
+ payload;
71
+ const boundedPayload = clipPayloadForPrompt(rawPayload);
72
+ const dedupeSeed = JSON.stringify({
73
+ watcherId: watcherId ?? null,
74
+ eventName: eventName ?? null,
75
+ matchedAt,
76
+ });
77
+ const generatedDedupe = createHash("sha256").update(dedupeSeed).digest("hex").slice(0, 16);
78
+ const dedupeKey = asString(payload.dedupeKey) ??
79
+ asString(payload.correlationId) ??
80
+ asString(payload.correlationID) ??
81
+ generatedDedupe;
82
+ const deliveryTargets = Array.isArray(payload.deliveryTargets)
83
+ ? payload.deliveryTargets.filter(isDeliveryTarget)
84
+ : undefined;
85
+ const envelope = {
86
+ watcherId: watcherId ?? null,
87
+ eventName: eventName ?? null,
88
+ matchedAt,
89
+ payload: boundedPayload,
90
+ dedupeKey,
91
+ correlationId: dedupeKey,
92
+ source: {
93
+ route: DEFAULT_SENTINEL_WEBHOOK_PATH,
94
+ plugin: "openclaw-sentinel",
95
+ },
96
+ };
97
+ if (skillId)
98
+ envelope.skillId = skillId;
99
+ if (deliveryTargets && deliveryTargets.length > 0)
100
+ envelope.deliveryTargets = deliveryTargets;
101
+ return envelope;
102
+ }
103
+ function buildSentinelSystemEvent(payload) {
104
+ const envelope = buildSentinelEventEnvelope(payload);
105
+ const jsonEnvelope = JSON.stringify(envelope, null, 2);
106
+ const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
107
+ return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
41
108
  }
42
109
  async function readSentinelWebhookPayload(req) {
43
110
  const preParsed = req.body;
@@ -70,6 +137,47 @@ async function readSentinelWebhookPayload(req) {
70
137
  }
71
138
  return parsed;
72
139
  }
140
+ async function notifyDeliveryTarget(api, target, message) {
141
+ switch (target.channel) {
142
+ case "telegram":
143
+ await api.runtime.channel.telegram.sendMessageTelegram(target.to, message, {
144
+ accountId: target.accountId,
145
+ });
146
+ return;
147
+ case "discord":
148
+ await api.runtime.channel.discord.sendMessageDiscord(target.to, message, {
149
+ accountId: target.accountId,
150
+ });
151
+ return;
152
+ case "slack":
153
+ await api.runtime.channel.slack.sendMessageSlack(target.to, message, {
154
+ accountId: target.accountId,
155
+ });
156
+ return;
157
+ case "signal":
158
+ await api.runtime.channel.signal.sendMessageSignal(target.to, message, {
159
+ accountId: target.accountId,
160
+ });
161
+ return;
162
+ case "imessage":
163
+ await api.runtime.channel.imessage.sendMessageIMessage(target.to, message, {
164
+ accountId: target.accountId,
165
+ });
166
+ return;
167
+ case "whatsapp":
168
+ await api.runtime.channel.whatsapp.sendMessageWhatsApp(target.to, message, {
169
+ accountId: target.accountId,
170
+ });
171
+ return;
172
+ case "line":
173
+ await api.runtime.channel.line.sendMessageLine(target.to, message, {
174
+ accountId: target.accountId,
175
+ });
176
+ return;
177
+ default:
178
+ throw new Error(`Unsupported delivery target channel: ${target.channel}`);
179
+ }
180
+ }
73
181
  export function createSentinelPlugin(overrides) {
74
182
  const config = {
75
183
  allowedHosts: [],
@@ -102,6 +210,11 @@ export function createSentinelPlugin(overrides) {
102
210
  await manager.init();
103
211
  },
104
212
  register(api) {
213
+ manager.setNotifier({
214
+ async notify(target, message) {
215
+ await notifyDeliveryTarget(api, target, message);
216
+ },
217
+ });
105
218
  registerSentinelControl(api.registerTool.bind(api), manager);
106
219
  const path = normalizePath(DEFAULT_SENTINEL_WEBHOOK_PATH);
107
220
  if (!api.registerHttpRoute) {
@@ -1,2 +1,5 @@
1
1
  import { WatcherDefinition } from "../types.js";
2
- export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>) => Promise<() => void>;
2
+ export interface StrategyCallbacks {
3
+ onConnect?: () => void;
4
+ }
5
+ export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>, callbacks?: StrategyCallbacks) => Promise<() => void>;
@@ -1,9 +1,16 @@
1
1
  import WebSocket from "ws";
2
- export const websocketStrategy = async (watcher, onPayload, onError) => {
2
+ export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
3
3
  let active = true;
4
4
  let ws = null;
5
5
  const connect = () => {
6
+ let pendingError = null;
7
+ let failureReported = false;
6
8
  ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
9
+ ws.on("open", () => {
10
+ if (!active)
11
+ return;
12
+ callbacks?.onConnect?.();
13
+ });
7
14
  ws.on("message", async (data) => {
8
15
  if (!active)
9
16
  return;
@@ -18,12 +25,14 @@ export const websocketStrategy = async (watcher, onPayload, onError) => {
18
25
  ws.on("error", (err) => {
19
26
  if (!active)
20
27
  return;
21
- void onError(err);
28
+ pendingError = err instanceof Error ? err : new Error(String(err));
22
29
  });
23
30
  ws.on("close", (code) => {
24
- if (!active)
31
+ if (!active || failureReported)
25
32
  return;
26
- void onError(new Error(`websocket closed: ${code}`));
33
+ failureReported = true;
34
+ const reason = pendingError?.message ?? `websocket closed: ${code}`;
35
+ void onError(new Error(reason));
27
36
  });
28
37
  };
29
38
  connect();
package/dist/tool.d.ts CHANGED
@@ -3,6 +3,12 @@ import type { Static } from "@sinclair/typebox";
3
3
  import { WatcherManager } from "./watcherManager.js";
4
4
  import { SentinelToolSchema } from "./toolSchema.js";
5
5
  export type SentinelToolParams = Static<typeof SentinelToolSchema>;
6
- type RegisterToolFn = (tool: AnyAgentTool) => void;
6
+ type SentinelToolContext = {
7
+ messageChannel?: string;
8
+ requesterSenderId?: string;
9
+ agentAccountId?: string;
10
+ sessionKey?: string;
11
+ };
12
+ type RegisterToolFn = (tool: AnyAgentTool | ((ctx: SentinelToolContext) => AnyAgentTool)) => void;
7
13
  export declare function registerSentinelControl(registerTool: RegisterToolFn, manager: WatcherManager): void;
8
14
  export {};
package/dist/tool.js CHANGED
@@ -11,8 +11,22 @@ function validateParams(params) {
11
11
  }
12
12
  return candidate;
13
13
  }
14
+ function inferDefaultDeliveryTargets(ctx) {
15
+ const channel = ctx.messageChannel?.trim();
16
+ if (!channel)
17
+ return [];
18
+ const fromSender = ctx.requesterSenderId?.trim();
19
+ if (fromSender) {
20
+ return [{ channel, to: fromSender, accountId: ctx.agentAccountId }];
21
+ }
22
+ const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
23
+ if (sessionPeer) {
24
+ return [{ channel, to: sessionPeer, accountId: ctx.agentAccountId }];
25
+ }
26
+ return [];
27
+ }
14
28
  export function registerSentinelControl(registerTool, manager) {
15
- registerTool({
29
+ registerTool((ctx) => ({
16
30
  name: "sentinel_control",
17
31
  label: "sentinel_control",
18
32
  description: "Create/manage sentinel watchers",
@@ -21,7 +35,9 @@ export function registerSentinelControl(registerTool, manager) {
21
35
  const payload = validateParams(params);
22
36
  switch (payload.action) {
23
37
  case "create":
24
- return jsonResult(await manager.create(payload.watcher));
38
+ return jsonResult(await manager.create(payload.watcher, {
39
+ deliveryTargets: inferDefaultDeliveryTargets(ctx),
40
+ }));
25
41
  case "enable":
26
42
  return jsonResult(await manager.enable(payload.id ?? ""));
27
43
  case "disable":
@@ -34,5 +50,5 @@ export function registerSentinelControl(registerTool, manager) {
34
50
  return jsonResult(manager.list());
35
51
  }
36
52
  },
37
- });
53
+ }));
38
54
  }
@@ -29,6 +29,11 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
29
29
  maxMs: import("@sinclair/typebox").TNumber;
30
30
  }>;
31
31
  fireOnce: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
32
+ deliveryTargets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
33
+ channel: import("@sinclair/typebox").TString;
34
+ to: import("@sinclair/typebox").TString;
35
+ accountId: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
36
+ }>>>;
32
37
  metadata: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
33
38
  }>>;
34
39
  }>;
@@ -32,6 +32,11 @@ const RetryPolicySchema = Type.Object({
32
32
  baseMs: Type.Number({ description: "Base delay in milliseconds for exponential backoff" }),
33
33
  maxMs: Type.Number({ description: "Maximum delay cap in milliseconds" }),
34
34
  });
35
+ const DeliveryTargetSchema = Type.Object({
36
+ channel: Type.String({ description: "Channel/provider id (e.g. telegram, discord)" }),
37
+ to: Type.String({ description: "Destination id within the channel" }),
38
+ accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
39
+ }, { additionalProperties: false });
35
40
  const WatcherSchema = Type.Object({
36
41
  id: Type.String({ description: "Unique watcher identifier" }),
37
42
  skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
@@ -61,6 +66,9 @@ const WatcherSchema = Type.Object({
61
66
  fire: FireConfigSchema,
62
67
  retry: RetryPolicySchema,
63
68
  fireOnce: Type.Optional(Type.Boolean({ description: "If true, the watcher disables itself after firing once" })),
69
+ deliveryTargets: Type.Optional(Type.Array(DeliveryTargetSchema, {
70
+ description: "Optional notification delivery targets. Defaults to the current chat/session context when omitted.",
71
+ })),
64
72
  metadata: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Arbitrary key-value metadata" })),
65
73
  }, { description: "Full watcher definition" });
66
74
  export const SentinelToolSchema = Type.Object({
package/dist/types.d.ts CHANGED
@@ -16,6 +16,11 @@ export interface RetryPolicy {
16
16
  baseMs: number;
17
17
  maxMs: number;
18
18
  }
19
+ export interface DeliveryTarget {
20
+ channel: string;
21
+ to: string;
22
+ accountId?: string;
23
+ }
19
24
  export interface WatcherDefinition {
20
25
  id: string;
21
26
  skillId: string;
@@ -32,6 +37,7 @@ export interface WatcherDefinition {
32
37
  fire: FireConfig;
33
38
  retry: RetryPolicy;
34
39
  fireOnce?: boolean;
40
+ deliveryTargets?: DeliveryTarget[];
35
41
  metadata?: Record<string, string>;
36
42
  }
37
43
  export interface WatcherRuntimeState {
@@ -39,9 +45,22 @@ export interface WatcherRuntimeState {
39
45
  lastError?: string;
40
46
  lastResponseAt?: string;
41
47
  consecutiveFailures: number;
48
+ reconnectAttempts: number;
42
49
  lastPayloadHash?: string;
43
50
  lastPayload?: unknown;
44
51
  lastEvaluated?: string;
52
+ lastConnectAt?: string;
53
+ lastDisconnectAt?: string;
54
+ lastDisconnectReason?: string;
55
+ lastDelivery?: {
56
+ attemptedAt: string;
57
+ successCount: number;
58
+ failureCount: number;
59
+ failures?: Array<{
60
+ target: DeliveryTarget;
61
+ error: string;
62
+ }>;
63
+ };
45
64
  }
46
65
  export interface SentinelStateFile {
47
66
  watchers: WatcherDefinition[];
package/dist/validator.js CHANGED
@@ -49,6 +49,11 @@ const WatcherSchema = Type.Object({
49
49
  maxMs: Type.Integer({ minimum: 100, maximum: 300000 }),
50
50
  }, { additionalProperties: false }),
51
51
  fireOnce: Type.Optional(Type.Boolean()),
52
+ deliveryTargets: Type.Optional(Type.Array(Type.Object({
53
+ channel: Type.String({ minLength: 1 }),
54
+ to: Type.String({ minLength: 1 }),
55
+ accountId: Type.Optional(Type.String({ minLength: 1 })),
56
+ }, { additionalProperties: false }), { minItems: 1 })),
52
57
  metadata: Type.Optional(Type.Record(Type.String(), Type.String())),
53
58
  }, { additionalProperties: false });
54
59
  function scanNoCodeLike(input, parentKey = "") {
@@ -1,18 +1,28 @@
1
- import { GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
1
+ import { DeliveryTarget, GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
2
+ export declare const RESET_BACKOFF_AFTER_MS = 60000;
3
+ export interface WatcherCreateContext {
4
+ deliveryTargets?: DeliveryTarget[];
5
+ }
6
+ export interface WatcherNotifier {
7
+ notify(target: DeliveryTarget, message: string): Promise<void>;
8
+ }
9
+ export declare const backoff: (base: number, max: number, failures: number) => number;
2
10
  export declare class WatcherManager {
3
11
  private config;
4
12
  private dispatcher;
13
+ private notifier?;
5
14
  private watchers;
6
15
  private runtime;
7
16
  private stops;
8
17
  private retryTimers;
9
18
  private statePath;
10
19
  private webhookRegistration;
11
- constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
20
+ constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
12
21
  init(): Promise<void>;
13
- create(input: unknown): Promise<WatcherDefinition>;
22
+ create(input: unknown, ctx?: WatcherCreateContext): Promise<WatcherDefinition>;
14
23
  list(): WatcherDefinition[];
15
24
  status(id: string): WatcherRuntimeState | undefined;
25
+ setNotifier(notifier: WatcherNotifier | undefined): void;
16
26
  setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
17
27
  enable(id: string): Promise<void>;
18
28
  disable(id: string): Promise<void>;
@@ -8,7 +8,8 @@ import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
8
8
  import { sseStrategy } from "./strategies/sse.js";
9
9
  import { websocketStrategy } from "./strategies/websocket.js";
10
10
  import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
11
- const backoff = (base, max, failures) => {
11
+ export const RESET_BACKOFF_AFTER_MS = 60_000;
12
+ export const backoff = (base, max, failures) => {
12
13
  const raw = Math.min(max, base * 2 ** failures);
13
14
  const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
14
15
  return Math.max(base, raw + jitter);
@@ -16,6 +17,7 @@ const backoff = (base, max, failures) => {
16
17
  export class WatcherManager {
17
18
  config;
18
19
  dispatcher;
20
+ notifier;
19
21
  watchers = new Map();
20
22
  runtime = {};
21
23
  stops = new Map();
@@ -25,9 +27,10 @@ export class WatcherManager {
25
27
  path: DEFAULT_SENTINEL_WEBHOOK_PATH,
26
28
  status: "pending",
27
29
  };
28
- constructor(config, dispatcher) {
30
+ constructor(config, dispatcher, notifier) {
29
31
  this.config = config;
30
32
  this.dispatcher = dispatcher;
33
+ this.notifier = notifier;
31
34
  this.statePath = config.stateFilePath ?? defaultStatePath();
32
35
  }
33
36
  async init() {
@@ -41,28 +44,33 @@ export class WatcherManager {
41
44
  this.watchers.set(watcher.id, watcher);
42
45
  }
43
46
  catch (err) {
47
+ const prev = this.runtime[rawWatcher.id];
44
48
  this.runtime[rawWatcher.id] = {
45
49
  id: rawWatcher.id,
46
- consecutiveFailures: (this.runtime[rawWatcher.id]?.consecutiveFailures ?? 0) + 1,
50
+ consecutiveFailures: (prev?.consecutiveFailures ?? 0) + 1,
51
+ reconnectAttempts: prev?.reconnectAttempts ?? 0,
47
52
  lastError: `Invalid persisted watcher: ${String(err?.message ?? err)}`,
48
- lastResponseAt: this.runtime[rawWatcher.id]?.lastResponseAt,
49
- lastEvaluated: this.runtime[rawWatcher.id]?.lastEvaluated,
50
- lastPayloadHash: this.runtime[rawWatcher.id]?.lastPayloadHash,
51
- lastPayload: this.runtime[rawWatcher.id]?.lastPayload,
53
+ lastResponseAt: prev?.lastResponseAt,
54
+ lastEvaluated: prev?.lastEvaluated,
55
+ lastPayloadHash: prev?.lastPayloadHash,
56
+ lastPayload: prev?.lastPayload,
52
57
  };
53
58
  }
54
59
  }
55
60
  for (const watcher of this.list().filter((w) => w.enabled))
56
61
  await this.startWatcher(watcher.id);
57
62
  }
58
- async create(input) {
63
+ async create(input, ctx) {
59
64
  const watcher = validateWatcherDefinition(input);
65
+ if (!watcher.deliveryTargets?.length && ctx?.deliveryTargets?.length) {
66
+ watcher.deliveryTargets = ctx.deliveryTargets;
67
+ }
60
68
  assertHostAllowed(this.config, watcher.endpoint);
61
69
  assertWatcherLimits(this.config, this.list(), watcher);
62
70
  if (this.watchers.has(watcher.id))
63
71
  throw new Error(`Watcher already exists: ${watcher.id}`);
64
72
  this.watchers.set(watcher.id, watcher);
65
- this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0 };
73
+ this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0, reconnectAttempts: 0 };
66
74
  if (watcher.enabled)
67
75
  await this.startWatcher(watcher.id);
68
76
  await this.persist();
@@ -74,6 +82,9 @@ export class WatcherManager {
74
82
  status(id) {
75
83
  return this.runtime[id];
76
84
  }
85
+ setNotifier(notifier) {
86
+ this.notifier = notifier;
87
+ }
77
88
  setWebhookRegistrationStatus(status, message, path) {
78
89
  this.webhookRegistration = {
79
90
  path: path ?? this.webhookRegistration.path,
@@ -117,9 +128,19 @@ export class WatcherManager {
117
128
  "http-long-poll": httpLongPollStrategy,
118
129
  }[watcher.strategy];
119
130
  const handleFailure = async (err) => {
120
- const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
131
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
132
+ rt.reconnectAttempts ??= 0;
133
+ const errMsg = String(err instanceof Error ? err.message : err);
134
+ rt.lastDisconnectAt = new Date().toISOString();
135
+ rt.lastDisconnectReason = errMsg;
136
+ rt.lastError = errMsg;
137
+ if (rt.lastConnectAt) {
138
+ const connectedMs = Date.now() - new Date(rt.lastConnectAt).getTime();
139
+ if (connectedMs >= RESET_BACKOFF_AFTER_MS) {
140
+ rt.consecutiveFailures = 0;
141
+ }
142
+ }
121
143
  rt.consecutiveFailures += 1;
122
- rt.lastError = String(err?.message ?? err);
123
144
  this.runtime[id] = rt;
124
145
  if (this.retryTimers.has(id)) {
125
146
  await this.persist();
@@ -127,6 +148,7 @@ export class WatcherManager {
127
148
  }
128
149
  const delay = backoff(watcher.retry.baseMs, watcher.retry.maxMs, rt.consecutiveFailures);
129
150
  if (rt.consecutiveFailures <= watcher.retry.maxRetries && watcher.enabled) {
151
+ rt.reconnectAttempts += 1;
130
152
  await this.stopWatcher(id);
131
153
  const timer = setTimeout(() => {
132
154
  this.retryTimers.delete(id);
@@ -137,7 +159,7 @@ export class WatcherManager {
137
159
  await this.persist();
138
160
  };
139
161
  const stop = await handler(watcher, async (payload) => {
140
- const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
162
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
141
163
  const previousPayload = rt.lastPayload;
142
164
  const matched = evaluateConditions(watcher.conditions, watcher.match, payload, previousPayload);
143
165
  rt.lastPayloadHash = hashPayload(payload);
@@ -145,6 +167,7 @@ export class WatcherManager {
145
167
  rt.lastResponseAt = new Date().toISOString();
146
168
  rt.lastEvaluated = rt.lastResponseAt;
147
169
  rt.consecutiveFailures = 0;
170
+ rt.reconnectAttempts = 0;
148
171
  rt.lastError = undefined;
149
172
  this.runtime[id] = rt;
150
173
  if (matched) {
@@ -155,13 +178,43 @@ export class WatcherManager {
155
178
  timestamp: new Date().toISOString(),
156
179
  });
157
180
  await this.dispatcher.dispatch(watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH, body);
181
+ if (watcher.deliveryTargets?.length && this.notifier) {
182
+ const attemptedAt = new Date().toISOString();
183
+ const message = JSON.stringify(body);
184
+ const failures = [];
185
+ let successCount = 0;
186
+ await Promise.all(watcher.deliveryTargets.map(async (target) => {
187
+ try {
188
+ await this.notifier?.notify(target, message);
189
+ successCount += 1;
190
+ }
191
+ catch (err) {
192
+ failures.push({
193
+ target,
194
+ error: String(err?.message ?? err),
195
+ });
196
+ }
197
+ }));
198
+ rt.lastDelivery = {
199
+ attemptedAt,
200
+ successCount,
201
+ failureCount: failures.length,
202
+ failures: failures.length > 0 ? failures : undefined,
203
+ };
204
+ }
158
205
  if (watcher.fireOnce) {
159
206
  watcher.enabled = false;
160
207
  await this.stopWatcher(id);
161
208
  }
162
209
  }
163
210
  await this.persist();
164
- }, handleFailure);
211
+ }, handleFailure, {
212
+ onConnect: () => {
213
+ const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
214
+ rt.lastConnectAt = new Date().toISOString();
215
+ this.runtime[id] = rt;
216
+ },
217
+ });
165
218
  this.stops.set(id, stop);
166
219
  }
167
220
  async stopWatcher(id) {
@@ -12,7 +12,6 @@
12
12
  },
13
13
  "localDispatchBase": {
14
14
  "type": "string",
15
- "format": "uri",
16
15
  "description": "Base URL for internal webhook dispatch",
17
16
  "default": "http://127.0.0.1:18789"
18
17
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -29,18 +29,6 @@
29
29
  "publishConfig": {
30
30
  "access": "public"
31
31
  },
32
- "scripts": {
33
- "build": "tsc -p tsconfig.json",
34
- "test": "vitest run",
35
- "lint": "tsc --noEmit",
36
- "format": "oxfmt --write .",
37
- "format:check": "oxfmt --check .",
38
- "changeset": "changeset",
39
- "version-packages": "changeset version",
40
- "release": "changeset publish",
41
- "prepack": "npm run build",
42
- "prepare": "husky"
43
- },
44
32
  "dependencies": {
45
33
  "@sinclair/typebox": "^0.34.48",
46
34
  "re2-wasm": "^1.0.2",
@@ -72,7 +60,18 @@
72
60
  },
73
61
  "openclaw": {
74
62
  "extensions": [
75
- "./dist/index.js"
63
+ "dist/index.js"
76
64
  ]
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json",
68
+ "test": "vitest run",
69
+ "test:smoke-install": "./scripts/smoke-install.sh",
70
+ "lint": "tsc --noEmit",
71
+ "format": "oxfmt --write .",
72
+ "format:check": "oxfmt --check .",
73
+ "changeset": "changeset",
74
+ "version-packages": "changeset version",
75
+ "release": "changeset publish"
77
76
  }
78
- }
77
+ }