@wrongstack/plugins 0.291.1 → 0.292.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.
@@ -1,42 +1,3 @@
1
- /**
2
- * notify-hub plugin — pushes session events to a webhook.
3
- *
4
- * Long-running autonomous sessions need a way to reach the user when
5
- * something noteworthy happens and nobody is watching the terminal.
6
- * notify-hub POSTs compact JSON payloads to a configurable webhook
7
- * URL (Slack/Discord-compatible via generic JSON, n8n, ntfy, or any
8
- * HTTP endpoint) for a configurable set of events:
9
- *
10
- * - `session.stop` — the agent loop ended (Stop hook)
11
- * - `tool.error` — a tool invocation failed
12
- * - `budget.threshold` — a budget threshold event fired on the bus
13
- *
14
- * Deliveries are fire-and-forget with a timeout — a dead webhook can
15
- * never stall the agent. Failures are counted and reported via
16
- * `notify_hub_status` / `health()`, and delivery stops trying after
17
- * `maxConsecutiveFailures` (circuit breaker) until setup runs again.
18
- *
19
- * The agent can also send an ad-hoc notification with `notify_send`
20
- * ("tell the user the migration finished").
21
- *
22
- * Config (`config.extensions['notify-hub']`):
23
- *
24
- * ```jsonc
25
- * {
26
- * "enabled": true,
27
- * "webhookUrl": "", // empty = plugin idles
28
- * "events": ["session.stop", "tool.error"],
29
- * "headers": {}, // extra HTTP headers (auth…)
30
- * "timeoutMs": 5000,
31
- * "maxConsecutiveFailures": 5
32
- * }
33
- * ```
34
- *
35
- * Toggle off with `{ "name": "notify-hub", "enabled": false }` in
36
- * `config.plugins`, or `"enabled": false` in the options above.
37
- *
38
- * @public
39
- */
40
1
  import type { Plugin } from '@wrongstack/core';
41
2
  declare const plugin: Plugin;
42
3
  export default plugin;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/notify-hub/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AA2L/C,QAAA,MAAM,MAAM,EAAE,MAsPb,CAAC;eAEa,MAAM"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/notify-hub/index.ts"],"names":[],"mappings":"AAwDA,OAAO,KAAK,EAAmD,MAAM,EAAE,MAAM,kBAAkB,CAAC;AA8NhG,QAAA,MAAM,MAAM,EAAE,MAwRb,CAAC;eAEa,MAAM"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * WebhookNotificationChannel — NotificationChannel implementation for
3
+ * generic HTTP(S) webhook delivery.
4
+ *
5
+ * POSTs JSON payloads to a configurable webhook URL (Slack/Discord
6
+ * compatible via generic JSON, n8n, ntfy, or any HTTP endpoint). Includes
7
+ * a built-in circuit breaker that stops delivery after N consecutive
8
+ * failures to avoid hammering a dead endpoint.
9
+ *
10
+ * The channel is stateless beyond the circuit breaker state — each
11
+ * `deliver()` call is an independent HTTP POST. The caller (Notifier or
12
+ * direct user) is responsible for retry logic if needed.
13
+ *
14
+ * @module notify-hub
15
+ * @public
16
+ */
17
+ import type { NotificationChannel, NotificationMessage, NotificationResult } from '@wrongstack/core';
18
+ export interface WebhookChannelOptions {
19
+ /** HTTP(S) endpoint that receives JSON POSTs. */
20
+ readonly webhookUrl: string;
21
+ /** Extra HTTP headers sent with every delivery (e.g. Authorization). */
22
+ readonly headers?: Readonly<Record<string, string>> | undefined;
23
+ /** Per-delivery timeout in ms (default 5000). */
24
+ readonly timeoutMs?: number | undefined;
25
+ /**
26
+ * Circuit breaker threshold — stop trying after this many consecutive
27
+ * failures. Default 5. Set to 0 to disable the circuit breaker.
28
+ */
29
+ readonly maxConsecutiveFailures?: number | undefined;
30
+ }
31
+ export declare class WebhookNotificationChannel implements NotificationChannel {
32
+ #private;
33
+ readonly name: 'webhook';
34
+ readonly type: 'webhook';
35
+ constructor(opts: WebhookChannelOptions);
36
+ deliver(msg: NotificationMessage): Promise<NotificationResult>;
37
+ ping(): Promise<{
38
+ ok: boolean;
39
+ error?: string | undefined;
40
+ }>;
41
+ /** Reset the circuit breaker (e.g. after config update). */
42
+ resetCircuit(): void;
43
+ /** Current circuit breaker state. */
44
+ circuitStatus(): {
45
+ open: boolean;
46
+ consecutiveFailures: number;
47
+ };
48
+ /** Lifetime delivery counters (never reset). */
49
+ counters(): {
50
+ attempted: number;
51
+ delivered: number;
52
+ failed: number;
53
+ suppressed: number;
54
+ };
55
+ }
56
+ //# sourceMappingURL=webhook-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-channel.d.ts","sourceRoot":"","sources":["../../src/notify-hub/webhook-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAmBrG,MAAM,WAAW,qBAAqB;IACpC,iDAAiD;IACjD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,wEAAwE;IACxE,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IAChE,iDAAiD;IACjD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtD;AAED,qBAAa,0BAA2B,YAAW,mBAAmB;;IACpE,QAAQ,CAAC,IAAI,EAAG,SAAS,CAAU;IACnC,QAAQ,CAAC,IAAI,EAAG,SAAS,CAAU;IAiBnC,YAAY,IAAI,EAAE,qBAAqB,EAMtC;IAMK,OAAO,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA6DnE;IAEK,IAAI,IAAI,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAC,CAYjE;IAMD,4DAA4D;IAC5D,YAAY,IAAI,IAAI,CAGnB;IAED,qCAAqC;IACrC,aAAa,IAAI;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE,CAE9D;IAED,gDAAgD;IAChD,QAAQ,IAAI;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAOA;CACF"}
@@ -1,13 +1,132 @@
1
1
  // src/notify-hub/index.ts
2
+ import { lookup } from "node:dns/promises";
3
+
4
+ // src/notify-hub/webhook-channel.ts
5
+ function freshCircuit() {
6
+ return { open: false, consecutiveFailures: 0 };
7
+ }
8
+ var WebhookNotificationChannel = class {
9
+ name = "webhook";
10
+ type = "webhook";
11
+ #url;
12
+ #headers;
13
+ #timeoutMs;
14
+ #maxFailures;
15
+ #circuit;
16
+ /** Total deliveries attempted (across all resets). */
17
+ #totalAttempted = 0;
18
+ /** Total successful deliveries. */
19
+ #totalDelivered = 0;
20
+ /** Total failed deliveries. */
21
+ #totalFailed = 0;
22
+ /** Total suppressed by circuit breaker. */
23
+ #totalSuppressed = 0;
24
+ constructor(opts) {
25
+ this.#url = opts.webhookUrl;
26
+ this.#headers = opts.headers ?? {};
27
+ this.#timeoutMs = opts.timeoutMs ?? 5e3;
28
+ this.#maxFailures = opts.maxConsecutiveFailures ?? 5;
29
+ this.#circuit = freshCircuit();
30
+ }
31
+ // -----------------------------------------------------------------------
32
+ // NotificationChannel
33
+ // -----------------------------------------------------------------------
34
+ async deliver(msg) {
35
+ const deliveredAt = (/* @__PURE__ */ new Date()).toISOString();
36
+ if (this.#circuit.open && this.#maxFailures > 0) {
37
+ this.#totalSuppressed += 1;
38
+ return {
39
+ ok: false,
40
+ channel: this.name,
41
+ error: `circuit open after ${this.#circuit.consecutiveFailures} consecutive failures`,
42
+ deliveredAt
43
+ };
44
+ }
45
+ this.#totalAttempted += 1;
46
+ const body = JSON.stringify({
47
+ source: "wrongstack/notify-hub",
48
+ event: msg.source,
49
+ ts: deliveredAt,
50
+ title: msg.title,
51
+ message: msg.body,
52
+ level: msg.level,
53
+ ...msg.metadata ?? {}
54
+ });
55
+ try {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
58
+ try {
59
+ const res = await fetch(this.#url, {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json", ...this.#headers },
62
+ body,
63
+ signal: controller.signal
64
+ });
65
+ if (!res.ok) throw new Error(`webhook responded ${res.status}`);
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ this.#totalDelivered += 1;
70
+ this.#circuit.consecutiveFailures = 0;
71
+ this.#circuit.open = false;
72
+ return { ok: true, channel: this.name, deliveredAt };
73
+ } catch (err) {
74
+ this.#totalFailed += 1;
75
+ this.#circuit.consecutiveFailures += 1;
76
+ if (this.#maxFailures > 0 && this.#circuit.consecutiveFailures >= this.#maxFailures) {
77
+ this.#circuit.open = true;
78
+ }
79
+ return {
80
+ ok: false,
81
+ channel: this.name,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ deliveredAt
84
+ };
85
+ }
86
+ }
87
+ async ping() {
88
+ if (!this.#url) {
89
+ return { ok: false, error: "no webhookUrl configured" };
90
+ }
91
+ if (this.#circuit.open) {
92
+ return {
93
+ ok: false,
94
+ error: `circuit open after ${this.#circuit.consecutiveFailures} consecutive failures`
95
+ };
96
+ }
97
+ return { ok: true };
98
+ }
99
+ // -----------------------------------------------------------------------
100
+ // Diagnostics (not part of NotificationChannel)
101
+ // -----------------------------------------------------------------------
102
+ /** Reset the circuit breaker (e.g. after config update). */
103
+ resetCircuit() {
104
+ this.#circuit.open = false;
105
+ this.#circuit.consecutiveFailures = 0;
106
+ }
107
+ /** Current circuit breaker state. */
108
+ circuitStatus() {
109
+ return { open: this.#circuit.open, consecutiveFailures: this.#circuit.consecutiveFailures };
110
+ }
111
+ /** Lifetime delivery counters (never reset). */
112
+ counters() {
113
+ return {
114
+ attempted: this.#totalAttempted,
115
+ delivered: this.#totalDelivered,
116
+ failed: this.#totalFailed,
117
+ suppressed: this.#totalSuppressed
118
+ };
119
+ }
120
+ };
121
+
122
+ // src/notify-hub/index.ts
123
+ var _pluginLog = null;
2
124
  var state = {
3
- sent: 0,
4
- failed: 0,
5
- suppressed: 0,
6
- consecutiveFailures: 0,
7
- circuitOpen: false,
125
+ channel: null,
8
126
  lastDelivery: null,
9
127
  stopHookUnregister: null,
10
- eventUnsubscribers: []
128
+ eventUnsubscribers: [],
129
+ circuitWarned: false
11
130
  };
12
131
  var KNOWN_EVENTS = ["session.stop", "tool.error", "budget.threshold"];
13
132
  var DEFAULTS = {
@@ -30,6 +149,21 @@ function isBlockedHostname(hostname) {
30
149
  const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
31
150
  return h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "0:0:0:0:0:0:0:1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:") || isPrivateIPv4(h);
32
151
  }
152
+ async function hasPrivateResolvedIP(hostname) {
153
+ try {
154
+ const entries = await lookup(hostname, { all: true, verbatim: true });
155
+ for (const { address } of entries) {
156
+ if (isPrivateIPv4(address)) return true;
157
+ const lower = address.toLowerCase();
158
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80:")) {
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
33
167
  function normalizeWebhookUrl(raw) {
34
168
  if (typeof raw !== "string" || raw.trim().length === 0) return "";
35
169
  try {
@@ -61,49 +195,37 @@ function readConfig(raw) {
61
195
  maxConsecutiveFailures: typeof r["maxConsecutiveFailures"] === "number" && r["maxConsecutiveFailures"] >= 1 ? r["maxConsecutiveFailures"] : DEFAULTS.maxConsecutiveFailures
62
196
  };
63
197
  }
64
- async function deliver(cfg, event, payload, log) {
65
- if (!cfg.webhookUrl) return false;
66
- if (state.circuitOpen) {
67
- state.suppressed += 1;
198
+ async function deliver(event, payload) {
199
+ const ch = state.channel;
200
+ if (!ch) {
68
201
  return false;
69
202
  }
70
- const body = JSON.stringify({
71
- source: "wrongstack/notify-hub",
72
- event,
73
- ts: (/* @__PURE__ */ new Date()).toISOString(),
74
- ...payload
203
+ const result = await deliverViaChannel(ch, event, {
204
+ title: typeof payload.title === "string" ? payload.title : event,
205
+ body: typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : JSON.stringify(payload),
206
+ level: event === "tool.error" || event === "budget.threshold" ? "warning" : "info",
207
+ source: event,
208
+ metadata: payload
75
209
  });
76
- try {
77
- const controller = new AbortController();
78
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
79
- try {
80
- const res = await fetch(cfg.webhookUrl, {
81
- method: "POST",
82
- headers: { "content-type": "application/json", ...cfg.headers },
83
- body,
84
- signal: controller.signal
85
- });
86
- if (!res.ok) throw new Error(`webhook responded ${res.status}`);
87
- } finally {
88
- clearTimeout(timer);
89
- }
90
- state.sent += 1;
91
- state.consecutiveFailures = 0;
210
+ return result.ok;
211
+ }
212
+ async function deliverViaChannel(ch, event, msg) {
213
+ const result = await ch.deliver(msg);
214
+ if (result.ok) {
215
+ state.circuitWarned = false;
92
216
  state.lastDelivery = { event, ok: true, when: (/* @__PURE__ */ new Date()).toISOString() };
93
- return true;
94
- } catch (err) {
95
- state.failed += 1;
96
- state.consecutiveFailures += 1;
217
+ } else {
97
218
  state.lastDelivery = { event, ok: false, when: (/* @__PURE__ */ new Date()).toISOString() };
98
- if (state.consecutiveFailures >= cfg.maxConsecutiveFailures) {
99
- state.circuitOpen = true;
100
- log.warn(
101
- `notify-hub: ${state.consecutiveFailures} consecutive delivery failures \u2014 circuit opened, further notifications suppressed`,
102
- { error: err instanceof Error ? err.message : String(err) }
219
+ const cs = ch.circuitStatus();
220
+ if (cs.open && !state.circuitWarned) {
221
+ state.circuitWarned = true;
222
+ _pluginLog?.warn(
223
+ `notify-hub: ${cs.consecutiveFailures} consecutive delivery failures \u2014 circuit opened, further notifications suppressed`,
224
+ { consecutiveFailures: cs.consecutiveFailures, event }
103
225
  );
104
226
  }
105
- return false;
106
227
  }
228
+ return result;
107
229
  }
108
230
  function truncateText(s, max) {
109
231
  return s.length > max ? `${s.slice(0, max)}\u2026` : s;
@@ -150,12 +272,10 @@ var plugin = {
150
272
  }
151
273
  }
152
274
  },
153
- setup(api) {
154
- state.sent = 0;
155
- state.failed = 0;
156
- state.suppressed = 0;
157
- state.consecutiveFailures = 0;
158
- state.circuitOpen = false;
275
+ async setup(api) {
276
+ _pluginLog = api.log;
277
+ state.circuitWarned = false;
278
+ state.channel = null;
159
279
  state.lastDelivery = null;
160
280
  if (state.stopHookUnregister) {
161
281
  try {
@@ -172,15 +292,35 @@ var plugin = {
172
292
  }
173
293
  state.eventUnsubscribers = [];
174
294
  const cfg = readConfig(api.config.extensions?.["notify-hub"]);
175
- const active = cfg.enabled && cfg.webhookUrl.length > 0;
295
+ let active = cfg.enabled && cfg.webhookUrl.length > 0;
296
+ if (active) {
297
+ try {
298
+ const url = new URL(cfg.webhookUrl);
299
+ const hostnameBlocked = await hasPrivateResolvedIP(url.hostname);
300
+ if (hostnameBlocked) {
301
+ api.log.warn(
302
+ `notify-hub: webhook URL resolves to a private/local IP (${url.hostname}) \u2014 disabling plugin`
303
+ );
304
+ active = false;
305
+ }
306
+ } catch {
307
+ }
308
+ }
309
+ if (active) {
310
+ state.channel = new WebhookNotificationChannel({
311
+ webhookUrl: cfg.webhookUrl,
312
+ headers: cfg.headers,
313
+ timeoutMs: cfg.timeoutMs,
314
+ maxConsecutiveFailures: cfg.maxConsecutiveFailures
315
+ });
316
+ api.notifier?.registerChannel(state.channel);
317
+ }
176
318
  if (active && cfg.events.includes("session.stop")) {
177
319
  const stopHook = (input) => {
178
- void deliver(
179
- cfg,
180
- "session.stop",
181
- { sessionId: input.sessionId ?? null, cwd: input.cwd ?? null },
182
- api.log
183
- );
320
+ void deliver("session.stop", {
321
+ sessionId: input.sessionId ?? null,
322
+ cwd: input.cwd ?? null
323
+ });
184
324
  };
185
325
  state.stopHookUnregister = api.registerHook("Stop", void 0, stopHook);
186
326
  }
@@ -188,26 +328,21 @@ var plugin = {
188
328
  const off = api.onPattern("tool.*", (eventName, payload) => {
189
329
  if (!/error|failed/.test(eventName)) return;
190
330
  const p = payload;
191
- void deliver(
192
- cfg,
193
- "tool.error",
194
- {
195
- tool: p?.tool ?? p?.name ?? "unknown",
196
- busEvent: eventName,
197
- error: truncateText(
198
- p?.error instanceof Error ? p.error.message : String(p?.error ?? ""),
199
- 500
200
- )
201
- },
202
- api.log
203
- );
331
+ void deliver("tool.error", {
332
+ tool: p?.tool ?? p?.name ?? "unknown",
333
+ busEvent: eventName,
334
+ error: truncateText(
335
+ p?.error instanceof Error ? p.error.message : String(p?.error ?? ""),
336
+ 500
337
+ )
338
+ });
204
339
  });
205
340
  state.eventUnsubscribers.push(off);
206
341
  }
207
342
  if (active && cfg.events.includes("budget.threshold")) {
208
343
  const off = api.onPattern("budget.*", (eventName, payload) => {
209
344
  if (!eventName.includes("threshold")) return;
210
- void deliver(cfg, "budget.threshold", { busEvent: eventName, detail: payload }, api.log);
345
+ void deliver("budget.threshold", { busEvent: eventName, detail: payload });
211
346
  });
212
347
  state.eventUnsubscribers.push(off);
213
348
  }
@@ -232,26 +367,23 @@ var plugin = {
232
367
  mutating: true,
233
368
  async execute(input) {
234
369
  if (!cfg.enabled) return { ok: false, error: "notify-hub is disabled" };
235
- if (!cfg.webhookUrl) {
370
+ const ch = state.channel;
371
+ if (!ch) {
236
372
  return {
237
373
  ok: false,
238
374
  error: 'no webhookUrl configured \u2014 set config.extensions["notify-hub"].webhookUrl to enable deliveries'
239
375
  };
240
376
  }
241
- const delivered = await deliver(
242
- cfg,
243
- "manual",
244
- {
245
- title: truncateText(String(input.title ?? "WrongStack notification"), 200),
246
- message: truncateText(String(input.message ?? ""), 2e3),
247
- level: input.level === "warning" || input.level === "critical" ? input.level : "info"
248
- },
249
- api.log
250
- );
377
+ const result = await deliverViaChannel(ch, "manual", {
378
+ title: truncateText(String(input.title ?? "WrongStack notification"), 200),
379
+ body: truncateText(String(input.message ?? ""), 2e3),
380
+ level: input.level === "warning" || input.level === "critical" ? input.level : "info",
381
+ source: "manual"
382
+ });
251
383
  return {
252
- ok: delivered,
253
- circuitOpen: state.circuitOpen,
254
- ...delivered ? {} : { error: "delivery failed (see notify_hub_status)" }
384
+ ok: result.ok,
385
+ circuitOpen: ch.circuitStatus().open,
386
+ ...result.ok ? {} : { error: result.error ?? "delivery failed" }
255
387
  };
256
388
  }
257
389
  });
@@ -263,18 +395,26 @@ var plugin = {
263
395
  category: "Diagnostics",
264
396
  mutating: false,
265
397
  async execute() {
398
+ const ch = state.channel;
399
+ const counters = ch?.counters() ?? {
400
+ attempted: 0,
401
+ delivered: 0,
402
+ failed: 0,
403
+ suppressed: 0
404
+ };
266
405
  return {
267
406
  ok: true,
268
407
  enabled: cfg.enabled,
269
408
  webhookConfigured: cfg.webhookUrl.length > 0,
270
409
  events: cfg.events,
271
410
  timeoutMs: cfg.timeoutMs,
272
- circuitOpen: state.circuitOpen,
411
+ circuitOpen: ch?.circuitStatus().open ?? false,
273
412
  counters: {
274
- sent: state.sent,
275
- failed: state.failed,
276
- suppressed: state.suppressed,
277
- consecutiveFailures: state.consecutiveFailures
413
+ sent: counters.delivered,
414
+ failed: counters.failed,
415
+ suppressed: counters.suppressed,
416
+ consecutiveFailures: ch?.circuitStatus().consecutiveFailures ?? 0,
417
+ totalAttempted: counters.attempted
278
418
  },
279
419
  lastDelivery: state.lastDelivery
280
420
  };
@@ -288,6 +428,8 @@ var plugin = {
288
428
  });
289
429
  },
290
430
  teardown(api) {
431
+ _pluginLog = null;
432
+ state.circuitWarned = false;
291
433
  if (state.stopHookUnregister) {
292
434
  try {
293
435
  state.stopHookUnregister();
@@ -302,25 +444,22 @@ var plugin = {
302
444
  }
303
445
  }
304
446
  state.eventUnsubscribers = [];
305
- const final = { sent: state.sent, failed: state.failed, suppressed: state.suppressed };
306
- state.sent = 0;
307
- state.failed = 0;
308
- state.suppressed = 0;
309
- state.consecutiveFailures = 0;
310
- state.circuitOpen = false;
447
+ const channelCounters = state.channel?.counters();
448
+ state.channel = null;
311
449
  state.lastDelivery = null;
312
- api.log.info("notify-hub: teardown complete", { final });
450
+ api.log.info("notify-hub: teardown complete", { channelCounters });
313
451
  },
314
452
  async health() {
453
+ const ch = state.channel;
454
+ const cs = ch?.circuitStatus();
455
+ const channelCounters = ch?.counters();
456
+ const sent = channelCounters?.delivered ?? 0;
457
+ const failed = channelCounters?.failed ?? 0;
458
+ const suppressed = channelCounters?.suppressed ?? 0;
315
459
  return {
316
- ok: !state.circuitOpen,
317
- message: state.circuitOpen ? `notify-hub: circuit OPEN after ${state.consecutiveFailures} consecutive failures \u2014 deliveries suppressed` : `notify-hub: ${state.sent} sent, ${state.failed} failed, ${state.suppressed} suppressed`,
318
- counters: {
319
- sent: state.sent,
320
- failed: state.failed,
321
- suppressed: state.suppressed,
322
- consecutiveFailures: state.consecutiveFailures
323
- }
460
+ ok: !cs?.open,
461
+ message: cs?.open ? `notify-hub: circuit OPEN after ${cs.consecutiveFailures} consecutive failures \u2014 deliveries suppressed` : ch ? `notify-hub: ${sent} sent, ${failed} failed, ${suppressed} suppressed` : `notify-hub: idle \u2014 no webhook channel configured`,
462
+ counters: { sent, failed, suppressed, consecutiveFailures: cs?.consecutiveFailures ?? 0 }
324
463
  };
325
464
  }
326
465
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell-check/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAmK/C,QAAA,MAAM,MAAM,EAAE,MAgPb,CAAC;eAEa,MAAM"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell-check/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AA0M/C,QAAA,MAAM,MAAM,EAAE,MAgPb,CAAC;eAEa,MAAM"}