@steadwing/openalerts 0.2.4 → 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.
package/dist/index.js CHANGED
@@ -1,15 +1,24 @@
1
- import { OpenAlertsEngine } from "./core/index.js";
1
+ import { ALL_RULES, OpenAlertsEngine } from "./core/index.js";
2
2
  import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
3
3
  import { createLogBridge } from "./plugin/log-bridge.js";
4
+ import { GatewayClient } from "./plugin/gateway-client.js";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
4
8
  import { OpenClawAlertChannel, createOpenClawEnricher, parseConfig, resolveAlertTarget, translateOpenClawEvent, translateToolCallHook, translateAgentStartHook, translateAgentEndHook, translateSessionStartHook, translateSessionEndHook, translateMessageSentHook, translateMessageReceivedHook, translateBeforeToolCallHook, translateBeforeCompactionHook, translateAfterCompactionHook, translateMessageSendingHook, translateToolResultPersistHook, translateGatewayStartHook, translateGatewayStopHook, } from "./plugin/adapter.js";
5
9
  import { bindEngine, createMonitorCommands } from "./plugin/commands.js";
6
- import { createDashboardHandler, closeDashboardConnections, } from "./plugin/dashboard-routes.js";
10
+ import { createDashboardHandler, closeDashboardConnections, emitAgentMonitorEvent, recordGatewayEvent, } from "./plugin/dashboard-routes.js";
11
+ import { CollectionManager, CollectionPersistence, parseGatewayEvent, diagnosticUsageToSessionUpdate, } from "./collections/index.js";
7
12
  const PLUGIN_ID = "openalerts";
8
13
  const LOG_PREFIX = "openalerts";
9
14
  let engine = null;
10
15
  let unsubDiagnostic = null;
11
16
  let unsubLogTransport = null;
12
17
  let logBridgeCleanup = null;
18
+ let gatewayClient = null;
19
+ let collections = null;
20
+ let collectionsPersistence = null;
21
+ let sessionSyncInterval = null;
13
22
  function createMonitorService(api) {
14
23
  return {
15
24
  id: PLUGIN_ID,
@@ -36,7 +45,7 @@ function createMonitorService(api) {
36
45
  engine.start();
37
46
  // Wire commands to engine
38
47
  bindEngine(engine, api);
39
- // ── Bridge 1: Diagnostic events → engine ──────────────────────────────
48
+ // ── Bridge 1: Diagnostic events → engine + collections ───────────────────
40
49
  // Covers: webhook.*, message.*, session.stuck, session.state,
41
50
  // model.usage, queue.lane.*, diagnostic.heartbeat, run.attempt
42
51
  unsubDiagnostic = onDiagnosticEvent((event) => {
@@ -44,6 +53,17 @@ function createMonitorService(api) {
44
53
  if (translated) {
45
54
  engine.ingest(translated);
46
55
  }
56
+ // Feed model.usage to collections for cost tracking
57
+ if (event.type === "model.usage" && collections) {
58
+ const parsed = diagnosticUsageToSessionUpdate(event);
59
+ if (parsed.session) {
60
+ collections.upsertSession(parsed.session);
61
+ }
62
+ if (parsed.action && collectionsPersistence) {
63
+ collections.addAction(parsed.action);
64
+ collectionsPersistence.queueAction(parsed.action);
65
+ }
66
+ }
47
67
  });
48
68
  // ── Bridge 2: Log transport → engine (fills gaps from non-firing hooks) ─
49
69
  // Parses structured log records to synthesize tool.call, session.start/end,
@@ -51,6 +71,27 @@ function createMonitorService(api) {
51
71
  const logBridge = createLogBridge(engine);
52
72
  unsubLogTransport = registerLogTransport(logBridge.transport);
53
73
  logBridgeCleanup = logBridge.cleanup;
74
+ // Helper to track actions in collections (for sessions, tools, messages)
75
+ const trackAction = (type, eventType, data, context) => {
76
+ if (!collections || !collectionsPersistence)
77
+ return;
78
+ const action = {
79
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
80
+ runId: data.runId || `run-${Date.now()}`,
81
+ sessionKey: context.sessionKey ||
82
+ context.sessionId ||
83
+ "unknown",
84
+ seq: Date.now() % 1000000,
85
+ type,
86
+ eventType,
87
+ timestamp: Date.now(),
88
+ content: typeof data.content === "string"
89
+ ? data.content
90
+ : JSON.stringify(data).slice(0, 500),
91
+ };
92
+ collections.addAction(action);
93
+ collectionsPersistence.queueAction(action);
94
+ };
54
95
  // ── Bridge 3: Plugin hooks → engine ───────────────────────────────────
55
96
  // Covers: tool calls, agent lifecycle, session lifecycle, gateway, messages
56
97
  // These events are NOT emitted as diagnostic events — they come through
@@ -61,38 +102,131 @@ function createMonitorService(api) {
61
102
  apiOn("after_tool_call", (data, hookCtx) => {
62
103
  if (!engine)
63
104
  return;
64
- engine.ingest(translateToolCallHook(data, {
65
- sessionId: hookCtx.sessionId,
66
- agentId: hookCtx.agentId,
67
- }));
105
+ const d = data;
106
+ const ctx = hookCtx;
107
+ const sessionId = ctx.sessionKey ||
108
+ ctx.sessionId ||
109
+ undefined;
110
+ const agentId = ctx.agentId;
111
+ const runId = ctx.runId;
112
+ engine.ingest(translateToolCallHook(d, { sessionId, agentId }));
113
+ const _toolKey = sessionId && isRealSessionKey(sessionId)
114
+ ? sessionId
115
+ : runSessionMap.get(runId || "");
116
+ const _toolParamSummary = d.params
117
+ ? JSON.stringify(d.params).slice(0, 200)
118
+ : undefined;
119
+ const _toolResultSummary = d.result
120
+ ? JSON.stringify(d.result).slice(0, 300)
121
+ : undefined;
122
+ if (_toolKey)
123
+ emitAgentMonitorEvent({
124
+ type: "agent",
125
+ data: {
126
+ ts: Date.now(),
127
+ type: "agent",
128
+ sessionKey: _toolKey,
129
+ runId: runId || `tool-${Date.now()}`,
130
+ data: {
131
+ phase: "tool",
132
+ toolName: d.toolName,
133
+ durationMs: d.durationMs,
134
+ error: d.error,
135
+ params: _toolParamSummary,
136
+ result: _toolResultSummary,
137
+ },
138
+ },
139
+ });
68
140
  });
69
141
  // Agent lifecycle
70
142
  apiOn("before_agent_start", (data, hookCtx) => {
71
143
  if (!engine)
72
144
  return;
73
- engine.ingest(translateAgentStartHook(data, {
74
- sessionId: hookCtx.sessionId,
75
- agentId: hookCtx.agentId,
76
- }));
145
+ const ctx = hookCtx;
146
+ const sessionId = ctx.sessionKey ||
147
+ ctx.sessionId ||
148
+ undefined;
149
+ const agentId = ctx.agentId;
150
+ const runId = ctx.runId;
151
+ engine.ingest(translateAgentStartHook(data, { sessionId, agentId }));
152
+ const _startKey = sessionId && isRealSessionKey(sessionId)
153
+ ? sessionId
154
+ : runSessionMap.get(runId || "");
155
+ const _msgs = data.messages;
156
+ const _lastUMsg = Array.isArray(_msgs)
157
+ ? _msgs
158
+ .filter((m) => m.role === "user")
159
+ .pop()
160
+ : null;
161
+ const _startCtx = _lastUMsg
162
+ ? (typeof _lastUMsg.content === "string"
163
+ ? _lastUMsg.content
164
+ : JSON.stringify(_lastUMsg.content)).slice(0, 300)
165
+ : undefined;
166
+ if (_startKey) {
167
+ runSessionMap.set(runId || "", _startKey);
168
+ emitAgentMonitorEvent({
169
+ type: "agent",
170
+ data: {
171
+ ts: Date.now(),
172
+ type: "agent",
173
+ sessionKey: _startKey,
174
+ runId: runId || `run-${Date.now()}`,
175
+ data: { phase: "start", agentId, content: _startCtx },
176
+ },
177
+ });
178
+ }
77
179
  });
78
180
  apiOn("agent_end", (data, hookCtx) => {
79
181
  if (!engine)
80
182
  return;
81
- engine.ingest(translateAgentEndHook(data, {
82
- sessionId: hookCtx.sessionId,
83
- agentId: hookCtx.agentId,
84
- }));
183
+ const d = data;
184
+ const ctx = hookCtx;
185
+ const sessionId = ctx.sessionKey ||
186
+ ctx.sessionId ||
187
+ undefined;
188
+ const agentId = ctx.agentId;
189
+ const runId = ctx.runId;
190
+ engine.ingest(translateAgentEndHook(d, { sessionId, agentId }));
191
+ const _endKey = sessionId && isRealSessionKey(sessionId)
192
+ ? sessionId
193
+ : runSessionMap.get(runId || "");
194
+ if (_endKey)
195
+ emitAgentMonitorEvent({
196
+ type: "agent",
197
+ data: {
198
+ ts: Date.now(),
199
+ type: "agent",
200
+ sessionKey: _endKey,
201
+ runId: runId || `run-${Date.now()}`,
202
+ data: {
203
+ phase: "end",
204
+ durationMs: d.durationMs,
205
+ success: d.success,
206
+ error: d.error,
207
+ },
208
+ },
209
+ });
85
210
  });
86
211
  // Session lifecycle
87
- apiOn("session_start", (data) => {
212
+ apiOn("session_start", (data, hookCtx) => {
88
213
  if (!engine)
89
214
  return;
90
215
  engine.ingest(translateSessionStartHook(data));
216
+ // Track in collections
217
+ const ctx = hookCtx;
218
+ trackAction("start", "system", { sessionId: data.sessionId }, ctx);
91
219
  });
92
- apiOn("session_end", (data) => {
220
+ apiOn("session_end", (data, hookCtx) => {
93
221
  if (!engine)
94
222
  return;
95
223
  engine.ingest(translateSessionEndHook(data));
224
+ // Track in collections
225
+ const ctx = hookCtx;
226
+ trackAction("complete", "system", {
227
+ sessionId: data.sessionId,
228
+ messageCount: data.messageCount,
229
+ }, ctx);
96
230
  });
97
231
  // Message delivery tracking (all messages — success and failure)
98
232
  apiOn("message_sent", (data, hookCtx) => {
@@ -105,13 +239,36 @@ function createMonitorService(api) {
105
239
  }));
106
240
  });
107
241
  // Inbound message tracking (fires reliably in all modes)
242
+ // Inbound message tracking (fires reliably in all modes)
108
243
  apiOn("message_received", (data, hookCtx) => {
109
244
  if (!engine)
110
245
  return;
111
- engine.ingest(translateMessageReceivedHook(data, {
112
- channelId: hookCtx.channelId,
113
- accountId: hookCtx.accountId,
114
- }));
246
+ const d = data;
247
+ const ctx = hookCtx;
248
+ const channelId = ctx.channelId;
249
+ const accountId = ctx.accountId;
250
+ engine.ingest(translateMessageReceivedHook(d, { channelId, accountId }));
251
+ // Emit inbound message to agent monitor live view
252
+ const _inboundKey = channelId || "unknown";
253
+ // Register channelId as a known session so subsequent agent events can link to it
254
+ if (_inboundKey !== "unknown" && isRealSessionKey(_inboundKey)) {
255
+ // Will be linked by runId once agent starts
256
+ }
257
+ emitAgentMonitorEvent({
258
+ type: "chat",
259
+ data: {
260
+ ts: d.timestamp || Date.now(),
261
+ type: "chat",
262
+ sessionKey: _inboundKey,
263
+ runId: "inbound",
264
+ data: {
265
+ state: "inbound",
266
+ content: d.content,
267
+ from: d.from,
268
+ isInbound: true,
269
+ },
270
+ },
271
+ });
115
272
  });
116
273
  // Tool start tracking (fires reliably — complements log-bridge tool end)
117
274
  apiOn("before_tool_call", (data, hookCtx) => {
@@ -153,6 +310,25 @@ function createMonitorService(api) {
153
310
  apiOn("message_sending", (data, hookCtx) => {
154
311
  if (!engine)
155
312
  return;
313
+ const _sendCtx = hookCtx;
314
+ const _sendChannelId = _sendCtx.channelId;
315
+ const _sendData = data;
316
+ if (_sendChannelId) {
317
+ emitAgentMonitorEvent({
318
+ type: "chat",
319
+ data: {
320
+ ts: Date.now(),
321
+ type: "chat",
322
+ sessionKey: _sendChannelId,
323
+ runId: "outbound",
324
+ data: {
325
+ state: "outbound",
326
+ content: _sendData.content,
327
+ to: _sendData.to,
328
+ },
329
+ },
330
+ });
331
+ }
156
332
  engine.ingest(translateMessageSendingHook(data, {
157
333
  channelId: hookCtx.channelId,
158
334
  accountId: hookCtx.accountId,
@@ -171,13 +347,224 @@ function createMonitorService(api) {
171
347
  });
172
348
  logger.info(`${LOG_PREFIX}: subscribed to 13 plugin hooks (100% coverage: tool, agent, session, gateway, message, compaction)`);
173
349
  }
350
+ // ── Collections: Session/Action/Exec tracking ───────────────────────────
351
+ // NOTE: Direct filesystem reading for sessions (no gateway pairing needed)
352
+ // Sessions are read from ~/.openclaw/agents/<agent>/sessions/sessions.json
353
+ collections = new CollectionManager();
354
+ collectionsPersistence = new CollectionPersistence(ctx.stateDir);
355
+ collectionsPersistence.start();
356
+ // Read sessions from filesystem
357
+ const agentsDir = path.join(process.env.OPENCLAW_HOME ?? path.join(os.homedir(), ".openclaw"), "agents");
358
+ const loadSessionsFromFilesystem = () => {
359
+ try {
360
+ if (!fs.existsSync(agentsDir))
361
+ return;
362
+ const agentDirs = fs
363
+ .readdirSync(agentsDir, { withFileTypes: true })
364
+ .filter((d) => d.isDirectory());
365
+ let loadedCount = 0;
366
+ for (const agentDir of agentDirs) {
367
+ const sessionsFile = path.join(agentsDir, agentDir.name, "sessions", "sessions.json");
368
+ if (fs.existsSync(sessionsFile)) {
369
+ const content = fs.readFileSync(sessionsFile, "utf-8");
370
+ const sessionsObj = JSON.parse(content);
371
+ // sessions.json is an object with keys like "agent:main:main"
372
+ for (const [key, session] of Object.entries(sessionsObj)) {
373
+ const s = session;
374
+ if (s.sessionId) {
375
+ collections?.upsertSession({
376
+ key: key,
377
+ agentId: s.agentId || agentDir.name,
378
+ platform: s.platform || "unknown",
379
+ recipient: s.recipient || "",
380
+ isGroup: s.isGroup || false,
381
+ lastActivityAt: s.updatedAt || Date.now(),
382
+ status: "idle",
383
+ messageCount: s.messageCount,
384
+ });
385
+ loadedCount++;
386
+ }
387
+ }
388
+ }
389
+ }
390
+ logger.info(`${LOG_PREFIX}: loaded ${loadedCount} sessions from filesystem`);
391
+ }
392
+ catch (err) {
393
+ logger.warn(`${LOG_PREFIX}: failed to load sessions from filesystem: ${err}`);
394
+ }
395
+ };
396
+ loadSessionsFromFilesystem();
397
+ // Persist sessions immediately after loading
398
+ if (collections && collectionsPersistence) {
399
+ collectionsPersistence.saveSessions(collections.getSessions());
400
+ }
401
+ // Persist sessions periodically
402
+ sessionSyncInterval = setInterval(() => {
403
+ if (collections && collectionsPersistence) {
404
+ collectionsPersistence.saveSessions(collections.getSessions());
405
+ }
406
+ }, 30000);
407
+ // Hydrate from persisted data
408
+ const hydrated = collectionsPersistence.hydrate();
409
+ if (hydrated.sessions.length > 0 || hydrated.actions.length > 0) {
410
+ collections.hydrate(hydrated.sessions, hydrated.actions, hydrated.execEvents);
411
+ logger.info(`${LOG_PREFIX}: hydrated ${hydrated.sessions.length} sessions, ${hydrated.actions.length} actions, ${hydrated.execEvents.length} exec events`);
412
+ }
413
+ // Wire collection changes to persistence
414
+ collections.setCallbacks({
415
+ onSessionChange: (session) => {
416
+ if (collections && collectionsPersistence) {
417
+ collectionsPersistence.saveSessions(collections.getSessions());
418
+ }
419
+ },
420
+ onActionChange: (action) => {
421
+ collectionsPersistence?.queueAction(action);
422
+ },
423
+ onExecChange: (exec) => {
424
+ // Exec changes tracked via hooks
425
+ },
426
+ });
427
+ // ── runId → real session key map ─────────────────────────────────
428
+ // Chat events always carry the real sessionKey (agent:agentId:platform:recipient).
429
+ // Agent events often omit it, falling back to stream name ("assistant", "lifecycle").
430
+ // We build this map from chat events and use it to normalize agent events.
431
+ const runSessionMap = new Map();
432
+ const isRealSessionKey = (k) => k.split(":")[0] === "agent" && k.split(":").length >= 3;
433
+ // ── Bridge 4: Gateway WebSocket → engine + collections ─────────────────
434
+ // Connects to gateway WS for real-time session/action/exec tracking.
435
+ // Falls back gracefully if not paired (NOT_PAIRED error is handled).
436
+ // Use token from config or fall back to empty (will show pairing warning)
437
+ // Bug 4 fix: read token from config instead of hardcoding
438
+ const _gatewayCfg = api.config.gateway;
439
+ const _gatewayAuth = _gatewayCfg?.auth;
440
+ const gatewayToken = _gatewayAuth?.token || "";
441
+ if (gatewayToken) {
442
+ gatewayClient = new GatewayClient({
443
+ token: gatewayToken,
444
+ });
445
+ gatewayClient.on("ready", () => {
446
+ logger.info(`${LOG_PREFIX}: gateway client connected`);
447
+ });
448
+ gatewayClient.on("error", (err) => {
449
+ // Ignore pairing errors - plugin works without full gateway client
450
+ if (err.message.includes("NOT_PAIRED") ||
451
+ err.message.includes("device identity")) {
452
+ logger.warn(`${LOG_PREFIX}: gateway pairing not configured (optional)`);
453
+ }
454
+ else {
455
+ logger.warn(`${LOG_PREFIX}: gateway client error: ${err.message}`);
456
+ }
457
+ });
458
+ gatewayClient.on("disconnected", () => {
459
+ logger.info(`${LOG_PREFIX}: gateway client disconnected`);
460
+ });
461
+ // Wire gateway events to collections
462
+ const gatewayEventNames = ["chat", "agent", "health", "tick"];
463
+ for (const eventName of gatewayEventNames) {
464
+ gatewayClient.on(eventName, (payload) => {
465
+ // Record for test endpoint
466
+ recordGatewayEvent(eventName, payload);
467
+ if (collections) {
468
+ const parsed = parseGatewayEvent(eventName, payload);
469
+ if (parsed) {
470
+ if (parsed.session)
471
+ collections.upsertSession(parsed.session);
472
+ if (parsed.action) {
473
+ // Register real keys in runSessionMap
474
+ if (parsed.action.sessionKey &&
475
+ !parsed.action.sessionKey.includes("lifecycle") &&
476
+ isRealSessionKey(parsed.action.sessionKey)) {
477
+ runSessionMap.set(parsed.action.runId, parsed.action.sessionKey);
478
+ }
479
+ // Route to real key via runId; fall back to original key — never filter (frontend handles grouping)
480
+ const realKey = isRealSessionKey(parsed.action.sessionKey)
481
+ ? parsed.action.sessionKey
482
+ : runSessionMap.get(parsed.action.runId) ||
483
+ parsed.action.sessionKey;
484
+ collections.addAction(parsed.action);
485
+ // Forward parsed WS actions to the live agent monitor SSE stream
486
+ const action = parsed.action;
487
+ const sseType = action.eventType === "chat" ? "chat" : "agent";
488
+ const sseData = {
489
+ ts: action.timestamp,
490
+ sessionKey: realKey,
491
+ runId: action.runId,
492
+ };
493
+ if (action.eventType === "chat") {
494
+ const state = action.type === "complete"
495
+ ? "final"
496
+ : action.type === "streaming"
497
+ ? "delta"
498
+ : action.type === "error"
499
+ ? "error"
500
+ : "final";
501
+ sseData.data = {
502
+ state,
503
+ content: action.content,
504
+ inputTokens: action.inputTokens,
505
+ outputTokens: action.outputTokens,
506
+ stopReason: action.stopReason,
507
+ };
508
+ }
509
+ else {
510
+ const phase = action.type === "start"
511
+ ? "start"
512
+ : action.type === "complete"
513
+ ? "end"
514
+ : action.type === "error"
515
+ ? "error"
516
+ : action.type === "tool_call"
517
+ ? "tool"
518
+ : action.type === "streaming"
519
+ ? "streaming"
520
+ : action.type;
521
+ sseData.data = {
522
+ phase,
523
+ content: action.content,
524
+ toolName: action.toolName,
525
+ toolArgs: action.toolArgs,
526
+ durationMs: action.endedAt && action.startedAt
527
+ ? action.endedAt - action.startedAt
528
+ : undefined,
529
+ };
530
+ }
531
+ emitAgentMonitorEvent({ type: sseType, data: sseData });
532
+ }
533
+ if (parsed.execEvent) {
534
+ collections.addExecEvent(parsed.execEvent);
535
+ collectionsPersistence?.queueExecEvent(parsed.execEvent);
536
+ }
537
+ }
538
+ }
539
+ });
540
+ }
541
+ gatewayClient.start();
542
+ // Bug 1 fix: cost sync via usage.cost RPC removed
543
+ // Gateway denies operator.read scope; cost flows via model.usage diagnostic events
544
+ }
174
545
  const targetDesc = target
175
546
  ? `alerting to ${target.channel}:${target.to}`
176
547
  : "log-only (no alert channel detected)";
177
- logger.info(`${LOG_PREFIX}: started, ${targetDesc}, log-bridge active, 8 rules active`);
548
+ logger.info(`${LOG_PREFIX}: started, ${targetDesc}, log-bridge active, ${ALL_RULES.length} rules active`);
178
549
  },
179
550
  stop() {
180
551
  closeDashboardConnections();
552
+ if (sessionSyncInterval) {
553
+ clearInterval(sessionSyncInterval);
554
+ sessionSyncInterval = null;
555
+ }
556
+ if (gatewayClient) {
557
+ gatewayClient.stop();
558
+ gatewayClient = null;
559
+ }
560
+ if (collectionsPersistence) {
561
+ collectionsPersistence.stop();
562
+ collectionsPersistence = null;
563
+ }
564
+ if (collections) {
565
+ collections.clear();
566
+ collections = null;
567
+ }
181
568
  if (unsubLogTransport) {
182
569
  unsubLogTransport();
183
570
  unsubLogTransport = null;
@@ -207,7 +594,7 @@ const plugin = {
207
594
  api.registerCommand(cmd);
208
595
  }
209
596
  // Register dashboard HTTP routes under /openalerts*
210
- api.registerHttpHandler(createDashboardHandler(() => engine));
597
+ api.registerHttpHandler(createDashboardHandler(() => engine, () => collections));
211
598
  },
212
599
  };
213
600
  export default plugin;
@@ -194,6 +194,28 @@ export function getDashboardHtml() {
194
194
  .log-detail .ld-grid .lk{color:#8b949e;text-align:right}
195
195
  .log-detail .ld-grid .lv{color:#c9d1d9;word-break:break-all}
196
196
  .log-detail .ld-file{color:#484f58;font-size:10px;margin-top:3px}
197
+
198
+ /* ── Graph View ──────────────────── */
199
+ .view-toggle{display:flex;gap:2px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:2px;font-size:11px}
200
+ .view-toggle button{background:transparent;border:none;color:#8b949e;padding:3px 10px;border-radius:3px;cursor:pointer;font-family:inherit;font-size:11px}
201
+ .view-toggle button:hover{color:#c9d1d9;background:#21262d}
202
+ .view-toggle button.active{background:#21262d;color:#58a6ff}
203
+ #graphView{display:none;flex:1;overflow:auto;position:relative;background:#0d1117}
204
+ #graphView.active{display:block}
205
+ .graph-svg{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}
206
+ .graph-node{position:absolute;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#161b22;border:2px solid #30363d;border-radius:8px;padding:8px 12px;min-width:60px;cursor:pointer;transition:all 0.15s}
207
+ .graph-node:hover{border-color:#58a6ff;background:#1c2129}
208
+ .graph-node.session{border-color:#58a6ff;background:#0d1a2d}
209
+ .graph-node.action{border-radius:6px;padding:6px 10px}
210
+ .graph-node.tool{border-color:#bc8cff;background:#1a0d2d;border-radius:4px;padding:4px 8px;font-size:11px}
211
+ .graph-node.exec{border-color:#d29922;background:#2d1a0d;border-radius:4px;padding:4px 8px;font-size:11px}
212
+ .node-icon{font-size:20px;margin-bottom:2px}
213
+ .node-label{font-size:11px;color:#c9d1d9;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center}
214
+ .node-status{font-size:9px;color:#8b949e;margin-top:2px}
215
+ .node-status.running{color:#58a6ff}
216
+ .node-status.completed{color:#3fb950}
217
+ .node-status.error{color:#f85149}
218
+
197
219
  </style>
198
220
  </head>
199
221
  <body>
@@ -218,7 +240,10 @@ export function getDashboardHtml() {
218
240
  <div class="tab-content active" id="tab-activity">
219
241
  <div class="activity-panels">
220
242
  <div class="panel">
221
- <div class="panel-header"><span>Live Timeline</span><span style="color:#484f58;font-weight:400" id="evCnt">0</span></div>
243
+ <div class="panel-header">
244
+ <span>Live Timeline</span>
245
+ <span style="color:#484f58;font-weight:400" id="evCnt">0</span>
246
+ </div>
222
247
  <div class="scroll" id="evList"><div class="empty-msg" id="emptyMsg">Waiting for events... send a message to your bot.</div></div>
223
248
  </div>
224
249
  <div class="panel">
@@ -626,8 +651,13 @@ export function getDashboardHtml() {
626
651
  evSrc.addEventListener('history',function(e){try{var evs=JSON.parse(e.data);for(var i=0;i<evs.length;i++)addEvent(evs[i])}catch(_){}});
627
652
  evSrc.addEventListener('oclog',function(e){try{addLogEntry(JSON.parse(e.data))}catch(_){}});
628
653
  evSrc.onopen=function(){$('sDot').className='dot live';$('sConn').textContent='live'};
629
- evSrc.onerror=function(e){$('sDot').className='dot dead';$('sConn').textContent='err:'+evSrc.readyState};
630
- }catch(e){$('sConn').textContent='SSE fail:'+e.message}
654
+ evSrc.onerror=function(e){
655
+ $('sDot').className='dot dead';
656
+ $('sConn').textContent='reconnecting...';
657
+ evSrc.close();
658
+ setTimeout(connectSSE,3000);
659
+ };
660
+ }catch(e){$('sConn').textContent='SSE fail:'+e.message;setTimeout(connectSSE,3000);}
631
661
  }
632
662
 
633
663
  // ─── State polling ──────────────────────
@@ -1,7 +1,12 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { OpenAlertsEngine } from "../core/index.js";
3
3
  type HttpHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
4
- /** Close all active SSE connections. Call on engine stop. */
4
+ type AgentMonitorEvent = {
5
+ type: string;
6
+ data: unknown;
7
+ };
8
+ export declare function emitAgentMonitorEvent(event: AgentMonitorEvent): void;
9
+ export declare function recordGatewayEvent(eventName: string, payload: unknown): void;
5
10
  export declare function closeDashboardConnections(): void;
6
- export declare function createDashboardHandler(getEngine: () => OpenAlertsEngine | null): HttpHandler;
11
+ export declare function createDashboardHandler(getEngine: () => OpenAlertsEngine | null, getCollections: () => unknown): HttpHandler;
7
12
  export {};