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.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Storage and indexing for curated recording files.
3
+ *
4
+ * Recordings uploaded or imported into the hub live in ~/.heyhank/hub/recordings/
5
+ * (separate from the auto-recording directory to avoid rotation cleanup).
6
+ * An index file (index.json) provides fast listing without re-parsing JSONL.
7
+ */
8
+
9
+ import {
10
+ mkdirSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ copyFileSync,
14
+ unlinkSync,
15
+ existsSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+ import { HEYHANK_HOME } from "../paths.js";
21
+ import { loadRecording } from "../replay.js";
22
+ import type { RecordingHeader, RecordingEntry } from "../recorder.js";
23
+ import type { BackendType } from "../session-types.js";
24
+ import { getMaxUploadBytes } from "./hub-config.js";
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface HubRecordingMeta {
29
+ id: string;
30
+ filename: string;
31
+ sessionId: string;
32
+ backendType: BackendType;
33
+ startedAt: number;
34
+ duration: number;
35
+ entryCount: number;
36
+ cwd: string;
37
+ tags: string[];
38
+ importedAt: number;
39
+ messageTypeSummary: Record<string, number>;
40
+ }
41
+
42
+ export interface HubRecordingSummary extends HubRecordingMeta {
43
+ toolNames: string[];
44
+ permissionCount: number;
45
+ }
46
+
47
+ // ─── HubStore ────────────────────────────────────────────────────────────────
48
+
49
+ const HUB_DIR = join(HEYHANK_HOME, "hub");
50
+ const RECORDINGS_DIR = join(HUB_DIR, "recordings");
51
+ const INDEX_PATH = join(HUB_DIR, "index.json");
52
+
53
+ export class HubStore {
54
+ private index: Map<string, HubRecordingMeta> = new Map();
55
+ private dirCreated = false;
56
+
57
+ constructor() {
58
+ this.ensureDir();
59
+ this.loadIndex();
60
+ }
61
+
62
+ // ── Public API ──────────────────────────────────────────────────────────
63
+
64
+ /** Import a recording from the auto-recordings directory by copying it. */
65
+ importLocal(sourcePath: string): HubRecordingMeta {
66
+ this.validateFileSize(sourcePath);
67
+ const recording = loadRecording(sourcePath);
68
+ const id = randomUUID();
69
+ const destFilename = `${id}.jsonl`;
70
+ const destPath = join(RECORDINGS_DIR, destFilename);
71
+ copyFileSync(sourcePath, destPath);
72
+ const meta = this.buildMeta(id, destFilename, recording.header, recording.entries);
73
+ this.index.set(id, meta);
74
+ this.saveIndex();
75
+ return meta;
76
+ }
77
+
78
+ /** Import from raw JSONL content (e.g. from an upload). */
79
+ importContent(content: string, originalFilename?: string): HubRecordingMeta {
80
+ const sizeBytes = Buffer.byteLength(content, "utf-8");
81
+ if (sizeBytes > getMaxUploadBytes()) {
82
+ throw new Error(`File too large: ${Math.round(sizeBytes / 1024 / 1024)}MB exceeds limit`);
83
+ }
84
+
85
+ // Validate by parsing
86
+ const lines = content.split("\n").filter((l) => l.trim());
87
+ if (lines.length === 0) throw new Error("Recording file is empty");
88
+
89
+ const header = JSON.parse(lines[0]) as RecordingHeader;
90
+ if (!header._header || header.version !== 1) {
91
+ throw new Error("Invalid recording header: missing _header or version !== 1");
92
+ }
93
+ if (header.backend_type !== "claude" && header.backend_type !== "codex") {
94
+ throw new Error(`Invalid backend_type: ${header.backend_type}`);
95
+ }
96
+
97
+ // Spot-check entries
98
+ const entries: RecordingEntry[] = [];
99
+ for (let i = 1; i < lines.length; i++) {
100
+ try {
101
+ const entry = JSON.parse(lines[i]) as RecordingEntry;
102
+ if (typeof entry.ts !== "number" || !entry.dir || typeof entry.raw !== "string" || !entry.ch) {
103
+ throw new Error(`Malformed entry at line ${i + 1}`);
104
+ }
105
+ entries.push(entry);
106
+ } catch (err) {
107
+ if (err instanceof SyntaxError) {
108
+ throw new Error(`Malformed JSON at line ${i + 1}`);
109
+ }
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ const id = randomUUID();
115
+ const destFilename = `${id}.jsonl`;
116
+ const destPath = join(RECORDINGS_DIR, destFilename);
117
+ writeFileSync(destPath, content, "utf-8");
118
+
119
+ const meta = this.buildMeta(id, originalFilename || destFilename, header, entries);
120
+ this.index.set(id, meta);
121
+ this.saveIndex();
122
+ return meta;
123
+ }
124
+
125
+ list(): HubRecordingMeta[] {
126
+ return Array.from(this.index.values()).sort((a, b) => b.importedAt - a.importedAt);
127
+ }
128
+
129
+ get(id: string): HubRecordingMeta | null {
130
+ return this.index.get(id) ?? null;
131
+ }
132
+
133
+ /** Load the full recording content from disk. */
134
+ loadRecording(id: string) {
135
+ const meta = this.index.get(id);
136
+ if (!meta) return null;
137
+ const filePath = this.recordingPath(id);
138
+ if (!existsSync(filePath)) return null;
139
+ return loadRecording(filePath);
140
+ }
141
+
142
+ /** Get the file path for a recording. */
143
+ recordingPath(id: string): string {
144
+ return join(RECORDINGS_DIR, `${id}.jsonl`);
145
+ }
146
+
147
+ delete(id: string): boolean {
148
+ const meta = this.index.get(id);
149
+ if (!meta) return false;
150
+ const filePath = this.recordingPath(id);
151
+ try {
152
+ unlinkSync(filePath);
153
+ } catch {
154
+ // File may already be gone
155
+ }
156
+ this.index.delete(id);
157
+ this.saveIndex();
158
+ return true;
159
+ }
160
+
161
+ updateTags(id: string, tags: string[]): HubRecordingMeta | null {
162
+ const meta = this.index.get(id);
163
+ if (!meta) return null;
164
+ meta.tags = tags;
165
+ this.saveIndex();
166
+ return meta;
167
+ }
168
+
169
+ /** Get a summary with tool names and permission count. */
170
+ getSummary(id: string): HubRecordingSummary | null {
171
+ const recording = this.loadRecording(id);
172
+ if (!recording) return null;
173
+ const meta = this.index.get(id);
174
+ if (!meta) return null;
175
+
176
+ const toolNames = new Set<string>();
177
+ let permissionCount = 0;
178
+
179
+ for (const entry of recording.entries) {
180
+ if (entry.dir !== "out" || entry.ch !== "browser") continue;
181
+ try {
182
+ const msg = JSON.parse(entry.raw);
183
+ if (msg.type === "permission_request" && msg.tool_name) {
184
+ toolNames.add(msg.tool_name);
185
+ permissionCount++;
186
+ }
187
+ } catch {
188
+ // Skip unparseable
189
+ }
190
+ }
191
+
192
+ return { ...meta, toolNames: Array.from(toolNames), permissionCount };
193
+ }
194
+
195
+ // ── Private helpers ─────────────────────────────────────────────────────
196
+
197
+ private buildMeta(
198
+ id: string,
199
+ filename: string,
200
+ header: RecordingHeader,
201
+ entries: RecordingEntry[],
202
+ ): HubRecordingMeta {
203
+ const typeSummary: Record<string, number> = {};
204
+ for (const entry of entries) {
205
+ if (entry.dir !== "out" || entry.ch !== "browser") continue;
206
+ try {
207
+ const msg = JSON.parse(entry.raw);
208
+ const type = msg.type || "unknown";
209
+ typeSummary[type] = (typeSummary[type] || 0) + 1;
210
+ } catch {
211
+ // Skip
212
+ }
213
+ }
214
+
215
+ const firstTs = entries[0]?.ts ?? header.started_at;
216
+ const lastTs = entries[entries.length - 1]?.ts ?? firstTs;
217
+
218
+ return {
219
+ id,
220
+ filename,
221
+ sessionId: header.session_id,
222
+ backendType: header.backend_type,
223
+ startedAt: header.started_at,
224
+ duration: lastTs - firstTs,
225
+ entryCount: entries.length,
226
+ cwd: header.cwd,
227
+ tags: [],
228
+ importedAt: Date.now(),
229
+ messageTypeSummary: typeSummary,
230
+ };
231
+ }
232
+
233
+ private validateFileSize(path: string): void {
234
+ const stat = statSync(path);
235
+ if (stat.size > getMaxUploadBytes()) {
236
+ throw new Error(`File too large: ${Math.round(stat.size / 1024 / 1024)}MB exceeds limit`);
237
+ }
238
+ }
239
+
240
+ private ensureDir(): void {
241
+ if (this.dirCreated) return;
242
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
243
+ this.dirCreated = true;
244
+ }
245
+
246
+ private loadIndex(): void {
247
+ try {
248
+ if (existsSync(INDEX_PATH)) {
249
+ const raw = readFileSync(INDEX_PATH, "utf-8");
250
+ const entries = JSON.parse(raw) as HubRecordingMeta[];
251
+ for (const entry of entries) {
252
+ this.index.set(entry.id, entry);
253
+ }
254
+ }
255
+ } catch {
256
+ // Start fresh if index is corrupted
257
+ this.index.clear();
258
+ }
259
+ }
260
+
261
+ private saveIndex(): void {
262
+ const entries = Array.from(this.index.values());
263
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2), "utf-8");
264
+ }
265
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ReplayAdapter — replays a recorded session as a fake live backend.
3
+ *
4
+ * Implements IBackendAdapter so the WsBridge treats it identically to a real
5
+ * Claude Code or Codex backend. The browser has no idea it's watching a replay.
6
+ */
7
+
8
+ import type { IBackendAdapter } from "../backend-adapter.js";
9
+ import type { BrowserIncomingMessage, BrowserOutgoingMessage } from "../session-types.js";
10
+ import type { Recording } from "../replay.js";
11
+ import { filterEntries } from "../replay.js";
12
+
13
+ type State = "idle" | "playing" | "paused" | "finished";
14
+
15
+ export class ReplayAdapter implements IBackendAdapter {
16
+ private state: State = "idle";
17
+ private speed: number;
18
+
19
+ private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
20
+ private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
21
+ private disconnectCb: (() => void) | null = null;
22
+
23
+ /** Outgoing browser messages from the recording, in order. */
24
+ private readonly entries: { ts: number; raw: string }[];
25
+ private currentIndex = 0;
26
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ /** Tracks wall-clock time when the current timer was scheduled (for pause/resume drift fix). */
29
+ private timerScheduledAt = 0;
30
+ /** Tracks the delay used for the current timer (for pause/resume drift fix). */
31
+ private timerDelayMs = 0;
32
+ /** Remaining ms when paused mid-timer, used on resume to avoid timeline drift. */
33
+ private pausedRemainingMs = 0;
34
+
35
+ private readonly recording: Recording;
36
+
37
+ constructor(recording: Recording, speed = 1) {
38
+ this.recording = recording;
39
+ this.speed = speed;
40
+
41
+ // Extract outgoing browser messages (what the server originally sent)
42
+ this.entries = filterEntries(recording.entries, "out", "browser").map((e) => ({
43
+ ts: e.ts,
44
+ raw: e.raw,
45
+ }));
46
+ }
47
+
48
+ // ── IBackendAdapter interface ────────────────────────────────────────
49
+
50
+ send(_msg: BrowserOutgoingMessage): boolean {
51
+ // Replay doesn't accept input from browsers — it just plays back.
52
+ // Permission responses, user messages, etc. are ignored.
53
+ return true;
54
+ }
55
+
56
+ isConnected(): boolean {
57
+ return this.state === "playing" || this.state === "paused";
58
+ }
59
+
60
+ async disconnect(): Promise<void> {
61
+ this.clearTimer();
62
+ this.state = "finished";
63
+ this.disconnectCb?.();
64
+ }
65
+
66
+ onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
67
+ this.browserMessageCb = cb;
68
+ }
69
+
70
+ onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
71
+ this.sessionMetaCb = cb;
72
+ }
73
+
74
+ onDisconnect(cb: () => void): void {
75
+ this.disconnectCb = cb;
76
+ }
77
+
78
+ // ── Replay controls ─────────────────────────────────────────────────
79
+
80
+ play(): void {
81
+ if (this.state === "finished") return;
82
+ if (this.state === "playing") return;
83
+
84
+ // Emit session metadata from recording header on first play
85
+ if (this.state === "idle") {
86
+ this.sessionMetaCb?.({
87
+ cliSessionId: this.recording.header.session_id,
88
+ cwd: this.recording.header.cwd,
89
+ });
90
+ }
91
+
92
+ this.state = "playing";
93
+ this.scheduleNext();
94
+ }
95
+
96
+ pause(): void {
97
+ if (this.state !== "playing") return;
98
+ // Calculate how much time remained on the current timer so resume doesn't drift
99
+ this.pausedRemainingMs = Math.max(0, this.timerDelayMs - (Date.now() - this.timerScheduledAt));
100
+ this.clearTimer();
101
+ this.state = "paused";
102
+ }
103
+
104
+ setSpeed(multiplier: number): void {
105
+ if (multiplier <= 0) return;
106
+ const oldSpeed = this.speed;
107
+ this.speed = multiplier;
108
+
109
+ if (this.state === "playing") {
110
+ this.clearTimer();
111
+ this.scheduleNext();
112
+ } else if (this.state === "paused" && this.pausedRemainingMs > 0) {
113
+ // Recalculate remaining time with the new speed ratio
114
+ this.pausedRemainingMs = this.pausedRemainingMs * (oldSpeed / multiplier);
115
+ }
116
+ }
117
+
118
+ getProgress(): { current: number; total: number; percentComplete: number; state: State } {
119
+ const total = this.entries.length;
120
+ const current = this.currentIndex;
121
+ return {
122
+ current,
123
+ total,
124
+ percentComplete: total > 0 ? Math.round((current / total) * 100) : 100,
125
+ state: this.state,
126
+ };
127
+ }
128
+
129
+ // ── Internal scheduling ─────────────────────────────────────────────
130
+
131
+ private scheduleNext(): void {
132
+ if (this.state !== "playing") return;
133
+ if (this.currentIndex >= this.entries.length) {
134
+ this.finish();
135
+ return;
136
+ }
137
+
138
+ const entry = this.entries[this.currentIndex];
139
+
140
+ let delayMs: number;
141
+
142
+ if (this.pausedRemainingMs > 0) {
143
+ // Resuming after pause — use the remaining time from the interrupted timer
144
+ delayMs = this.pausedRemainingMs;
145
+ this.pausedRemainingMs = 0;
146
+ } else {
147
+ // Calculate delay based on timing difference from previous entry
148
+ delayMs = 0;
149
+ if (this.currentIndex > 0) {
150
+ const prevTs = this.entries[this.currentIndex - 1].ts;
151
+ delayMs = (entry.ts - prevTs) / this.speed;
152
+ }
153
+
154
+ // Instant mode: no delay at all
155
+ if (!Number.isFinite(this.speed) || this.speed === Infinity) {
156
+ delayMs = 0;
157
+ }
158
+
159
+ // Cap maximum delay to prevent excessively long waits
160
+ delayMs = Math.min(delayMs, 5000 / this.speed);
161
+ }
162
+
163
+ this.timerDelayMs = delayMs;
164
+ this.timerScheduledAt = Date.now();
165
+
166
+ if (delayMs <= 0) {
167
+ // Emit synchronously for instant mode, but use microtask to avoid stack overflow
168
+ this.pendingTimer = setTimeout(() => this.emitEntry(), 0);
169
+ } else {
170
+ this.pendingTimer = setTimeout(() => this.emitEntry(), delayMs);
171
+ }
172
+ }
173
+
174
+ private emitEntry(): void {
175
+ if (this.state !== "playing") return;
176
+ if (this.currentIndex >= this.entries.length) {
177
+ this.finish();
178
+ return;
179
+ }
180
+
181
+ const entry = this.entries[this.currentIndex];
182
+ this.currentIndex++;
183
+
184
+ try {
185
+ const msg = JSON.parse(entry.raw) as BrowserIncomingMessage;
186
+ this.browserMessageCb?.(msg);
187
+ } catch {
188
+ // Skip malformed entries
189
+ }
190
+
191
+ this.scheduleNext();
192
+ }
193
+
194
+ private finish(): void {
195
+ this.state = "finished";
196
+ // Emit a cli_disconnected so the browser knows the session ended
197
+ this.browserMessageCb?.({ type: "cli_disconnected" } as BrowserIncomingMessage);
198
+ this.disconnectCb?.();
199
+ }
200
+
201
+ private clearTimer(): void {
202
+ if (this.pendingTimer !== null) {
203
+ clearTimeout(this.pendingTimer);
204
+ this.pendingTimer = null;
205
+ }
206
+ }
207
+ }