heyhank 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.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// In-memory runtime metrics collector for the HeyHank server.
|
|
2
|
+
// Subscribes to the event bus and provides direct instrumentation methods.
|
|
3
|
+
// All data is in-memory — resets on server restart.
|
|
4
|
+
|
|
5
|
+
import { heyHankBus } from "./event-bus.js";
|
|
6
|
+
import type { SessionPhase } from "./session-state-machine.js";
|
|
7
|
+
import type {
|
|
8
|
+
MetricsSnapshot,
|
|
9
|
+
HistogramSnapshot,
|
|
10
|
+
CounterMetrics,
|
|
11
|
+
GaugeMetrics,
|
|
12
|
+
} from "./metrics-types.js";
|
|
13
|
+
|
|
14
|
+
// ── Histogram bucket boundaries (ms) ──────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const TIMING_BUCKETS_MS = [50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000, 60_000] as const;
|
|
17
|
+
|
|
18
|
+
// ── Internal histogram data structure ─────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface Histogram {
|
|
21
|
+
count: number;
|
|
22
|
+
sum: number;
|
|
23
|
+
min: number;
|
|
24
|
+
max: number;
|
|
25
|
+
/** Frequency bucket counts. buckets[i] = count of values in (TIMING_BUCKETS_MS[i-1], TIMING_BUCKETS_MS[i]]. */
|
|
26
|
+
buckets: number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createHistogram(): Histogram {
|
|
30
|
+
return {
|
|
31
|
+
count: 0,
|
|
32
|
+
sum: 0,
|
|
33
|
+
min: Infinity,
|
|
34
|
+
max: -Infinity,
|
|
35
|
+
buckets: new Array(TIMING_BUCKETS_MS.length + 1).fill(0), // +1 for +Infinity bucket
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function recordHistogramValue(h: Histogram, value: number): void {
|
|
40
|
+
h.count++;
|
|
41
|
+
h.sum += value;
|
|
42
|
+
if (value < h.min) h.min = value;
|
|
43
|
+
if (value > h.max) h.max = value;
|
|
44
|
+
|
|
45
|
+
// Find the appropriate bucket
|
|
46
|
+
for (let i = 0; i < TIMING_BUCKETS_MS.length; i++) {
|
|
47
|
+
if (value <= TIMING_BUCKETS_MS[i]) {
|
|
48
|
+
h.buckets[i]++;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Falls into the +Infinity bucket
|
|
53
|
+
h.buckets[TIMING_BUCKETS_MS.length]++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function serializeHistogram(h: Histogram): HistogramSnapshot {
|
|
57
|
+
const buckets: Record<string, number> = {};
|
|
58
|
+
for (let i = 0; i < TIMING_BUCKETS_MS.length; i++) {
|
|
59
|
+
buckets[String(TIMING_BUCKETS_MS[i])] = h.buckets[i];
|
|
60
|
+
}
|
|
61
|
+
buckets["Infinity"] = h.buckets[TIMING_BUCKETS_MS.length];
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
count: h.count,
|
|
65
|
+
sum: h.sum,
|
|
66
|
+
min: h.count > 0 ? h.min : 0,
|
|
67
|
+
max: h.count > 0 ? h.max : 0,
|
|
68
|
+
avg: h.count > 0 ? Math.round(h.sum / h.count) : 0,
|
|
69
|
+
p50Bucket: computePercentileBucket(h, 0.5),
|
|
70
|
+
p95Bucket: computePercentileBucket(h, 0.95),
|
|
71
|
+
p99Bucket: computePercentileBucket(h, 0.99),
|
|
72
|
+
buckets,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Approximate the bucket boundary for a given percentile. */
|
|
77
|
+
function computePercentileBucket(h: Histogram, p: number): number {
|
|
78
|
+
if (h.count === 0) return 0;
|
|
79
|
+
const target = Math.ceil(h.count * p);
|
|
80
|
+
let cumulative = 0;
|
|
81
|
+
for (let i = 0; i < TIMING_BUCKETS_MS.length; i++) {
|
|
82
|
+
cumulative += h.buckets[i];
|
|
83
|
+
if (cumulative >= target) return TIMING_BUCKETS_MS[i];
|
|
84
|
+
}
|
|
85
|
+
return Infinity;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Gauge data provider interface ─────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Minimal interface for computing gauges at snapshot time. */
|
|
91
|
+
export interface GaugeDataProvider {
|
|
92
|
+
getSessionMemoryStats(): { id: string; browsers: number; historyLen: number; eventBufferLen: number; pendingMsgs: number }[];
|
|
93
|
+
getSessionPhases(): Map<string, SessionPhase>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── MetricsCollector ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export class MetricsCollector {
|
|
99
|
+
private startedAt: number;
|
|
100
|
+
|
|
101
|
+
// Counters
|
|
102
|
+
private sessionsCreated = new Map<string, number>();
|
|
103
|
+
private sessionsTerminated = new Map<string, number>();
|
|
104
|
+
private autoRelaunches = { attempted: 0, succeeded: 0, exhausted: 0 };
|
|
105
|
+
private messagesProcessed = new Map<string, number>();
|
|
106
|
+
private permissions = { total: 0, autoApproved: 0, autoDenied: 0, userApproved: 0, userDenied: 0 };
|
|
107
|
+
private errors = new Map<string, number>();
|
|
108
|
+
private stateTransitions = new Map<string, number>();
|
|
109
|
+
private wsConnections = { cliOpened: 0, cliClosed: 0, browserOpened: 0, browserClosed: 0 };
|
|
110
|
+
|
|
111
|
+
// Histograms
|
|
112
|
+
private sessionInitTime = createHistogram();
|
|
113
|
+
private turnDuration = createHistogram();
|
|
114
|
+
private permissionDuration = createHistogram();
|
|
115
|
+
|
|
116
|
+
// Ephemeral timing state
|
|
117
|
+
private sessionSpawnedAt = new Map<string, number>();
|
|
118
|
+
private turnStartedAt = new Map<string, number>();
|
|
119
|
+
private permissionRequestedAt = new Map<string, number>();
|
|
120
|
+
/** Maps requestId → sessionId so permission timers can be cleaned up on session exit. */
|
|
121
|
+
private permissionRequestToSession = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
// Event bus unsubscribers (for cleanup in tests)
|
|
124
|
+
private unsubscribers: (() => void)[] = [];
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
this.startedAt = Date.now();
|
|
128
|
+
this.wireEventBus();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Event bus wiring ──────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
private wireEventBus(): void {
|
|
134
|
+
this.unsubscribers.push(
|
|
135
|
+
heyHankBus.on("session:phase-changed", ({ sessionId, from, to }) => {
|
|
136
|
+
// Count state transitions
|
|
137
|
+
const key = `${from}→${to}`;
|
|
138
|
+
this.stateTransitions.set(key, (this.stateTransitions.get(key) ?? 0) + 1);
|
|
139
|
+
|
|
140
|
+
// Compute session init time: initializing → ready
|
|
141
|
+
if (to === "ready" && (from === "initializing" || from === "starting")) {
|
|
142
|
+
const spawned = this.sessionSpawnedAt.get(sessionId);
|
|
143
|
+
if (spawned != null) {
|
|
144
|
+
recordHistogramValue(this.sessionInitTime, Date.now() - spawned);
|
|
145
|
+
this.sessionSpawnedAt.delete(sessionId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
heyHankBus.on("session:exited", ({ sessionId, exitCode }) => {
|
|
151
|
+
const key = String(exitCode ?? "null");
|
|
152
|
+
this.sessionsTerminated.set(key, (this.sessionsTerminated.get(key) ?? 0) + 1);
|
|
153
|
+
|
|
154
|
+
// Clean up ephemeral timing state
|
|
155
|
+
this.sessionSpawnedAt.delete(sessionId);
|
|
156
|
+
this.turnStartedAt.delete(sessionId);
|
|
157
|
+
|
|
158
|
+
// Evict orphaned permission timers for this session
|
|
159
|
+
for (const [reqId, sid] of this.permissionRequestToSession) {
|
|
160
|
+
if (sid === sessionId) {
|
|
161
|
+
this.permissionRequestedAt.delete(reqId);
|
|
162
|
+
this.permissionRequestToSession.delete(reqId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
|
|
167
|
+
heyHankBus.on("message:result", ({ sessionId }) => {
|
|
168
|
+
const started = this.turnStartedAt.get(sessionId);
|
|
169
|
+
if (started != null) {
|
|
170
|
+
recordHistogramValue(this.turnDuration, Date.now() - started);
|
|
171
|
+
this.turnStartedAt.delete(sessionId);
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Direct instrumentation methods ────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
recordSessionCreated(backendType: string): void {
|
|
180
|
+
this.sessionsCreated.set(backendType, (this.sessionsCreated.get(backendType) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
recordSessionSpawned(sessionId: string): void {
|
|
184
|
+
this.sessionSpawnedAt.set(sessionId, Date.now());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
recordRelaunchAttempted(): void {
|
|
188
|
+
this.autoRelaunches.attempted++;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
recordRelaunchSucceeded(): void {
|
|
192
|
+
this.autoRelaunches.succeeded++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
recordRelaunchExhausted(): void {
|
|
196
|
+
this.autoRelaunches.exhausted++;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
recordTurnStarted(sessionId: string): void {
|
|
200
|
+
this.turnStartedAt.set(sessionId, Date.now());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
recordPermissionRequested(requestId: string, sessionId?: string): void {
|
|
204
|
+
this.permissions.total++;
|
|
205
|
+
this.permissionRequestedAt.set(requestId, Date.now());
|
|
206
|
+
if (sessionId) {
|
|
207
|
+
this.permissionRequestToSession.set(requestId, sessionId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
recordPermissionResolved(requestId: string, behavior: "allow" | "deny", isAutomatic: boolean): void {
|
|
212
|
+
if (isAutomatic) {
|
|
213
|
+
if (behavior === "allow") this.permissions.autoApproved++;
|
|
214
|
+
else this.permissions.autoDenied++;
|
|
215
|
+
} else {
|
|
216
|
+
if (behavior === "allow") this.permissions.userApproved++;
|
|
217
|
+
else this.permissions.userDenied++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const requested = this.permissionRequestedAt.get(requestId);
|
|
221
|
+
if (requested != null) {
|
|
222
|
+
recordHistogramValue(this.permissionDuration, Date.now() - requested);
|
|
223
|
+
this.permissionRequestedAt.delete(requestId);
|
|
224
|
+
this.permissionRequestToSession.delete(requestId);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
recordWsConnection(kind: "cli" | "browser", event: "open" | "close"): void {
|
|
229
|
+
if (kind === "cli") {
|
|
230
|
+
if (event === "open") this.wsConnections.cliOpened++;
|
|
231
|
+
else this.wsConnections.cliClosed++;
|
|
232
|
+
} else {
|
|
233
|
+
if (event === "open") this.wsConnections.browserOpened++;
|
|
234
|
+
else this.wsConnections.browserClosed++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
recordMessageProcessed(messageType: string): void {
|
|
239
|
+
this.messagesProcessed.set(messageType, (this.messagesProcessed.get(messageType) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
recordError(category: string): void {
|
|
243
|
+
this.errors.set(category, (this.errors.get(category) ?? 0) + 1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Snapshot ──────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
getSnapshot(gaugeProvider?: GaugeDataProvider): MetricsSnapshot {
|
|
249
|
+
const counters = this.buildCounters();
|
|
250
|
+
const gauges = this.buildGauges(gaugeProvider);
|
|
251
|
+
const histograms = {
|
|
252
|
+
sessionInitTimeMs: serializeHistogram(this.sessionInitTime),
|
|
253
|
+
turnDurationMs: serializeHistogram(this.turnDuration),
|
|
254
|
+
permissionDurationMs: serializeHistogram(this.permissionDuration),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
serverUptimeMs: Date.now() - this.startedAt,
|
|
259
|
+
snapshotAt: Date.now(),
|
|
260
|
+
counters,
|
|
261
|
+
gauges,
|
|
262
|
+
histograms,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private buildCounters(): CounterMetrics {
|
|
267
|
+
return {
|
|
268
|
+
sessionsCreated: Object.fromEntries(this.sessionsCreated),
|
|
269
|
+
sessionsTerminated: Object.fromEntries(this.sessionsTerminated),
|
|
270
|
+
autoRelaunches: { ...this.autoRelaunches },
|
|
271
|
+
messagesProcessed: Object.fromEntries(this.messagesProcessed),
|
|
272
|
+
permissionRequests: { ...this.permissions },
|
|
273
|
+
errors: Object.fromEntries(this.errors),
|
|
274
|
+
stateTransitions: Object.fromEntries(this.stateTransitions),
|
|
275
|
+
wsConnections: { ...this.wsConnections },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private buildGauges(provider?: GaugeDataProvider): GaugeMetrics {
|
|
280
|
+
const activeSessions: Partial<Record<SessionPhase, number>> = {};
|
|
281
|
+
let totalActive = 0;
|
|
282
|
+
let connectedBrowsers = 0;
|
|
283
|
+
let totalPending = 0;
|
|
284
|
+
let totalEventBuffer = 0;
|
|
285
|
+
let totalHistory = 0;
|
|
286
|
+
|
|
287
|
+
if (provider) {
|
|
288
|
+
// Compute phase distribution
|
|
289
|
+
for (const [, phase] of provider.getSessionPhases()) {
|
|
290
|
+
activeSessions[phase] = (activeSessions[phase] ?? 0) + 1;
|
|
291
|
+
if (phase !== "terminated") totalActive++;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Compute memory stats
|
|
295
|
+
for (const stats of provider.getSessionMemoryStats()) {
|
|
296
|
+
connectedBrowsers += stats.browsers;
|
|
297
|
+
totalPending += stats.pendingMsgs;
|
|
298
|
+
totalEventBuffer += stats.eventBufferLen;
|
|
299
|
+
totalHistory += stats.historyLen;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const mem = process.memoryUsage();
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
activeSessions,
|
|
307
|
+
totalActiveSessions: totalActive,
|
|
308
|
+
connectedBrowsers,
|
|
309
|
+
totalPendingMessages: totalPending,
|
|
310
|
+
totalEventBufferSize: totalEventBuffer,
|
|
311
|
+
totalHistoryMessages: totalHistory,
|
|
312
|
+
memory: {
|
|
313
|
+
rss: mem.rss,
|
|
314
|
+
heapUsed: mem.heapUsed,
|
|
315
|
+
heapTotal: mem.heapTotal,
|
|
316
|
+
external: mem.external,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Reset (for testing) ───────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
reset(): void {
|
|
324
|
+
this.startedAt = Date.now();
|
|
325
|
+
this.sessionsCreated.clear();
|
|
326
|
+
this.sessionsTerminated.clear();
|
|
327
|
+
this.autoRelaunches = { attempted: 0, succeeded: 0, exhausted: 0 };
|
|
328
|
+
this.messagesProcessed.clear();
|
|
329
|
+
this.permissions = { total: 0, autoApproved: 0, autoDenied: 0, userApproved: 0, userDenied: 0 };
|
|
330
|
+
this.errors.clear();
|
|
331
|
+
this.stateTransitions.clear();
|
|
332
|
+
this.wsConnections = { cliOpened: 0, cliClosed: 0, browserOpened: 0, browserClosed: 0 };
|
|
333
|
+
this.sessionInitTime = createHistogram();
|
|
334
|
+
this.turnDuration = createHistogram();
|
|
335
|
+
this.permissionDuration = createHistogram();
|
|
336
|
+
this.sessionSpawnedAt.clear();
|
|
337
|
+
this.turnStartedAt.clear();
|
|
338
|
+
this.permissionRequestedAt.clear();
|
|
339
|
+
this.permissionRequestToSession.clear();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Unsubscribe from all event bus listeners. */
|
|
343
|
+
destroy(): void {
|
|
344
|
+
for (const unsub of this.unsubscribers) unsub();
|
|
345
|
+
this.unsubscribers = [];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Singleton instance used by the server. */
|
|
350
|
+
export const metricsCollector = new MetricsCollector();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Type definitions for the HeyHank runtime metrics system.
|
|
2
|
+
// Defines the shape of the JSON snapshot returned by GET /api/metrics.
|
|
3
|
+
|
|
4
|
+
import type { SessionPhase } from "./session-state-machine.js";
|
|
5
|
+
|
|
6
|
+
// ── Snapshot (top-level) ───────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface MetricsSnapshot {
|
|
9
|
+
/** Milliseconds since server started. */
|
|
10
|
+
serverUptimeMs: number;
|
|
11
|
+
/** Unix timestamp (ms) when this snapshot was taken. */
|
|
12
|
+
snapshotAt: number;
|
|
13
|
+
counters: CounterMetrics;
|
|
14
|
+
gauges: GaugeMetrics;
|
|
15
|
+
histograms: HistogramMetrics;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Counters (monotonically increasing) ────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface CounterMetrics {
|
|
21
|
+
/** Sessions created, keyed by backend type ("claude" | "codex"). */
|
|
22
|
+
sessionsCreated: Record<string, number>;
|
|
23
|
+
/** Sessions terminated, keyed by exit code (stringified). */
|
|
24
|
+
sessionsTerminated: Record<string, number>;
|
|
25
|
+
/** Auto-relaunch tracking. */
|
|
26
|
+
autoRelaunches: {
|
|
27
|
+
attempted: number;
|
|
28
|
+
succeeded: number;
|
|
29
|
+
exhausted: number;
|
|
30
|
+
};
|
|
31
|
+
/** Messages processed by the bridge, keyed by message type. */
|
|
32
|
+
messagesProcessed: Record<string, number>;
|
|
33
|
+
/** Permission request flow tracking. */
|
|
34
|
+
permissionRequests: {
|
|
35
|
+
total: number;
|
|
36
|
+
autoApproved: number;
|
|
37
|
+
autoDenied: number;
|
|
38
|
+
userApproved: number;
|
|
39
|
+
userDenied: number;
|
|
40
|
+
};
|
|
41
|
+
/** Errors by category (e.g. "invalid_state_transition", "parse_error"). */
|
|
42
|
+
errors: Record<string, number>;
|
|
43
|
+
/** State machine transitions, keyed by "from→to". */
|
|
44
|
+
stateTransitions: Record<string, number>;
|
|
45
|
+
/** WebSocket connection events. */
|
|
46
|
+
wsConnections: {
|
|
47
|
+
cliOpened: number;
|
|
48
|
+
cliClosed: number;
|
|
49
|
+
browserOpened: number;
|
|
50
|
+
browserClosed: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Gauges (point-in-time values) ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface GaugeMetrics {
|
|
57
|
+
/** Active sessions grouped by phase. */
|
|
58
|
+
activeSessions: Partial<Record<SessionPhase, number>>;
|
|
59
|
+
/** Total non-terminated sessions. */
|
|
60
|
+
totalActiveSessions: number;
|
|
61
|
+
/** Total connected browser WebSockets across all sessions. */
|
|
62
|
+
connectedBrowsers: number;
|
|
63
|
+
/** Total pending messages queued across all sessions. */
|
|
64
|
+
totalPendingMessages: number;
|
|
65
|
+
/** Total event buffer entries across all sessions. */
|
|
66
|
+
totalEventBufferSize: number;
|
|
67
|
+
/** Total message history entries across all sessions. */
|
|
68
|
+
totalHistoryMessages: number;
|
|
69
|
+
/** Process memory usage in bytes. */
|
|
70
|
+
memory: {
|
|
71
|
+
rss: number;
|
|
72
|
+
heapUsed: number;
|
|
73
|
+
heapTotal: number;
|
|
74
|
+
external: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Histograms (distributions) ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export interface HistogramSnapshot {
|
|
81
|
+
/** Number of observations. */
|
|
82
|
+
count: number;
|
|
83
|
+
/** Sum of all observed values. */
|
|
84
|
+
sum: number;
|
|
85
|
+
/** Minimum observed value (0 if no observations). */
|
|
86
|
+
min: number;
|
|
87
|
+
/** Maximum observed value (0 if no observations). */
|
|
88
|
+
max: number;
|
|
89
|
+
/** Average (0 if no observations). */
|
|
90
|
+
avg: number;
|
|
91
|
+
/** Approximate bucket boundary for the 50th percentile. */
|
|
92
|
+
p50Bucket: number;
|
|
93
|
+
/** Approximate bucket boundary for the 95th percentile. */
|
|
94
|
+
p95Bucket: number;
|
|
95
|
+
/** Approximate bucket boundary for the 99th percentile. */
|
|
96
|
+
p99Bucket: number;
|
|
97
|
+
/** Cumulative counts per bucket boundary (stringified keys). */
|
|
98
|
+
buckets: Record<string, number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface HistogramMetrics {
|
|
102
|
+
/** Session initialization time: spawn → ready (ms). */
|
|
103
|
+
sessionInitTimeMs: HistogramSnapshot;
|
|
104
|
+
/** Turn duration: user message → result (ms). */
|
|
105
|
+
turnDurationMs: HistogramSnapshot;
|
|
106
|
+
/** Permission request duration: request → response (ms). */
|
|
107
|
+
permissionDurationMs: HistogramSnapshot;
|
|
108
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auth middleware for managed HeyHank Cloud instances.
|
|
6
|
+
*
|
|
7
|
+
* Only active when HEYHANK_AUTH_ENABLED=1. Validates a JWT from a cookie
|
|
8
|
+
* or query parameter, signed by the control plane using HEYHANK_AUTH_SECRET.
|
|
9
|
+
*
|
|
10
|
+
* Skipped paths:
|
|
11
|
+
* - /ws/cli/* — internal CLI WebSocket (Claude Code connects from within the machine)
|
|
12
|
+
* - /health — monitoring endpoint used by control plane health checks
|
|
13
|
+
*/
|
|
14
|
+
export const managedAuth = createMiddleware(async (c: Context, next) => {
|
|
15
|
+
// This middleware is only registered by index.ts when managed auth is
|
|
16
|
+
// enabled (HEYHANK_AUTH_ENABLED=1 or HEYHANK_AUTH_SECRET is set).
|
|
17
|
+
// No redundant env check needed here.
|
|
18
|
+
|
|
19
|
+
const path = c.req.path;
|
|
20
|
+
|
|
21
|
+
// Internal paths that bypass auth
|
|
22
|
+
if (path.startsWith("/ws/cli/") || path === "/health") return next();
|
|
23
|
+
|
|
24
|
+
const cookieToken = getCookie(c, "heyhank_token") || getCookie(c, "companion_token");
|
|
25
|
+
const queryToken = c.req.query("token");
|
|
26
|
+
// Give explicit URL token precedence so reconnect links can always override
|
|
27
|
+
// stale/expired cookies in the browser.
|
|
28
|
+
const token = queryToken || cookieToken;
|
|
29
|
+
|
|
30
|
+
if (!token) {
|
|
31
|
+
return redirectOrUnauthorized(c);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const secret = process.env.HEYHANK_AUTH_SECRET || process.env.COMPANION_AUTH_SECRET;
|
|
35
|
+
if (!secret) {
|
|
36
|
+
console.error("[managed-auth] HEYHANK_AUTH_SECRET is not set");
|
|
37
|
+
return c.json({ error: "Server misconfigured" }, 500);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const valid = await verifyToken(token, secret);
|
|
41
|
+
if (!valid) {
|
|
42
|
+
return redirectOrUnauthorized(c);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// When auth arrives via URL query once, persist it to a cookie so static
|
|
46
|
+
// assets and subsequent API calls are authenticated without ?token=...
|
|
47
|
+
if (queryToken && queryToken !== cookieToken) {
|
|
48
|
+
setAuthCookie(c, queryToken);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return next();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function getCookie(c: Context, name: string): string | undefined {
|
|
57
|
+
const header = c.req.header("cookie");
|
|
58
|
+
if (!header) return undefined;
|
|
59
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
60
|
+
return match?.[1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setAuthCookie(c: Context, token: string): void {
|
|
64
|
+
const encoded = encodeURIComponent(token);
|
|
65
|
+
const secure = shouldUseSecureCookie(c) ? "; Secure" : "";
|
|
66
|
+
c.header(
|
|
67
|
+
"Set-Cookie",
|
|
68
|
+
`heyhank_token=${encoded}; Path=/; HttpOnly${secure}; SameSite=Lax; Max-Age=900`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shouldUseSecureCookie(c: Context): boolean {
|
|
73
|
+
const forwardedProto = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
|
|
74
|
+
if (forwardedProto) return forwardedProto === "https";
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return new URL(c.req.url).protocol === "https:";
|
|
78
|
+
} catch {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function redirectOrUnauthorized(c: Context): Response {
|
|
84
|
+
const loginUrl = process.env.HEYHANK_LOGIN_URL || process.env.COMPANION_LOGIN_URL;
|
|
85
|
+
if (loginUrl) {
|
|
86
|
+
return c.redirect(loginUrl);
|
|
87
|
+
}
|
|
88
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Verify a JWT-like HMAC-SHA256 token.
|
|
93
|
+
* Token format: base64url(payload).base64url(signature)
|
|
94
|
+
* Payload: { exp: number } (Unix seconds)
|
|
95
|
+
*/
|
|
96
|
+
export async function verifyToken(
|
|
97
|
+
token: string,
|
|
98
|
+
secret: string,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const parts = token.split(".");
|
|
101
|
+
if (parts.length !== 2) return false;
|
|
102
|
+
|
|
103
|
+
const [payloadB64, signatureB64] = parts;
|
|
104
|
+
|
|
105
|
+
// Verify signature using HMAC-SHA256
|
|
106
|
+
const encoder = new TextEncoder();
|
|
107
|
+
const key = await crypto.subtle.importKey(
|
|
108
|
+
"raw",
|
|
109
|
+
encoder.encode(secret),
|
|
110
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
111
|
+
false,
|
|
112
|
+
["sign"],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const expectedSig = await crypto.subtle.sign(
|
|
116
|
+
"HMAC",
|
|
117
|
+
key,
|
|
118
|
+
encoder.encode(payloadB64),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const expectedB64 = base64UrlEncode(new Uint8Array(expectedSig));
|
|
122
|
+
if (!timingSafeEqual(expectedB64, signatureB64)) return false;
|
|
123
|
+
|
|
124
|
+
// Check expiration
|
|
125
|
+
try {
|
|
126
|
+
const payload = JSON.parse(
|
|
127
|
+
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
|
128
|
+
);
|
|
129
|
+
if (typeof payload.exp === "number" && payload.exp < Date.now() / 1000) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a signed token for the control plane to issue.
|
|
141
|
+
* Exported for use by the control plane's token endpoint.
|
|
142
|
+
*/
|
|
143
|
+
export async function createToken(
|
|
144
|
+
secret: string,
|
|
145
|
+
ttlSeconds = 900, // 15 minutes
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const payload = { exp: Math.floor(Date.now() / 1000) + ttlSeconds };
|
|
148
|
+
const payloadB64 = base64UrlEncode(
|
|
149
|
+
new TextEncoder().encode(JSON.stringify(payload)),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const key = await crypto.subtle.importKey(
|
|
153
|
+
"raw",
|
|
154
|
+
new TextEncoder().encode(secret),
|
|
155
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
156
|
+
false,
|
|
157
|
+
["sign"],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const sig = await crypto.subtle.sign(
|
|
161
|
+
"HMAC",
|
|
162
|
+
key,
|
|
163
|
+
new TextEncoder().encode(payloadB64),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return `${payloadB64}.${base64UrlEncode(new Uint8Array(sig))}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Base64url ───────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
function base64UrlEncode(data: Uint8Array): string {
|
|
172
|
+
let binary = "";
|
|
173
|
+
for (const byte of data) binary += String.fromCharCode(byte);
|
|
174
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function base64UrlDecode(str: string): Uint8Array {
|
|
178
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
179
|
+
const binary = atob(padded);
|
|
180
|
+
const bytes = new Uint8Array(binary.length);
|
|
181
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
182
|
+
return bytes;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
187
|
+
*/
|
|
188
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
189
|
+
if (a.length !== b.length) return false;
|
|
190
|
+
let result = 0;
|
|
191
|
+
for (let i = 0; i < a.length; i++) {
|
|
192
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
193
|
+
}
|
|
194
|
+
return result === 0;
|
|
195
|
+
}
|