aegis-bridge 0.1.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
  4. package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
  5. package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/api-contracts.d.ts +229 -0
  8. package/dist/api-contracts.js +7 -0
  9. package/dist/api-contracts.typecheck.d.ts +14 -0
  10. package/dist/api-contracts.typecheck.js +1 -0
  11. package/dist/api-error-envelope.d.ts +15 -0
  12. package/dist/api-error-envelope.js +80 -0
  13. package/dist/auth.d.ts +87 -0
  14. package/dist/auth.js +276 -0
  15. package/dist/channels/index.d.ts +8 -0
  16. package/dist/channels/index.js +8 -0
  17. package/dist/channels/manager.d.ts +47 -0
  18. package/dist/channels/manager.js +115 -0
  19. package/dist/channels/telegram-style.d.ts +118 -0
  20. package/dist/channels/telegram-style.js +202 -0
  21. package/dist/channels/telegram.d.ts +91 -0
  22. package/dist/channels/telegram.js +1518 -0
  23. package/dist/channels/types.d.ts +77 -0
  24. package/dist/channels/types.js +8 -0
  25. package/dist/channels/webhook.d.ts +60 -0
  26. package/dist/channels/webhook.js +216 -0
  27. package/dist/cli.d.ts +8 -0
  28. package/dist/cli.js +252 -0
  29. package/dist/config.d.ts +90 -0
  30. package/dist/config.js +214 -0
  31. package/dist/consensus.d.ts +16 -0
  32. package/dist/consensus.js +19 -0
  33. package/dist/continuation-pointer.d.ts +11 -0
  34. package/dist/continuation-pointer.js +65 -0
  35. package/dist/diagnostics.d.ts +27 -0
  36. package/dist/diagnostics.js +95 -0
  37. package/dist/error-categories.d.ts +39 -0
  38. package/dist/error-categories.js +73 -0
  39. package/dist/events.d.ts +133 -0
  40. package/dist/events.js +389 -0
  41. package/dist/fault-injection.d.ts +29 -0
  42. package/dist/fault-injection.js +115 -0
  43. package/dist/file-utils.d.ts +2 -0
  44. package/dist/file-utils.js +37 -0
  45. package/dist/handshake.d.ts +60 -0
  46. package/dist/handshake.js +124 -0
  47. package/dist/hook-settings.d.ts +80 -0
  48. package/dist/hook-settings.js +272 -0
  49. package/dist/hook.d.ts +19 -0
  50. package/dist/hook.js +231 -0
  51. package/dist/hooks.d.ts +32 -0
  52. package/dist/hooks.js +364 -0
  53. package/dist/jsonl-watcher.d.ts +59 -0
  54. package/dist/jsonl-watcher.js +166 -0
  55. package/dist/logger.d.ts +35 -0
  56. package/dist/logger.js +65 -0
  57. package/dist/mcp-server.d.ts +123 -0
  58. package/dist/mcp-server.js +869 -0
  59. package/dist/memory-bridge.d.ts +27 -0
  60. package/dist/memory-bridge.js +137 -0
  61. package/dist/memory-routes.d.ts +3 -0
  62. package/dist/memory-routes.js +100 -0
  63. package/dist/metrics.d.ts +126 -0
  64. package/dist/metrics.js +286 -0
  65. package/dist/model-router.d.ts +53 -0
  66. package/dist/model-router.js +150 -0
  67. package/dist/monitor.d.ts +103 -0
  68. package/dist/monitor.js +820 -0
  69. package/dist/path-utils.d.ts +11 -0
  70. package/dist/path-utils.js +21 -0
  71. package/dist/permission-evaluator.d.ts +10 -0
  72. package/dist/permission-evaluator.js +48 -0
  73. package/dist/permission-guard.d.ts +51 -0
  74. package/dist/permission-guard.js +196 -0
  75. package/dist/permission-request-manager.d.ts +12 -0
  76. package/dist/permission-request-manager.js +36 -0
  77. package/dist/permission-routes.d.ts +7 -0
  78. package/dist/permission-routes.js +28 -0
  79. package/dist/pipeline.d.ts +97 -0
  80. package/dist/pipeline.js +291 -0
  81. package/dist/process-utils.d.ts +4 -0
  82. package/dist/process-utils.js +73 -0
  83. package/dist/question-manager.d.ts +54 -0
  84. package/dist/question-manager.js +80 -0
  85. package/dist/retry.d.ts +11 -0
  86. package/dist/retry.js +34 -0
  87. package/dist/safe-json.d.ts +12 -0
  88. package/dist/safe-json.js +22 -0
  89. package/dist/screenshot.d.ts +28 -0
  90. package/dist/screenshot.js +60 -0
  91. package/dist/server.d.ts +10 -0
  92. package/dist/server.js +1973 -0
  93. package/dist/session-cleanup.d.ts +18 -0
  94. package/dist/session-cleanup.js +11 -0
  95. package/dist/session.d.ts +379 -0
  96. package/dist/session.js +1568 -0
  97. package/dist/shutdown-utils.d.ts +5 -0
  98. package/dist/shutdown-utils.js +24 -0
  99. package/dist/signal-cleanup-helper.d.ts +48 -0
  100. package/dist/signal-cleanup-helper.js +117 -0
  101. package/dist/sse-limiter.d.ts +47 -0
  102. package/dist/sse-limiter.js +61 -0
  103. package/dist/sse-writer.d.ts +31 -0
  104. package/dist/sse-writer.js +94 -0
  105. package/dist/ssrf.d.ts +102 -0
  106. package/dist/ssrf.js +267 -0
  107. package/dist/startup.d.ts +6 -0
  108. package/dist/startup.js +162 -0
  109. package/dist/suppress.d.ts +33 -0
  110. package/dist/suppress.js +79 -0
  111. package/dist/swarm-monitor.d.ts +117 -0
  112. package/dist/swarm-monitor.js +300 -0
  113. package/dist/template-store.d.ts +45 -0
  114. package/dist/template-store.js +142 -0
  115. package/dist/terminal-parser.d.ts +16 -0
  116. package/dist/terminal-parser.js +346 -0
  117. package/dist/tmux-capture-cache.d.ts +18 -0
  118. package/dist/tmux-capture-cache.js +34 -0
  119. package/dist/tmux.d.ts +183 -0
  120. package/dist/tmux.js +906 -0
  121. package/dist/tool-registry.d.ts +40 -0
  122. package/dist/tool-registry.js +83 -0
  123. package/dist/transcript.d.ts +63 -0
  124. package/dist/transcript.js +284 -0
  125. package/dist/utils/circular-buffer.d.ts +11 -0
  126. package/dist/utils/circular-buffer.js +37 -0
  127. package/dist/utils/redact-headers.d.ts +13 -0
  128. package/dist/utils/redact-headers.js +54 -0
  129. package/dist/validation.d.ts +406 -0
  130. package/dist/validation.js +415 -0
  131. package/dist/verification.d.ts +2 -0
  132. package/dist/verification.js +72 -0
  133. package/dist/worktree-lookup.d.ts +24 -0
  134. package/dist/worktree-lookup.js +71 -0
  135. package/dist/ws-terminal.d.ts +32 -0
  136. package/dist/ws-terminal.js +348 -0
  137. package/package.json +83 -0
@@ -0,0 +1,27 @@
1
+ interface MemoryEntry {
2
+ value: string;
3
+ namespace: string;
4
+ key: string;
5
+ created_at: number;
6
+ updated_at: number;
7
+ expires_at?: number;
8
+ }
9
+ export declare class MemoryBridge {
10
+ private reaperIntervalMs;
11
+ private store;
12
+ private persistPath;
13
+ private reaperTimer;
14
+ private saveTimer;
15
+ constructor(persistPath?: string | null, reaperIntervalMs?: number);
16
+ set(key: string, value: string, ttlSeconds?: number): MemoryEntry;
17
+ get(key: string): MemoryEntry | null;
18
+ delete(key: string): boolean;
19
+ list(prefix?: string): MemoryEntry[];
20
+ resolveKeys(keys: string[]): Map<string, string>;
21
+ load(): Promise<void>;
22
+ save(): Promise<void>;
23
+ private scheduleSave;
24
+ startReaper(): void;
25
+ stopReaper(): void;
26
+ }
27
+ export {};
@@ -0,0 +1,137 @@
1
+ import { existsSync, renameSync, writeFileSync, readFileSync } from "fs";
2
+ import { safeJsonParse } from './safe-json.js';
3
+ function isMemoryEntry(value) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value))
5
+ return false;
6
+ const entry = value;
7
+ if (typeof entry.key !== 'string')
8
+ return false;
9
+ if (typeof entry.value !== 'string')
10
+ return false;
11
+ if (typeof entry.namespace !== 'string')
12
+ return false;
13
+ if (typeof entry.created_at !== 'number')
14
+ return false;
15
+ if (typeof entry.updated_at !== 'number')
16
+ return false;
17
+ if (entry.expires_at !== undefined && typeof entry.expires_at !== 'number')
18
+ return false;
19
+ return true;
20
+ }
21
+ const KEY_REGEX = /^(.+?)\/(.+)$/;
22
+ const MAX_KEY_LEN = 256;
23
+ const MAX_VALUE_SIZE = 100 * 1024; // 100KB
24
+ export class MemoryBridge {
25
+ reaperIntervalMs;
26
+ store = new Map();
27
+ persistPath = null;
28
+ reaperTimer = null;
29
+ saveTimer = null;
30
+ constructor(persistPath = null, reaperIntervalMs = 60_000) {
31
+ this.reaperIntervalMs = reaperIntervalMs;
32
+ this.persistPath = persistPath;
33
+ }
34
+ set(key, value, ttlSeconds) {
35
+ if (value.length > MAX_VALUE_SIZE)
36
+ throw new Error("Value exceeds maximum size");
37
+ const m = KEY_REGEX.exec(key);
38
+ if (!m)
39
+ throw new Error(`Invalid key format: must be namespace/key, got "${key}"`);
40
+ const [, namespace, keyName] = m;
41
+ if (key.length > MAX_KEY_LEN)
42
+ throw new Error("Key exceeds maximum length");
43
+ const now = Date.now();
44
+ const entry = {
45
+ value, namespace, key,
46
+ created_at: this.store.has(key) ? this.store.get(key).created_at : now,
47
+ updated_at: now,
48
+ expires_at: ttlSeconds ? now + ttlSeconds * 1000 : undefined,
49
+ };
50
+ this.store.set(key, entry);
51
+ this.scheduleSave();
52
+ return entry;
53
+ }
54
+ get(key) {
55
+ const entry = this.store.get(key);
56
+ if (!entry)
57
+ return null;
58
+ if (entry.expires_at && Date.now() > entry.expires_at) {
59
+ this.store.delete(key);
60
+ return null;
61
+ }
62
+ return entry;
63
+ }
64
+ delete(key) {
65
+ const deleted = this.store.delete(key);
66
+ if (deleted)
67
+ this.scheduleSave();
68
+ return deleted;
69
+ }
70
+ list(prefix) {
71
+ const now = Date.now();
72
+ const entries = [...this.store.values()].filter(e => !e.expires_at || now <= e.expires_at);
73
+ if (!prefix)
74
+ return entries;
75
+ return entries.filter(e => e.key.startsWith(prefix));
76
+ }
77
+ resolveKeys(keys) {
78
+ const result = new Map();
79
+ for (const k of keys) {
80
+ const e = this.get(k);
81
+ if (e)
82
+ result.set(k, e.value);
83
+ }
84
+ return result;
85
+ }
86
+ async load() {
87
+ if (!this.persistPath || !existsSync(this.persistPath))
88
+ return;
89
+ const parsed = safeJsonParse(readFileSync(this.persistPath, 'utf-8'), 'Memory bridge store');
90
+ if (!parsed.ok)
91
+ return;
92
+ if (!Array.isArray(parsed.data))
93
+ return;
94
+ for (const rawEntry of parsed.data) {
95
+ if (!isMemoryEntry(rawEntry))
96
+ continue;
97
+ this.store.set(rawEntry.key, rawEntry);
98
+ }
99
+ }
100
+ async save() {
101
+ if (!this.persistPath)
102
+ return;
103
+ const entries = [...this.store.values()];
104
+ const tmp = this.persistPath + ".tmp";
105
+ writeFileSync(tmp, JSON.stringify(entries, null, 2));
106
+ renameSync(tmp, this.persistPath);
107
+ }
108
+ scheduleSave() {
109
+ if (this.saveTimer)
110
+ return;
111
+ this.saveTimer = setTimeout(async () => {
112
+ this.saveTimer = null;
113
+ await this.save();
114
+ }, 1000);
115
+ }
116
+ startReaper() {
117
+ if (this.reaperTimer)
118
+ return;
119
+ this.reaperTimer = setInterval(() => {
120
+ const now = Date.now();
121
+ for (const [k, e] of this.store) {
122
+ if (e.expires_at && now > e.expires_at)
123
+ this.store.delete(k);
124
+ }
125
+ }, this.reaperIntervalMs);
126
+ }
127
+ stopReaper() {
128
+ if (this.reaperTimer) {
129
+ clearInterval(this.reaperTimer);
130
+ this.reaperTimer = null;
131
+ }
132
+ if (this.saveTimer) {
133
+ clearTimeout(this.saveTimer);
134
+ this.saveTimer = null;
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,3 @@
1
+ import { FastifyInstance } from 'fastify';
2
+ import { MemoryBridge } from './memory-bridge.js';
3
+ export declare function registerMemoryRoutes(app: FastifyInstance, bridge: MemoryBridge): void;
@@ -0,0 +1,100 @@
1
+ import { isValidUUID } from './validation.js';
2
+ import { z } from 'zod';
3
+ const setMemorySchema = z.object({
4
+ key: z.string().max(256),
5
+ value: z.string().max(100 * 1024),
6
+ ttlSeconds: z.number().int().positive().max(86400 * 30).optional(),
7
+ }).strict();
8
+ const getMemorySchema = z.object({
9
+ prefix: z.string().optional(),
10
+ });
11
+ export function registerMemoryRoutes(app, bridge) {
12
+ // POST /v1/memory — write a memory entry
13
+ app.post('/v1/memory', async (req, reply) => {
14
+ const parsed = setMemorySchema.safeParse(req.body ?? {});
15
+ if (!parsed.success) {
16
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
17
+ }
18
+ const { key, value, ttlSeconds } = parsed.data;
19
+ try {
20
+ const entry = bridge.set(key, value, ttlSeconds);
21
+ return { ok: true, entry };
22
+ }
23
+ catch (e) {
24
+ const msg = e instanceof Error ? e.message : String(e);
25
+ if (msg.includes('Invalid key format'))
26
+ return reply.status(400).send({ error: msg });
27
+ if (msg.includes('exceeds maximum size'))
28
+ return reply.status(413).send({ error: msg });
29
+ throw e;
30
+ }
31
+ });
32
+ // GET /v1/memory/:key — read a memory entry
33
+ app.get('/v1/memory/:key', async (req, reply) => {
34
+ const key = decodeURIComponent(req.params.key);
35
+ const entry = bridge.get(key);
36
+ if (!entry)
37
+ return reply.status(404).send({ error: `Key not found: ${key}` });
38
+ return { entry };
39
+ });
40
+ // GET /v1/memory — list entries, optionally filtered by prefix
41
+ app.get('/v1/memory', async (req, reply) => {
42
+ const { prefix } = req.query;
43
+ const entries = bridge.list(prefix);
44
+ return { entries };
45
+ });
46
+ // DELETE /v1/memory/:key — delete a memory entry
47
+ app.delete('/v1/memory/:key', async (req, reply) => {
48
+ const key = decodeURIComponent(req.params.key);
49
+ const deleted = bridge.delete(key);
50
+ if (!deleted)
51
+ return reply.status(404).send({ error: `Key not found: ${key}` });
52
+ return { ok: true };
53
+ });
54
+ // Issue #705: Scoped memory retrieval — GET /v1/memories?scope=project|user|team
55
+ const VALID_SCOPES = new Set(['project', 'user', 'team']);
56
+ app.get('/v1/memories', async (req, reply) => {
57
+ const { scope } = req.query;
58
+ if (!scope || !VALID_SCOPES.has(scope)) {
59
+ return reply.status(400).send({ error: 'scope must be one of: project, user, team' });
60
+ }
61
+ const entries = bridge.list(`${scope}/`);
62
+ return { scope, entries };
63
+ });
64
+ // Issue #705: Session-linked memories — POST /v1/sessions/:id/memories
65
+ const sessionMemoryWriteSchema = z.object({
66
+ key: z.string().min(1).max(200),
67
+ value: z.string().max(100 * 1024),
68
+ ttlSeconds: z.number().int().positive().max(86400 * 30).optional(),
69
+ }).strict();
70
+ app.post('/v1/sessions/:id/memories', async (req, reply) => {
71
+ const { id } = req.params;
72
+ if (!isValidUUID(id))
73
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
74
+ const parsed = sessionMemoryWriteSchema.safeParse(req.body ?? {});
75
+ if (!parsed.success)
76
+ return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues });
77
+ const { key, value, ttlSeconds } = parsed.data;
78
+ const fullKey = `session:${id}/${key}`;
79
+ try {
80
+ const entry = bridge.set(fullKey, value, ttlSeconds);
81
+ return { ok: true, entry };
82
+ }
83
+ catch (e) {
84
+ const msg = e instanceof Error ? e.message : String(e);
85
+ if (msg.includes('Invalid key format'))
86
+ return reply.status(400).send({ error: msg });
87
+ if (msg.includes('exceeds maximum size'))
88
+ return reply.status(413).send({ error: msg });
89
+ throw e;
90
+ }
91
+ });
92
+ // Issue #705: Session-linked memories — GET /v1/sessions/:id/memories
93
+ app.get('/v1/sessions/:id/memories', async (req, reply) => {
94
+ const { id } = req.params;
95
+ if (!isValidUUID(id))
96
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
97
+ const entries = bridge.list(`session:${id}/`);
98
+ return { sessionId: id, entries };
99
+ });
100
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * metrics.ts — Usage metrics and counters.
3
+ *
4
+ * Issue #40: Global and per-session metrics for monitoring.
5
+ * Counters are in-memory, persisted to disk on shutdown, loaded on startup.
6
+ */
7
+ import type { GlobalMetrics as GlobalMetricsResponse } from './api-contracts.js';
8
+ import type { TokenUsageDelta } from './transcript.js';
9
+ export interface GlobalMetrics {
10
+ sessionsCreated: number;
11
+ sessionsCompleted: number;
12
+ sessionsFailed: number;
13
+ totalMessages: number;
14
+ totalToolCalls: number;
15
+ autoApprovals: number;
16
+ webhooksSent: number;
17
+ webhooksFailed: number;
18
+ screenshotsTaken: number;
19
+ pipelinesCreated: number;
20
+ batchesCreated: number;
21
+ promptsSent: number;
22
+ promptsDelivered: number;
23
+ promptsFailed: number;
24
+ }
25
+ /** Issue #488: Cumulative token usage + estimated cost for a session. */
26
+ export interface SessionTokenUsage {
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ cacheCreationTokens: number;
30
+ cacheReadTokens: number;
31
+ estimatedCostUsd: number;
32
+ }
33
+ export interface SessionMetrics {
34
+ durationSec: number;
35
+ messages: number;
36
+ toolCalls: number;
37
+ approvals: number;
38
+ autoApprovals: number;
39
+ statusChanges: string[];
40
+ /** Issue #488: Cumulative token usage and estimated cost. Present once tokens are first observed. */
41
+ tokenUsage?: SessionTokenUsage;
42
+ }
43
+ /** Issue #87: Per-session latency samples (rolling window). */
44
+ export interface SessionLatency {
45
+ hook_latency_ms: number[];
46
+ state_change_detection_ms: number[];
47
+ permission_response_ms: number[];
48
+ channel_delivery_ms: number[];
49
+ }
50
+ /** Issue #87: Aggregated latency summary for a session. */
51
+ export interface SessionLatencySummary {
52
+ hook_latency_ms: {
53
+ min: number | null;
54
+ max: number | null;
55
+ avg: number | null;
56
+ count: number;
57
+ };
58
+ state_change_detection_ms: {
59
+ min: number | null;
60
+ max: number | null;
61
+ avg: number | null;
62
+ count: number;
63
+ };
64
+ permission_response_ms: {
65
+ min: number | null;
66
+ max: number | null;
67
+ avg: number | null;
68
+ count: number;
69
+ };
70
+ channel_delivery_ms: {
71
+ min: number | null;
72
+ max: number | null;
73
+ avg: number | null;
74
+ count: number;
75
+ };
76
+ }
77
+ export declare class MetricsCollector {
78
+ private metricsFile;
79
+ private global;
80
+ private perSession;
81
+ private latency;
82
+ private startTime;
83
+ /** Maximum samples per latency type per session (rolling window). */
84
+ static readonly MAX_LATENCY_SAMPLES = 100;
85
+ /**
86
+ * Issue #488: Cost per million tokens by model family.
87
+ * Rates: [input $/M, output $/M, cacheWrite $/M, cacheRead $/M].
88
+ */
89
+ private static readonly COST_TABLE;
90
+ private static estimateCost;
91
+ constructor(metricsFile: string);
92
+ load(): Promise<void>;
93
+ save(): Promise<void>;
94
+ sessionCreated(sessionId: string): void;
95
+ sessionCompleted(sessionId: string): void;
96
+ sessionFailed(sessionId: string): void;
97
+ messageReceived(sessionId: string): void;
98
+ toolCallReceived(sessionId: string): void;
99
+ approvalGranted(sessionId: string, auto?: boolean): void;
100
+ statusChanged(sessionId: string, status: string): void;
101
+ webhookSent(): void;
102
+ webhookFailed(): void;
103
+ screenshotTaken(): void;
104
+ pipelineCreated(): void;
105
+ batchCreated(): void;
106
+ promptSent(delivered: boolean): void;
107
+ /** Issue #488: Accumulate token usage for a session. */
108
+ recordTokenUsage(sessionId: string, delta: TokenUsageDelta, model?: string): void;
109
+ private getOrCreateLatency;
110
+ private pushSample;
111
+ recordHookLatency(sessionId: string, latencyMs: number): void;
112
+ recordStateChangeDetection(sessionId: string, latencyMs: number): void;
113
+ recordPermissionResponse(sessionId: string, latencyMs: number): void;
114
+ recordChannelDelivery(sessionId: string, latencyMs: number): void;
115
+ private summarizeSamples;
116
+ /** Stream-aggregate a single latency field across all sessions without creating temp arrays. */
117
+ private aggregateLatencyField;
118
+ getSessionLatency(sessionId: string): SessionLatencySummary | null;
119
+ /** Clean up latency data for a session (called on session kill). */
120
+ clearSessionLatency(sessionId: string): void;
121
+ /** #357: Clean up all per-session data (call on session destroy). */
122
+ cleanupSession(sessionId: string): void;
123
+ getGlobalMetrics(activeSessionCount: number): GlobalMetricsResponse;
124
+ getSessionMetrics(sessionId: string): SessionMetrics | null;
125
+ getTotalSessionsCreated(): number;
126
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * metrics.ts — Usage metrics and counters.
3
+ *
4
+ * Issue #40: Global and per-session metrics for monitoring.
5
+ * Counters are in-memory, persisted to disk on shutdown, loaded on startup.
6
+ */
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { metricsFileSchema } from './validation.js';
11
+ export class MetricsCollector {
12
+ metricsFile;
13
+ global = {
14
+ sessionsCreated: 0,
15
+ sessionsCompleted: 0,
16
+ sessionsFailed: 0,
17
+ totalMessages: 0,
18
+ totalToolCalls: 0,
19
+ autoApprovals: 0,
20
+ webhooksSent: 0,
21
+ webhooksFailed: 0,
22
+ screenshotsTaken: 0,
23
+ pipelinesCreated: 0,
24
+ batchesCreated: 0,
25
+ promptsSent: 0,
26
+ promptsDelivered: 0,
27
+ promptsFailed: 0,
28
+ };
29
+ perSession = new Map();
30
+ latency = new Map();
31
+ startTime = Date.now();
32
+ /** Maximum samples per latency type per session (rolling window). */
33
+ static MAX_LATENCY_SAMPLES = 100;
34
+ /**
35
+ * Issue #488: Cost per million tokens by model family.
36
+ * Rates: [input $/M, output $/M, cacheWrite $/M, cacheRead $/M].
37
+ */
38
+ static COST_TABLE = {
39
+ 'haiku': [0.80, 4.00, 1.00, 0.08],
40
+ 'sonnet': [3.00, 15.00, 3.75, 0.30],
41
+ 'opus': [15.00, 75.00, 18.75, 1.50],
42
+ };
43
+ static estimateCost(delta, model) {
44
+ let tier = MetricsCollector.COST_TABLE['sonnet'];
45
+ if (model) {
46
+ const lower = model.toLowerCase();
47
+ if (lower.includes('haiku'))
48
+ tier = MetricsCollector.COST_TABLE['haiku'];
49
+ else if (lower.includes('opus'))
50
+ tier = MetricsCollector.COST_TABLE['opus'];
51
+ }
52
+ const [inRate, outRate, cwRate, crRate] = tier;
53
+ return ((delta.inputTokens * inRate +
54
+ delta.outputTokens * outRate +
55
+ delta.cacheCreationTokens * cwRate +
56
+ delta.cacheReadTokens * crRate) / 1_000_000);
57
+ }
58
+ constructor(metricsFile) {
59
+ this.metricsFile = metricsFile;
60
+ }
61
+ async load() {
62
+ if (existsSync(this.metricsFile)) {
63
+ try {
64
+ const raw = await readFile(this.metricsFile, 'utf-8');
65
+ const parsed = metricsFileSchema.safeParse(JSON.parse(raw));
66
+ if (parsed.success && parsed.data.global) {
67
+ this.global = { ...this.global, ...parsed.data.global };
68
+ }
69
+ }
70
+ catch { /* ignore corrupt file */ }
71
+ }
72
+ }
73
+ async save() {
74
+ const dir = dirname(this.metricsFile);
75
+ if (!existsSync(dir)) {
76
+ await mkdir(dir, { recursive: true });
77
+ }
78
+ await writeFile(this.metricsFile, JSON.stringify({ global: this.global, savedAt: Date.now() }, null, 2));
79
+ }
80
+ sessionCreated(sessionId) {
81
+ this.global.sessionsCreated++;
82
+ this.perSession.set(sessionId, {
83
+ durationSec: 0, messages: 0, toolCalls: 0,
84
+ approvals: 0, autoApprovals: 0, statusChanges: [],
85
+ });
86
+ }
87
+ sessionCompleted(sessionId) {
88
+ this.global.sessionsCompleted++;
89
+ }
90
+ sessionFailed(sessionId) {
91
+ this.global.sessionsFailed++;
92
+ }
93
+ messageReceived(sessionId) {
94
+ this.global.totalMessages++;
95
+ const m = this.perSession.get(sessionId);
96
+ if (m)
97
+ m.messages++;
98
+ }
99
+ toolCallReceived(sessionId) {
100
+ this.global.totalToolCalls++;
101
+ const m = this.perSession.get(sessionId);
102
+ if (m)
103
+ m.toolCalls++;
104
+ }
105
+ approvalGranted(sessionId, auto = false) {
106
+ if (auto)
107
+ this.global.autoApprovals++;
108
+ const m = this.perSession.get(sessionId);
109
+ if (m) {
110
+ m.approvals++;
111
+ if (auto)
112
+ m.autoApprovals++;
113
+ }
114
+ }
115
+ statusChanged(sessionId, status) {
116
+ const m = this.perSession.get(sessionId);
117
+ if (m)
118
+ m.statusChanges.push(status);
119
+ }
120
+ webhookSent() { this.global.webhooksSent++; }
121
+ webhookFailed() { this.global.webhooksFailed++; }
122
+ screenshotTaken() { this.global.screenshotsTaken++; }
123
+ pipelineCreated() { this.global.pipelinesCreated++; }
124
+ batchCreated() { this.global.batchesCreated++; }
125
+ promptSent(delivered) {
126
+ this.global.promptsSent++;
127
+ if (delivered) {
128
+ this.global.promptsDelivered++;
129
+ }
130
+ else {
131
+ this.global.promptsFailed++;
132
+ }
133
+ }
134
+ /** Issue #488: Accumulate token usage for a session. */
135
+ recordTokenUsage(sessionId, delta, model) {
136
+ const m = this.perSession.get(sessionId);
137
+ if (!m)
138
+ return;
139
+ const addedCost = MetricsCollector.estimateCost(delta, model);
140
+ if (!m.tokenUsage) {
141
+ m.tokenUsage = {
142
+ inputTokens: delta.inputTokens,
143
+ outputTokens: delta.outputTokens,
144
+ cacheCreationTokens: delta.cacheCreationTokens,
145
+ cacheReadTokens: delta.cacheReadTokens,
146
+ estimatedCostUsd: addedCost,
147
+ };
148
+ }
149
+ else {
150
+ m.tokenUsage.inputTokens += delta.inputTokens;
151
+ m.tokenUsage.outputTokens += delta.outputTokens;
152
+ m.tokenUsage.cacheCreationTokens += delta.cacheCreationTokens;
153
+ m.tokenUsage.cacheReadTokens += delta.cacheReadTokens;
154
+ m.tokenUsage.estimatedCostUsd += addedCost;
155
+ }
156
+ }
157
+ // ── Issue #87: Latency metric recording ─────────────────────────────
158
+ getOrCreateLatency(sessionId) {
159
+ let lat = this.latency.get(sessionId);
160
+ if (!lat) {
161
+ lat = { hook_latency_ms: [], state_change_detection_ms: [], permission_response_ms: [], channel_delivery_ms: [] };
162
+ this.latency.set(sessionId, lat);
163
+ }
164
+ return lat;
165
+ }
166
+ pushSample(arr, value) {
167
+ arr.push(value);
168
+ if (arr.length > MetricsCollector.MAX_LATENCY_SAMPLES) {
169
+ arr.shift();
170
+ }
171
+ }
172
+ recordHookLatency(sessionId, latencyMs) {
173
+ this.pushSample(this.getOrCreateLatency(sessionId).hook_latency_ms, latencyMs);
174
+ }
175
+ recordStateChangeDetection(sessionId, latencyMs) {
176
+ this.pushSample(this.getOrCreateLatency(sessionId).state_change_detection_ms, latencyMs);
177
+ }
178
+ recordPermissionResponse(sessionId, latencyMs) {
179
+ this.pushSample(this.getOrCreateLatency(sessionId).permission_response_ms, latencyMs);
180
+ }
181
+ recordChannelDelivery(sessionId, latencyMs) {
182
+ this.pushSample(this.getOrCreateLatency(sessionId).channel_delivery_ms, latencyMs);
183
+ }
184
+ summarizeSamples(samples) {
185
+ if (samples.length === 0) {
186
+ return { min: null, max: null, avg: null, count: 0 };
187
+ }
188
+ let min = samples[0];
189
+ let max = samples[0];
190
+ let sum = 0;
191
+ for (const s of samples) {
192
+ if (s < min)
193
+ min = s;
194
+ if (s > max)
195
+ max = s;
196
+ sum += s;
197
+ }
198
+ return { min, max, avg: Math.round(sum / samples.length), count: samples.length };
199
+ }
200
+ /** Stream-aggregate a single latency field across all sessions without creating temp arrays. */
201
+ aggregateLatencyField(field) {
202
+ let min;
203
+ let max;
204
+ let sum = 0;
205
+ let count = 0;
206
+ for (const lat of this.latency.values()) {
207
+ const samples = lat[field];
208
+ for (const s of samples) {
209
+ if (min === undefined || s < min)
210
+ min = s;
211
+ if (max === undefined || s > max)
212
+ max = s;
213
+ sum += s;
214
+ count++;
215
+ }
216
+ }
217
+ if (count === 0)
218
+ return { min: null, max: null, avg: null, count: 0 };
219
+ return { min: min, max: max, avg: Math.round(sum / count), count };
220
+ }
221
+ getSessionLatency(sessionId) {
222
+ const lat = this.latency.get(sessionId);
223
+ if (!lat)
224
+ return null;
225
+ return {
226
+ hook_latency_ms: this.summarizeSamples(lat.hook_latency_ms),
227
+ state_change_detection_ms: this.summarizeSamples(lat.state_change_detection_ms),
228
+ permission_response_ms: this.summarizeSamples(lat.permission_response_ms),
229
+ channel_delivery_ms: this.summarizeSamples(lat.channel_delivery_ms),
230
+ };
231
+ }
232
+ /** Clean up latency data for a session (called on session kill). */
233
+ clearSessionLatency(sessionId) {
234
+ this.latency.delete(sessionId);
235
+ }
236
+ /** #357: Clean up all per-session data (call on session destroy). */
237
+ cleanupSession(sessionId) {
238
+ this.perSession.delete(sessionId);
239
+ this.latency.delete(sessionId);
240
+ }
241
+ getGlobalMetrics(activeSessionCount) {
242
+ const avgMessages = this.global.sessionsCreated > 0
243
+ ? Math.round(this.global.totalMessages / this.global.sessionsCreated) : 0;
244
+ // Issue #87: Stream-aggregate latency across all sessions (no temp arrays)
245
+ const aggHook = this.aggregateLatencyField('hook_latency_ms');
246
+ const aggStateChange = this.aggregateLatencyField('state_change_detection_ms');
247
+ const aggPermission = this.aggregateLatencyField('permission_response_ms');
248
+ const aggChannel = this.aggregateLatencyField('channel_delivery_ms');
249
+ return {
250
+ uptime: Math.round((Date.now() - this.startTime) / 1000),
251
+ sessions: {
252
+ total_created: this.global.sessionsCreated,
253
+ currently_active: activeSessionCount,
254
+ completed: this.global.sessionsCompleted,
255
+ failed: this.global.sessionsFailed,
256
+ avg_duration_sec: 0,
257
+ avg_messages_per_session: avgMessages,
258
+ },
259
+ auto_approvals: this.global.autoApprovals,
260
+ webhooks_sent: this.global.webhooksSent,
261
+ webhooks_failed: this.global.webhooksFailed,
262
+ screenshots_taken: this.global.screenshotsTaken,
263
+ pipelines_created: this.global.pipelinesCreated,
264
+ batches_created: this.global.batchesCreated,
265
+ prompt_delivery: {
266
+ sent: this.global.promptsSent,
267
+ delivered: this.global.promptsDelivered,
268
+ failed: this.global.promptsFailed,
269
+ success_rate: this.global.promptsSent > 0
270
+ ? Math.round((this.global.promptsDelivered / this.global.promptsSent) * 100) : null,
271
+ },
272
+ latency: {
273
+ hook_latency_ms: aggHook,
274
+ state_change_detection_ms: aggStateChange,
275
+ permission_response_ms: aggPermission,
276
+ channel_delivery_ms: aggChannel,
277
+ },
278
+ };
279
+ }
280
+ getSessionMetrics(sessionId) {
281
+ return this.perSession.get(sessionId) || null;
282
+ }
283
+ getTotalSessionsCreated() {
284
+ return this.global.sessionsCreated;
285
+ }
286
+ }