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,374 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, appendFileSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { BackendType } from "./session-types.js";
|
|
5
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
6
|
+
import { countFileLines } from "./fs-utils.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_LINES = 1_000_000;
|
|
9
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface RecordingHeader {
|
|
14
|
+
_header: true;
|
|
15
|
+
version: 1;
|
|
16
|
+
session_id: string;
|
|
17
|
+
backend_type: BackendType;
|
|
18
|
+
started_at: number;
|
|
19
|
+
cwd: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RecordingDirection = "in" | "out";
|
|
23
|
+
export type RecordingChannel = "cli" | "browser";
|
|
24
|
+
|
|
25
|
+
export type RecordingLifecycleEvent =
|
|
26
|
+
| "ws_open"
|
|
27
|
+
| "ws_close"
|
|
28
|
+
| "ws_error"
|
|
29
|
+
| "reconnect_attempt"
|
|
30
|
+
| "reconnect_success";
|
|
31
|
+
|
|
32
|
+
export interface RecordingEntry {
|
|
33
|
+
ts: number;
|
|
34
|
+
dir: RecordingDirection;
|
|
35
|
+
raw: string;
|
|
36
|
+
ch: RecordingChannel;
|
|
37
|
+
/** Optional connection lifecycle event (for disconnection diagnostics). */
|
|
38
|
+
event?: RecordingLifecycleEvent;
|
|
39
|
+
/** Optional metadata for lifecycle events (e.g. close code, error message). */
|
|
40
|
+
meta?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RecordingFileMeta {
|
|
44
|
+
filename: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
backendType: string;
|
|
47
|
+
startedAt: string;
|
|
48
|
+
/** Number of lines in the file (header + entries). */
|
|
49
|
+
lines: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── SessionRecorder ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Writes raw messages for a single session to a JSONL file.
|
|
56
|
+
* First line is a header with session metadata; subsequent lines are entries.
|
|
57
|
+
* Tracks its own line count so the manager can enforce the global limit.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionRecorder {
|
|
60
|
+
readonly filePath: string;
|
|
61
|
+
private closed = false;
|
|
62
|
+
private _recordWriteErrorLogged = false;
|
|
63
|
+
/** Number of lines written (1 for the header at construction). */
|
|
64
|
+
lineCount = 1;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
sessionId: string,
|
|
68
|
+
backendType: BackendType,
|
|
69
|
+
cwd: string,
|
|
70
|
+
outputDir: string,
|
|
71
|
+
) {
|
|
72
|
+
const ts = new Date().toISOString().replace(/:/g, "-");
|
|
73
|
+
const suffix = randomBytes(3).toString("hex");
|
|
74
|
+
const filename = `${sessionId}_${backendType}_${ts}_${suffix}.jsonl`;
|
|
75
|
+
this.filePath = join(outputDir, filename);
|
|
76
|
+
|
|
77
|
+
const header: RecordingHeader = {
|
|
78
|
+
_header: true,
|
|
79
|
+
version: 1,
|
|
80
|
+
session_id: sessionId,
|
|
81
|
+
backend_type: backendType,
|
|
82
|
+
started_at: Date.now(),
|
|
83
|
+
cwd,
|
|
84
|
+
};
|
|
85
|
+
appendFileSync(this.filePath, JSON.stringify(header) + "\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
record(dir: RecordingDirection, raw: string, channel: RecordingChannel): void {
|
|
89
|
+
if (this.closed) return;
|
|
90
|
+
const entry: RecordingEntry = {
|
|
91
|
+
ts: Date.now(),
|
|
92
|
+
dir,
|
|
93
|
+
raw,
|
|
94
|
+
ch: channel,
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
98
|
+
this.lineCount++;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Never throw — recording must not disrupt normal operation.
|
|
101
|
+
// But log once so operators can diagnose disk/permission issues.
|
|
102
|
+
if (!this._recordWriteErrorLogged) {
|
|
103
|
+
this._recordWriteErrorLogged = true;
|
|
104
|
+
console.warn(`[recorder] Write failed for ${this.filePath}: ${err instanceof Error ? err.message : err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Record a connection lifecycle event (open, close, error, reconnect). */
|
|
110
|
+
recordEvent(
|
|
111
|
+
event: RecordingLifecycleEvent,
|
|
112
|
+
channel: RecordingChannel,
|
|
113
|
+
meta?: Record<string, unknown>,
|
|
114
|
+
): void {
|
|
115
|
+
if (this.closed) return;
|
|
116
|
+
const entry: RecordingEntry = {
|
|
117
|
+
ts: Date.now(),
|
|
118
|
+
dir: "in",
|
|
119
|
+
raw: "",
|
|
120
|
+
ch: channel,
|
|
121
|
+
event,
|
|
122
|
+
...(meta ? { meta } : {}),
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
126
|
+
this.lineCount++;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (!this._recordWriteErrorLogged) {
|
|
129
|
+
this._recordWriteErrorLogged = true;
|
|
130
|
+
console.warn(`[recorder] Write failed for ${this.filePath}: ${err instanceof Error ? err.message : err}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
close(): void {
|
|
136
|
+
this.closed = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── RecorderManager ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Manages recording for all sessions.
|
|
144
|
+
*
|
|
145
|
+
* Always enabled by default. Disable explicitly with HEYHANK_RECORD=0.
|
|
146
|
+
*
|
|
147
|
+
* Automatic rotation: when total lines across all recording files exceed
|
|
148
|
+
* maxLines (default 1 000 000, override with HEYHANK_RECORDINGS_MAX_LINES),
|
|
149
|
+
* the oldest files are deleted until we're back under the limit.
|
|
150
|
+
*/
|
|
151
|
+
export class RecorderManager {
|
|
152
|
+
private globalEnabled: boolean;
|
|
153
|
+
private recordingsDir: string;
|
|
154
|
+
private maxLines: number;
|
|
155
|
+
private perSessionEnabled = new Set<string>();
|
|
156
|
+
private perSessionDisabled = new Set<string>();
|
|
157
|
+
private recorders = new Map<string, SessionRecorder>();
|
|
158
|
+
private dirCreated = false;
|
|
159
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
160
|
+
|
|
161
|
+
constructor(options?: {
|
|
162
|
+
globalEnabled?: boolean;
|
|
163
|
+
recordingsDir?: string;
|
|
164
|
+
maxLines?: number;
|
|
165
|
+
}) {
|
|
166
|
+
this.globalEnabled = options?.globalEnabled ?? RecorderManager.resolveEnabled();
|
|
167
|
+
this.recordingsDir =
|
|
168
|
+
options?.recordingsDir ??
|
|
169
|
+
(process.env.HEYHANK_RECORDINGS_DIR || process.env.COMPANION_RECORDINGS_DIR ||
|
|
170
|
+
join(HEYHANK_HOME, "recordings"));
|
|
171
|
+
this.maxLines =
|
|
172
|
+
options?.maxLines ??
|
|
173
|
+
(Number(process.env.HEYHANK_RECORDINGS_MAX_LINES || process.env.COMPANION_RECORDINGS_MAX_LINES) || DEFAULT_MAX_LINES);
|
|
174
|
+
|
|
175
|
+
if (this.globalEnabled) {
|
|
176
|
+
// Run cleanup at startup (async, non-blocking) and periodically
|
|
177
|
+
this.cleanup();
|
|
178
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
179
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Always on unless explicitly disabled with HEYHANK_RECORD=0|false.
|
|
185
|
+
*/
|
|
186
|
+
private static resolveEnabled(): boolean {
|
|
187
|
+
const env = process.env.HEYHANK_RECORD || process.env.COMPANION_RECORD;
|
|
188
|
+
if (env === "0" || env === "false") return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
isGloballyEnabled(): boolean {
|
|
193
|
+
return this.globalEnabled;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getRecordingsDir(): string {
|
|
197
|
+
return this.recordingsDir;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getMaxLines(): number {
|
|
201
|
+
return this.maxLines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
isRecording(sessionId: string): boolean {
|
|
205
|
+
if (this.perSessionDisabled.has(sessionId)) return false;
|
|
206
|
+
return this.globalEnabled || this.perSessionEnabled.has(sessionId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
enableForSession(sessionId: string): void {
|
|
210
|
+
this.perSessionDisabled.delete(sessionId);
|
|
211
|
+
this.perSessionEnabled.add(sessionId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
disableForSession(sessionId: string): void {
|
|
215
|
+
this.perSessionEnabled.delete(sessionId);
|
|
216
|
+
this.perSessionDisabled.add(sessionId);
|
|
217
|
+
this.stopRecording(sessionId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Record a raw message. No-op if recording is disabled for this session.
|
|
222
|
+
* Lazily creates the SessionRecorder on first call.
|
|
223
|
+
*/
|
|
224
|
+
record(
|
|
225
|
+
sessionId: string,
|
|
226
|
+
dir: RecordingDirection,
|
|
227
|
+
raw: string,
|
|
228
|
+
channel: RecordingChannel,
|
|
229
|
+
backendType: BackendType,
|
|
230
|
+
cwd: string,
|
|
231
|
+
): void {
|
|
232
|
+
if (!this.isRecording(sessionId)) return;
|
|
233
|
+
|
|
234
|
+
let recorder = this.recorders.get(sessionId);
|
|
235
|
+
if (!recorder) {
|
|
236
|
+
this.ensureDir();
|
|
237
|
+
recorder = new SessionRecorder(sessionId, backendType, cwd, this.recordingsDir);
|
|
238
|
+
this.recorders.set(sessionId, recorder);
|
|
239
|
+
}
|
|
240
|
+
recorder.record(dir, raw, channel);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Record a connection lifecycle event for diagnostics. */
|
|
244
|
+
recordEvent(
|
|
245
|
+
sessionId: string,
|
|
246
|
+
event: RecordingLifecycleEvent,
|
|
247
|
+
channel: RecordingChannel,
|
|
248
|
+
meta?: Record<string, unknown>,
|
|
249
|
+
): void {
|
|
250
|
+
const recorder = this.recorders.get(sessionId);
|
|
251
|
+
if (recorder) {
|
|
252
|
+
recorder.recordEvent(event, channel, meta);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
stopRecording(sessionId: string): void {
|
|
257
|
+
const recorder = this.recorders.get(sessionId);
|
|
258
|
+
if (recorder) {
|
|
259
|
+
recorder.close();
|
|
260
|
+
this.recorders.delete(sessionId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getRecordingStatus(sessionId: string): { filePath?: string } {
|
|
265
|
+
const recorder = this.recorders.get(sessionId);
|
|
266
|
+
return recorder ? { filePath: recorder.filePath } : {};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
listRecordings(): RecordingFileMeta[] {
|
|
270
|
+
try {
|
|
271
|
+
const files = readdirSync(this.recordingsDir);
|
|
272
|
+
return files
|
|
273
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
274
|
+
.map((filename) => {
|
|
275
|
+
// Format: {sessionId}_{backendType}_{ISO-timestamp}_{suffix}.jsonl
|
|
276
|
+
const withoutExt = filename.replace(/\.jsonl$/, "");
|
|
277
|
+
const firstUnderscore = withoutExt.indexOf("_");
|
|
278
|
+
const secondUnderscore = withoutExt.indexOf("_", firstUnderscore + 1);
|
|
279
|
+
if (firstUnderscore === -1 || secondUnderscore === -1) {
|
|
280
|
+
return { filename, sessionId: "", backendType: "", startedAt: "", lines: 0 };
|
|
281
|
+
}
|
|
282
|
+
// Count lines — fast: just count newlines
|
|
283
|
+
const lines = countFileLines(join(this.recordingsDir, filename));
|
|
284
|
+
return {
|
|
285
|
+
filename,
|
|
286
|
+
sessionId: withoutExt.substring(0, firstUnderscore),
|
|
287
|
+
backendType: withoutExt.substring(firstUnderscore + 1, secondUnderscore),
|
|
288
|
+
startedAt: withoutExt.substring(secondUnderscore + 1),
|
|
289
|
+
lines,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
} catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
closeAll(): void {
|
|
298
|
+
if (this.cleanupTimer) {
|
|
299
|
+
clearInterval(this.cleanupTimer);
|
|
300
|
+
this.cleanupTimer = null;
|
|
301
|
+
}
|
|
302
|
+
for (const [, recorder] of this.recorders) {
|
|
303
|
+
recorder.close();
|
|
304
|
+
}
|
|
305
|
+
this.recorders.clear();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete oldest recording files until total lines are under maxLines.
|
|
310
|
+
* Skips files that belong to active (currently recording) sessions.
|
|
311
|
+
*/
|
|
312
|
+
cleanup(): number {
|
|
313
|
+
try {
|
|
314
|
+
this.ensureDir();
|
|
315
|
+
const files = readdirSync(this.recordingsDir).filter((f) => f.endsWith(".jsonl"));
|
|
316
|
+
if (files.length === 0) return 0;
|
|
317
|
+
|
|
318
|
+
// Build list with line counts and mtime, sorted oldest-first
|
|
319
|
+
const activeFiles = new Set<string>();
|
|
320
|
+
for (const rec of this.recorders.values()) {
|
|
321
|
+
activeFiles.add(rec.filePath);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const entries: { filename: string; path: string; lines: number; mtimeMs: number }[] = [];
|
|
325
|
+
let totalLines = 0;
|
|
326
|
+
|
|
327
|
+
for (const filename of files) {
|
|
328
|
+
const fullPath = join(this.recordingsDir, filename);
|
|
329
|
+
const lines = countFileLines(fullPath);
|
|
330
|
+
let mtimeMs = 0;
|
|
331
|
+
try {
|
|
332
|
+
mtimeMs = statSync(fullPath).mtimeMs;
|
|
333
|
+
} catch {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
entries.push({ filename, path: fullPath, lines, mtimeMs });
|
|
337
|
+
totalLines += lines;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (totalLines <= this.maxLines) return 0;
|
|
341
|
+
|
|
342
|
+
// Sort oldest first (lowest mtime = oldest)
|
|
343
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
344
|
+
|
|
345
|
+
let deleted = 0;
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (totalLines <= this.maxLines) break;
|
|
348
|
+
// Don't delete files that are actively being written to
|
|
349
|
+
if (activeFiles.has(entry.path)) continue;
|
|
350
|
+
try {
|
|
351
|
+
unlinkSync(entry.path);
|
|
352
|
+
totalLines -= entry.lines;
|
|
353
|
+
deleted++;
|
|
354
|
+
} catch {
|
|
355
|
+
// File may have been removed concurrently
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (deleted > 0) {
|
|
360
|
+
console.log(`[recorder] Cleanup: deleted ${deleted} old recording(s), ${totalLines} lines remaining`);
|
|
361
|
+
}
|
|
362
|
+
return deleted;
|
|
363
|
+
} catch {
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private ensureDir(): void {
|
|
369
|
+
if (this.dirCreated) return;
|
|
370
|
+
mkdirSync(this.recordingsDir, { recursive: true });
|
|
371
|
+
this.dirCreated = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility validator for recorded sessions.
|
|
3
|
+
*
|
|
4
|
+
* Compares a recording's browser output messages structurally to detect
|
|
5
|
+
* protocol drift. This catches changes when Claude Code or Codex update
|
|
6
|
+
* their message format.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Recording } from "../replay.js";
|
|
10
|
+
import { filterEntries } from "../replay.js";
|
|
11
|
+
|
|
12
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface ProtocolDiff {
|
|
15
|
+
entryIndex: number;
|
|
16
|
+
expected: { type: string; [key: string]: unknown };
|
|
17
|
+
actual: { type: string; [key: string]: unknown } | null;
|
|
18
|
+
kind: "missing" | "extra" | "type_mismatch" | "field_mismatch";
|
|
19
|
+
details: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ValidationResult {
|
|
23
|
+
compatible: boolean;
|
|
24
|
+
backendType: string;
|
|
25
|
+
totalMessages: number;
|
|
26
|
+
diffs: ProtocolDiff[];
|
|
27
|
+
messageTypeBreakdown: Record<string, { count: number; issues: number }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fields to ignore during comparison (they change between runs)
|
|
31
|
+
const IGNORED_FIELDS = new Set([
|
|
32
|
+
"timestamp",
|
|
33
|
+
"ts",
|
|
34
|
+
"created_at",
|
|
35
|
+
"updated_at",
|
|
36
|
+
"session_id",
|
|
37
|
+
"uuid",
|
|
38
|
+
"id",
|
|
39
|
+
"request_id",
|
|
40
|
+
"duration_ms",
|
|
41
|
+
"duration_api_ms",
|
|
42
|
+
"cost_usd",
|
|
43
|
+
"total_cost_usd",
|
|
44
|
+
"api_tokens",
|
|
45
|
+
"input_tokens",
|
|
46
|
+
"output_tokens",
|
|
47
|
+
"cache_read_input_tokens",
|
|
48
|
+
"cache_creation_input_tokens",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// ─── Validation ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a recording's structural consistency.
|
|
55
|
+
*
|
|
56
|
+
* Checks that browser messages have expected types, required fields are present,
|
|
57
|
+
* and message type distribution is reasonable.
|
|
58
|
+
*/
|
|
59
|
+
export function validateRecording(recording: Recording): ValidationResult {
|
|
60
|
+
const browserMessages = filterEntries(recording.entries, "out", "browser");
|
|
61
|
+
const diffs: ProtocolDiff[] = [];
|
|
62
|
+
const typeBreakdown: Record<string, { count: number; issues: number }> = {};
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < browserMessages.length; i++) {
|
|
65
|
+
const entry = browserMessages[i];
|
|
66
|
+
let parsed: Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(entry.raw);
|
|
70
|
+
} catch {
|
|
71
|
+
diffs.push({
|
|
72
|
+
entryIndex: i,
|
|
73
|
+
expected: { type: "valid_json" },
|
|
74
|
+
actual: null,
|
|
75
|
+
kind: "missing",
|
|
76
|
+
details: `Entry ${i}: unparseable JSON`,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const msgType = String(parsed.type || "unknown");
|
|
82
|
+
|
|
83
|
+
if (!typeBreakdown[msgType]) {
|
|
84
|
+
typeBreakdown[msgType] = { count: 0, issues: 0 };
|
|
85
|
+
}
|
|
86
|
+
typeBreakdown[msgType].count++;
|
|
87
|
+
|
|
88
|
+
// Validate required fields per message type
|
|
89
|
+
const issues = validateMessageStructure(msgType, parsed, i);
|
|
90
|
+
for (const issue of issues) {
|
|
91
|
+
diffs.push(issue);
|
|
92
|
+
typeBreakdown[msgType].issues++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
compatible: diffs.length === 0,
|
|
98
|
+
backendType: recording.header.backend_type,
|
|
99
|
+
totalMessages: browserMessages.length,
|
|
100
|
+
diffs,
|
|
101
|
+
messageTypeBreakdown: typeBreakdown,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compare two recordings structurally.
|
|
107
|
+
*
|
|
108
|
+
* Useful for verifying that replaying CLI input through the adapter produces
|
|
109
|
+
* the same browser output. Returns diffs where messages diverge.
|
|
110
|
+
*/
|
|
111
|
+
export function compareRecordings(
|
|
112
|
+
expected: Recording,
|
|
113
|
+
actual: { type: string; [key: string]: unknown }[],
|
|
114
|
+
): ProtocolDiff[] {
|
|
115
|
+
const expectedMsgs = filterEntries(expected.entries, "out", "browser");
|
|
116
|
+
const diffs: ProtocolDiff[] = [];
|
|
117
|
+
|
|
118
|
+
const maxLen = Math.max(expectedMsgs.length, actual.length);
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < maxLen; i++) {
|
|
121
|
+
if (i >= expectedMsgs.length) {
|
|
122
|
+
diffs.push({
|
|
123
|
+
entryIndex: i,
|
|
124
|
+
expected: { type: "none" },
|
|
125
|
+
actual: actual[i],
|
|
126
|
+
kind: "extra",
|
|
127
|
+
details: `Extra message at index ${i}: type=${actual[i].type}`,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (i >= actual.length) {
|
|
133
|
+
let expectedParsed: Record<string, unknown>;
|
|
134
|
+
try {
|
|
135
|
+
expectedParsed = JSON.parse(expectedMsgs[i].raw);
|
|
136
|
+
} catch {
|
|
137
|
+
expectedParsed = { type: "unparseable" };
|
|
138
|
+
}
|
|
139
|
+
diffs.push({
|
|
140
|
+
entryIndex: i,
|
|
141
|
+
expected: expectedParsed as { type: string },
|
|
142
|
+
actual: null,
|
|
143
|
+
kind: "missing",
|
|
144
|
+
details: `Missing message at index ${i}: expected type=${expectedParsed.type}`,
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let expectedParsed: Record<string, unknown>;
|
|
150
|
+
try {
|
|
151
|
+
expectedParsed = JSON.parse(expectedMsgs[i].raw);
|
|
152
|
+
} catch {
|
|
153
|
+
diffs.push({
|
|
154
|
+
entryIndex: i,
|
|
155
|
+
expected: { type: "unparseable" },
|
|
156
|
+
actual: actual[i],
|
|
157
|
+
kind: "field_mismatch",
|
|
158
|
+
details: `Entry ${i}: expected message has unparseable JSON`,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const actualMsg = actual[i];
|
|
164
|
+
|
|
165
|
+
// Type must match
|
|
166
|
+
if (expectedParsed.type !== actualMsg.type) {
|
|
167
|
+
diffs.push({
|
|
168
|
+
entryIndex: i,
|
|
169
|
+
expected: expectedParsed as { type: string },
|
|
170
|
+
actual: actualMsg as { type: string },
|
|
171
|
+
kind: "type_mismatch",
|
|
172
|
+
details: `Type mismatch at index ${i}: expected=${expectedParsed.type}, actual=${actualMsg.type}`,
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for missing/extra top-level fields (excluding ignored fields)
|
|
178
|
+
const fieldDiffs = compareFields(expectedParsed, actualMsg, i);
|
|
179
|
+
diffs.push(...fieldDiffs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return diffs;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
function validateMessageStructure(
|
|
188
|
+
type: string,
|
|
189
|
+
msg: Record<string, unknown>,
|
|
190
|
+
index: number,
|
|
191
|
+
): ProtocolDiff[] {
|
|
192
|
+
const issues: ProtocolDiff[] = [];
|
|
193
|
+
|
|
194
|
+
// All messages must have a type field
|
|
195
|
+
if (!msg.type) {
|
|
196
|
+
issues.push({
|
|
197
|
+
entryIndex: index,
|
|
198
|
+
expected: { type: "any" },
|
|
199
|
+
actual: msg as { type: string },
|
|
200
|
+
kind: "field_mismatch",
|
|
201
|
+
details: `Entry ${index}: missing 'type' field`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Type-specific validation
|
|
206
|
+
switch (type) {
|
|
207
|
+
case "session_init":
|
|
208
|
+
if (!msg.session || typeof msg.session !== "object") {
|
|
209
|
+
issues.push({
|
|
210
|
+
entryIndex: index,
|
|
211
|
+
expected: { type, session: "object" },
|
|
212
|
+
actual: msg as { type: string },
|
|
213
|
+
kind: "field_mismatch",
|
|
214
|
+
details: `Entry ${index}: session_init missing 'session' object`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case "permission_request":
|
|
220
|
+
if (!msg.tool_name) {
|
|
221
|
+
issues.push({
|
|
222
|
+
entryIndex: index,
|
|
223
|
+
expected: { type, tool_name: "string" },
|
|
224
|
+
actual: msg as { type: string },
|
|
225
|
+
kind: "field_mismatch",
|
|
226
|
+
details: `Entry ${index}: permission_request missing 'tool_name'`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case "result":
|
|
232
|
+
if (!msg.subtype) {
|
|
233
|
+
issues.push({
|
|
234
|
+
entryIndex: index,
|
|
235
|
+
expected: { type, subtype: "string" },
|
|
236
|
+
actual: msg as { type: string },
|
|
237
|
+
kind: "field_mismatch",
|
|
238
|
+
details: `Entry ${index}: result missing 'subtype'`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return issues;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function compareFields(
|
|
248
|
+
expected: Record<string, unknown>,
|
|
249
|
+
actual: Record<string, unknown>,
|
|
250
|
+
index: number,
|
|
251
|
+
): ProtocolDiff[] {
|
|
252
|
+
const diffs: ProtocolDiff[] = [];
|
|
253
|
+
|
|
254
|
+
const expectedKeys = Object.keys(expected).filter((k) => !IGNORED_FIELDS.has(k));
|
|
255
|
+
const actualKeys = Object.keys(actual).filter((k) => !IGNORED_FIELDS.has(k));
|
|
256
|
+
|
|
257
|
+
// Check for missing fields in actual
|
|
258
|
+
for (const key of expectedKeys) {
|
|
259
|
+
if (!(key in actual)) {
|
|
260
|
+
diffs.push({
|
|
261
|
+
entryIndex: index,
|
|
262
|
+
expected: expected as { type: string },
|
|
263
|
+
actual: actual as { type: string },
|
|
264
|
+
kind: "field_mismatch",
|
|
265
|
+
details: `Entry ${index}: missing field '${key}' in actual (type=${expected.type})`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check for unexpected new fields in actual (informational, not necessarily a break)
|
|
271
|
+
for (const key of actualKeys) {
|
|
272
|
+
if (!(key in expected)) {
|
|
273
|
+
diffs.push({
|
|
274
|
+
entryIndex: index,
|
|
275
|
+
expected: expected as { type: string },
|
|
276
|
+
actual: actual as { type: string },
|
|
277
|
+
kind: "field_mismatch",
|
|
278
|
+
details: `Entry ${index}: new field '${key}' in actual (type=${actual.type})`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return diffs;
|
|
284
|
+
}
|