@steadwing/openalerts 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,42 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { DEFAULTS } from "../core/index.js";
4
4
  import { getDashboardHtml } from "./dashboard-html.js";
5
- // ─── SSE connection tracking ─────────────────────────────────────────────────
6
5
  const sseConnections = new Set();
7
- /** Close all active SSE connections. Call on engine stop. */
6
+ const monitorListeners = new Set();
7
+ const monitorConnections = new Set();
8
+ // Ring buffer — last 150 agent monitor events for history replay
9
+ const agentMonitorBuffer = [];
10
+ const AGENT_MONITOR_BUF_SIZE = 150;
11
+ export function emitAgentMonitorEvent(event) {
12
+ // Store in ring buffer
13
+ agentMonitorBuffer.push(event);
14
+ if (agentMonitorBuffer.length > AGENT_MONITOR_BUF_SIZE)
15
+ agentMonitorBuffer.shift();
16
+ for (const fn of monitorListeners) {
17
+ try {
18
+ fn(event);
19
+ }
20
+ catch { /* ignore */ }
21
+ }
22
+ }
23
+ // ── Gateway event recorder ──────────────────────────────────────────────────
24
+ const recentGatewayEvents = [];
25
+ export function recordGatewayEvent(eventName, payload) {
26
+ recentGatewayEvents.unshift({ eventName, payload, ts: Date.now() });
27
+ while (recentGatewayEvents.length > 10) {
28
+ recentGatewayEvents.pop();
29
+ }
30
+ }
8
31
  export function closeDashboardConnections() {
32
+ for (const conn of monitorConnections) {
33
+ clearInterval(conn.heartbeat);
34
+ try {
35
+ conn.res.end();
36
+ }
37
+ catch { /* ignore */ }
38
+ }
39
+ monitorConnections.clear();
40
+ monitorListeners.clear();
9
41
  for (const conn of sseConnections) {
10
42
  clearInterval(conn.heartbeat);
11
43
  clearInterval(conn.logTailer);
@@ -27,6 +59,8 @@ const RULE_IDS = [
27
59
  "heartbeat-fail",
28
60
  "queue-depth",
29
61
  "high-error-rate",
62
+ "cost-hourly-spike",
63
+ "cost-daily-budget",
30
64
  "tool-errors",
31
65
  "gateway-down",
32
66
  ];
@@ -195,7 +229,7 @@ function createLogTailer(res) {
195
229
  }, 2000);
196
230
  }
197
231
  // ─── HTTP Handler ────────────────────────────────────────────────────────────
198
- export function createDashboardHandler(getEngine) {
232
+ export function createDashboardHandler(getEngine, getCollections) {
199
233
  return async (req, res) => {
200
234
  const url = req.url ?? "";
201
235
  if (!url.startsWith("/openalerts"))
@@ -307,6 +341,27 @@ export function createDashboardHandler(getEngine) {
307
341
  res.end(body);
308
342
  return true;
309
343
  }
344
+ // ── GET /openalerts/collections → Sessions, actions, execs from CollectionManager ────
345
+ if (url === "/openalerts/collections" && req.method === "GET") {
346
+ const collections = getCollections();
347
+ if (!collections) {
348
+ res.writeHead(503, { "Content-Type": "application/json" });
349
+ res.end(JSON.stringify({ error: "Collections not available" }));
350
+ return true;
351
+ }
352
+ // getActiveSessions filters out stale idle sessions (>2h inactive) from filesystem hydration
353
+ const sessions = collections.getActiveSessions();
354
+ const actions = collections.getActions();
355
+ const execs = collections.getExecs();
356
+ const stats = collections.getStats();
357
+ res.writeHead(200, {
358
+ "Content-Type": "application/json",
359
+ "Cache-Control": "no-cache",
360
+ "Access-Control-Allow-Origin": "*",
361
+ });
362
+ res.end(JSON.stringify({ sessions, actions, execs, stats }));
363
+ return true;
364
+ }
310
365
  // ── GET /openalerts/logs → OpenClaw log entries (for Logs tab) ────
311
366
  if (url.startsWith("/openalerts/logs") && req.method === "GET") {
312
367
  const urlObj = new URL(url, "http://localhost");
@@ -328,6 +383,59 @@ export function createDashboardHandler(getEngine) {
328
383
  }));
329
384
  return true;
330
385
  }
386
+ // ── GET /openalerts/agent-monitor/stream → Live agent activity SSE ────
387
+ // Receives events from plugin hooks (before_agent_start, agent_end, after_tool_call, message_received)
388
+ if (url === "/openalerts/agent-monitor/stream" && req.method === "GET") {
389
+ res.writeHead(200, {
390
+ "Content-Type": "text/event-stream",
391
+ "Cache-Control": "no-cache",
392
+ Connection: "keep-alive",
393
+ "Access-Control-Allow-Origin": "*",
394
+ });
395
+ res.flushHeaders();
396
+ res.write(`:ok\n\n`);
397
+ // Replay buffered events so client sees full current session history
398
+ if (agentMonitorBuffer.length > 0) {
399
+ try {
400
+ res.write(`event: history\ndata: ${JSON.stringify(agentMonitorBuffer)}\n\n`);
401
+ }
402
+ catch { /* closed before flush */ }
403
+ }
404
+ const listener = (event) => {
405
+ try {
406
+ res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
407
+ }
408
+ catch { /* closed */ }
409
+ };
410
+ monitorListeners.add(listener);
411
+ const heartbeat = setInterval(() => {
412
+ try {
413
+ res.write(`:heartbeat\n\n`);
414
+ }
415
+ catch { /* closed */ }
416
+ }, 15_000);
417
+ const conn = { res, heartbeat };
418
+ monitorConnections.add(conn);
419
+ req.on("close", () => {
420
+ clearInterval(heartbeat);
421
+ monitorListeners.delete(listener);
422
+ monitorConnections.delete(conn);
423
+ });
424
+ return true;
425
+ }
426
+ // ── GET /openalerts/test/events → Last 10 gateway events ────
427
+ if (url === "/openalerts/test/events" && req.method === "GET") {
428
+ res.writeHead(200, {
429
+ "Content-Type": "application/json",
430
+ "Cache-Control": "no-cache",
431
+ "Access-Control-Allow-Origin": "*",
432
+ });
433
+ res.end(JSON.stringify({
434
+ count: recentGatewayEvents.length,
435
+ events: recentGatewayEvents,
436
+ }));
437
+ return true;
438
+ }
331
439
  // Unknown /openalerts sub-route
332
440
  res.writeHead(404, { "Content-Type": "text/plain" });
333
441
  res.end("Not found");
@@ -0,0 +1,39 @@
1
+ import EventEmitter from "eventemitter3";
2
+ interface GatewayClientConfig {
3
+ url: string;
4
+ token: string;
5
+ reconnectInterval: number;
6
+ maxRetries: number;
7
+ }
8
+ /**
9
+ * WebSocket client for OpenClaw Gateway
10
+ * - Connects to ws://127.0.0.1:18789
11
+ * - Handles JSON-RPC requests/responses
12
+ * - Emits events: agent, health, cron, chat
13
+ * - Auto-reconnects on disconnect
14
+ */
15
+ export declare class GatewayClient extends EventEmitter {
16
+ private ws;
17
+ private config;
18
+ private pending;
19
+ private backoffMs;
20
+ private closed;
21
+ private connectTimer;
22
+ private ready;
23
+ constructor(config?: Partial<GatewayClientConfig>);
24
+ start(): void;
25
+ stop(): void;
26
+ isReady(): boolean;
27
+ private doConnect;
28
+ private sendConnectHandshake;
29
+ private handleMessage;
30
+ /**
31
+ * Send RPC request to gateway
32
+ * @example
33
+ * const cost = await client.request("usage.cost", { period: "day" });
34
+ * const sessions = await client.request("sessions.list");
35
+ */
36
+ request<T = unknown>(method: string, params?: unknown): Promise<T>;
37
+ private scheduleReconnect;
38
+ }
39
+ export {};
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocket } from "ws";
3
+ import EventEmitter from "eventemitter3";
4
+ /**
5
+ * WebSocket client for OpenClaw Gateway
6
+ * - Connects to ws://127.0.0.1:18789
7
+ * - Handles JSON-RPC requests/responses
8
+ * - Emits events: agent, health, cron, chat
9
+ * - Auto-reconnects on disconnect
10
+ */
11
+ export class GatewayClient extends EventEmitter {
12
+ ws = null;
13
+ config;
14
+ pending = new Map();
15
+ backoffMs = 1000;
16
+ closed = false;
17
+ connectTimer = null;
18
+ ready = false;
19
+ constructor(config) {
20
+ super();
21
+ this.config = {
22
+ url: config?.url ?? "ws://127.0.0.1:18789",
23
+ token: config?.token ?? "",
24
+ reconnectInterval: config?.reconnectInterval ?? 1000,
25
+ maxRetries: config?.maxRetries ?? 60,
26
+ };
27
+ }
28
+ start() {
29
+ if (this.closed) {
30
+ return;
31
+ }
32
+ this.doConnect();
33
+ }
34
+ stop() {
35
+ this.closed = true;
36
+ if (this.connectTimer) {
37
+ clearTimeout(this.connectTimer);
38
+ this.connectTimer = null;
39
+ }
40
+ if (this.ws) {
41
+ this.ws.close();
42
+ this.ws = null;
43
+ }
44
+ }
45
+ isReady() {
46
+ return this.ready;
47
+ }
48
+ doConnect() {
49
+ if (this.closed || this.ws) {
50
+ return;
51
+ }
52
+ this.ws = new WebSocket(this.config.url, {
53
+ maxPayload: 25 * 1024 * 1024,
54
+ });
55
+ this.ws.on("open", () => {
56
+ this.backoffMs = 1000;
57
+ // Gateway sends 'connect.challenge' event first
58
+ });
59
+ this.ws.on("message", (data) => {
60
+ this.handleMessage(data.toString());
61
+ });
62
+ this.ws.on("error", (err) => {
63
+ this.emit("error", err);
64
+ });
65
+ this.ws.on("close", () => {
66
+ this.ws = null;
67
+ this.ready = false;
68
+ this.emit("disconnected");
69
+ if (!this.closed) {
70
+ this.scheduleReconnect();
71
+ }
72
+ });
73
+ }
74
+ sendConnectHandshake() {
75
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
76
+ return;
77
+ }
78
+ const id = randomUUID();
79
+ const frame = {
80
+ type: "req",
81
+ id,
82
+ method: "connect",
83
+ params: {
84
+ minProtocol: 3,
85
+ maxProtocol: 3,
86
+ client: {
87
+ id: "cli",
88
+ displayName: "OpenAlerts Monitor",
89
+ version: "0.1.0",
90
+ platform: process.platform,
91
+ mode: "cli",
92
+ },
93
+ role: "operator",
94
+ scopes: ["operator.read"],
95
+ caps: [],
96
+ commands: [],
97
+ permissions: {},
98
+ locale: "en-US",
99
+ userAgent: "openalerts-monitor/0.1.0",
100
+ auth: this.config.token ? { token: this.config.token } : undefined,
101
+ },
102
+ };
103
+ this.pending.set(id, {
104
+ resolve: (result) => {
105
+ this.ready = true;
106
+ this.emit("ready", result);
107
+ },
108
+ reject: (err) => {
109
+ this.emit("error", new Error(`Connect handshake failed: ${err.message}`));
110
+ },
111
+ });
112
+ this.ws.send(JSON.stringify(frame));
113
+ }
114
+ handleMessage(raw) {
115
+ try {
116
+ const frame = JSON.parse(raw);
117
+ // Handle challenge-response auth
118
+ if (frame.type === "event" && frame.event === "connect.challenge") {
119
+ this.sendConnectHandshake();
120
+ return;
121
+ }
122
+ if (frame.type === "res") {
123
+ const pending = this.pending.get(frame.id);
124
+ if (pending) {
125
+ if (frame.error || frame.ok === false) {
126
+ const errMsg = typeof frame.error === "string"
127
+ ? frame.error
128
+ : typeof frame.payload === "object" &&
129
+ frame.payload &&
130
+ "message" in frame.payload
131
+ ? String(frame.payload.message)
132
+ : JSON.stringify(frame.error ?? frame.payload);
133
+ pending.reject(new Error(errMsg));
134
+ }
135
+ else {
136
+ pending.resolve(frame.payload ?? frame.result);
137
+ }
138
+ this.pending.delete(frame.id);
139
+ }
140
+ }
141
+ else if (frame.type === "event") {
142
+ // Emit the event for subscribers: agent, health, cron, chat
143
+ this.emit(frame.event, frame.payload);
144
+ }
145
+ }
146
+ catch (err) {
147
+ this.emit("error", new Error(`Failed to parse frame: ${err}`));
148
+ }
149
+ }
150
+ /**
151
+ * Send RPC request to gateway
152
+ * @example
153
+ * const cost = await client.request("usage.cost", { period: "day" });
154
+ * const sessions = await client.request("sessions.list");
155
+ */
156
+ request(method, params) {
157
+ if (!this.ready || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
158
+ return Promise.reject(new Error("Gateway not ready"));
159
+ }
160
+ const id = randomUUID();
161
+ return new Promise((resolve, reject) => {
162
+ const timeout = setTimeout(() => {
163
+ this.pending.delete(id);
164
+ reject(new Error(`Request timeout: ${method}`));
165
+ }, 10000);
166
+ this.pending.set(id, {
167
+ resolve: (value) => {
168
+ clearTimeout(timeout);
169
+ resolve(value);
170
+ },
171
+ reject: (err) => {
172
+ clearTimeout(timeout);
173
+ reject(err);
174
+ },
175
+ });
176
+ const frame = {
177
+ type: "req",
178
+ id,
179
+ method,
180
+ params,
181
+ };
182
+ if (!this.ws) {
183
+ this.pending.delete(id);
184
+ clearTimeout(timeout);
185
+ reject(new Error("WebSocket not connected"));
186
+ return;
187
+ }
188
+ this.ws.send(JSON.stringify(frame));
189
+ });
190
+ }
191
+ scheduleReconnect() {
192
+ if (this.connectTimer) {
193
+ clearTimeout(this.connectTimer);
194
+ }
195
+ this.connectTimer = setTimeout(() => {
196
+ this.backoffMs = Math.min(this.backoffMs * 2, 60000);
197
+ this.doConnect();
198
+ }, this.backoffMs);
199
+ }
200
+ }
@@ -14,6 +14,26 @@
14
14
  "quiet": { "type": "boolean" },
15
15
  "rules": {
16
16
  "type": "object",
17
+ "properties": {
18
+ "cost-hourly-spike": {
19
+ "type": "object",
20
+ "properties": {
21
+ "enabled": { "type": "boolean" },
22
+ "threshold": { "type": "number", "minimum": 0 },
23
+ "cooldownMinutes": { "type": "number", "minimum": 1 }
24
+ },
25
+ "additionalProperties": false
26
+ },
27
+ "cost-daily-budget": {
28
+ "type": "object",
29
+ "properties": {
30
+ "enabled": { "type": "boolean" },
31
+ "threshold": { "type": "number", "minimum": 0 },
32
+ "cooldownMinutes": { "type": "number", "minimum": 1 }
33
+ },
34
+ "additionalProperties": false
35
+ }
36
+ },
17
37
  "additionalProperties": {
18
38
  "type": "object",
19
39
  "properties": {
@@ -52,6 +72,16 @@
52
72
  "quiet": {
53
73
  "label": "Quiet Mode",
54
74
  "help": "Log alerts to file only — no messages sent to your channels."
75
+ },
76
+ "rules.cost-hourly-spike.threshold": {
77
+ "label": "Hourly Cost Threshold (USD)",
78
+ "help": "Alert when total LLM spend in the last 60 minutes exceeds this amount. Default: 5.0.",
79
+ "advanced": true
80
+ },
81
+ "rules.cost-daily-budget.threshold": {
82
+ "label": "Daily Budget Threshold (USD)",
83
+ "help": "Alert when total LLM spend in the last 24 hours exceeds this amount. Default: 20.0.",
84
+ "advanced": true
55
85
  }
56
86
  }
57
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steadwing/openalerts",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "OpenAlerts — An alerting layer for agentic frameworks",
6
6
  "author": "Steadwing",
@@ -21,10 +21,15 @@
21
21
  "publish": "npm publish",
22
22
  "prepublishOnly": "npm run build"
23
23
  },
24
+ "dependencies": {
25
+ "eventemitter3": "^5.0.1",
26
+ "ws": "^8.18.0"
27
+ },
24
28
  "peerDependencies": {
25
29
  "openclaw": "*"
26
30
  },
27
31
  "devDependencies": {
32
+ "@types/ws": "^8.5.13",
28
33
  "typescript": "^5.9.3"
29
34
  },
30
35
  "openclaw": {
@@ -36,7 +41,18 @@
36
41
  },
37
42
  "homepage": "https://github.com/steadwing/openalerts#readme",
38
43
  "bugs": "https://github.com/steadwing/openalerts/issues",
39
- "keywords": ["openalerts", "openclaw", "monitoring", "alerting", "plugin"],
44
+ "keywords": [
45
+ "openclaw",
46
+ "openclaw-plugin",
47
+ "alerting",
48
+ "monitoring",
49
+ "observability",
50
+ "llm-monitoring",
51
+ "agent-monitoring",
52
+ "ai-agents",
53
+ "real-time-alerts",
54
+ "error-tracking"
55
+ ],
40
56
  "engines": {
41
57
  "node": ">=18"
42
58
  },