@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,294 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ReplayAdapter } from "./replay-adapter.js";
3
+ import type { Recording } from "../replay.js";
4
+ import type { BrowserIncomingMessage } from "../session-types.js";
5
+
6
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
7
+
8
+ function makeRecording(browserMessages: { type: string; [key: string]: unknown }[], delayMs = 100): Recording {
9
+ const entries = browserMessages.map((msg, i) => ({
10
+ ts: 1000 + i * delayMs,
11
+ dir: "out" as const,
12
+ raw: JSON.stringify(msg),
13
+ ch: "browser" as const,
14
+ }));
15
+
16
+ return {
17
+ header: {
18
+ _header: true as const,
19
+ version: 1 as const,
20
+ session_id: "test-session",
21
+ backend_type: "claude" as const,
22
+ started_at: 1000,
23
+ cwd: "/test",
24
+ },
25
+ entries,
26
+ };
27
+ }
28
+
29
+ // ─── Tests ───────────────────────────────────────────────────────────────────
30
+
31
+ describe("ReplayAdapter", () => {
32
+ beforeEach(() => {
33
+ vi.useFakeTimers();
34
+ });
35
+
36
+ describe("basic replay", () => {
37
+ it("emits all browser messages in order", async () => {
38
+ const messages = [
39
+ { type: "session_init", session: {} },
40
+ { type: "assistant", text: "Hello" },
41
+ { type: "result", subtype: "success" },
42
+ ];
43
+ const adapter = new ReplayAdapter(makeRecording(messages), Infinity);
44
+ const received: BrowserIncomingMessage[] = [];
45
+ adapter.onBrowserMessage((msg) => received.push(msg));
46
+ adapter.onSessionMeta(() => {});
47
+ adapter.onDisconnect(() => {});
48
+
49
+ adapter.play();
50
+ // Flush all microtasks/timers for instant mode
51
+ await vi.runAllTimersAsync();
52
+
53
+ // Should receive all messages plus the final cli_disconnected
54
+ expect(received.length).toBe(messages.length + 1);
55
+ expect(received[0].type).toBe("session_init");
56
+ expect(received[1].type).toBe("assistant");
57
+ expect(received[2].type).toBe("result");
58
+ expect(received[3].type).toBe("cli_disconnected");
59
+ });
60
+
61
+ it("emits session metadata on first play", async () => {
62
+ const adapter = new ReplayAdapter(makeRecording([{ type: "session_init", session: {} }]), Infinity);
63
+ const metaCalls: { cliSessionId?: string; cwd?: string }[] = [];
64
+ adapter.onBrowserMessage(() => {});
65
+ adapter.onSessionMeta((meta) => metaCalls.push(meta));
66
+ adapter.onDisconnect(() => {});
67
+
68
+ adapter.play();
69
+ await vi.runAllTimersAsync();
70
+
71
+ expect(metaCalls).toHaveLength(1);
72
+ expect(metaCalls[0].cliSessionId).toBe("test-session");
73
+ expect(metaCalls[0].cwd).toBe("/test");
74
+ });
75
+ });
76
+
77
+ describe("IBackendAdapter interface", () => {
78
+ it("isConnected returns true while playing", () => {
79
+ const adapter = new ReplayAdapter(makeRecording([{ type: "assistant" }]), Infinity);
80
+ adapter.onBrowserMessage(() => {});
81
+ adapter.onSessionMeta(() => {});
82
+ adapter.onDisconnect(() => {});
83
+
84
+ expect(adapter.isConnected()).toBe(false); // idle
85
+ adapter.play();
86
+ expect(adapter.isConnected()).toBe(true); // playing
87
+ });
88
+
89
+ it("isConnected returns false after disconnect", async () => {
90
+ const adapter = new ReplayAdapter(makeRecording([{ type: "assistant" }]), Infinity);
91
+ adapter.onBrowserMessage(() => {});
92
+ adapter.onSessionMeta(() => {});
93
+ adapter.onDisconnect(() => {});
94
+
95
+ adapter.play();
96
+ await vi.runAllTimersAsync();
97
+ expect(adapter.isConnected()).toBe(false);
98
+ });
99
+
100
+ it("send() returns true (no-op for replay)", () => {
101
+ const adapter = new ReplayAdapter(makeRecording([]), Infinity);
102
+ expect(adapter.send({ type: "user_message" } as any)).toBe(true);
103
+ });
104
+
105
+ it("disconnect stops replay and calls disconnect callback", async () => {
106
+ const adapter = new ReplayAdapter(makeRecording([{ type: "a" }, { type: "b" }]), 1);
107
+ const disconnected = vi.fn();
108
+ adapter.onBrowserMessage(() => {});
109
+ adapter.onSessionMeta(() => {});
110
+ adapter.onDisconnect(disconnected);
111
+
112
+ adapter.play();
113
+ await adapter.disconnect();
114
+ expect(disconnected).toHaveBeenCalledOnce();
115
+ expect(adapter.isConnected()).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("pause and resume", () => {
120
+ it("pauses and resumes playback", async () => {
121
+ const messages = [
122
+ { type: "a" },
123
+ { type: "b" },
124
+ { type: "c" },
125
+ ];
126
+ // Use a real delay (100ms between messages)
127
+ const adapter = new ReplayAdapter(makeRecording(messages, 100), 1);
128
+ const received: BrowserIncomingMessage[] = [];
129
+ adapter.onBrowserMessage((msg) => received.push(msg));
130
+ adapter.onSessionMeta(() => {});
131
+ adapter.onDisconnect(() => {});
132
+
133
+ adapter.play();
134
+
135
+ // First message is instant (no delay for first entry)
136
+ await vi.advanceTimersByTimeAsync(0);
137
+ expect(received.length).toBeGreaterThanOrEqual(1);
138
+
139
+ adapter.pause();
140
+ const countAtPause = received.length;
141
+
142
+ // Advance time — no more messages should arrive while paused
143
+ await vi.advanceTimersByTimeAsync(500);
144
+ expect(received.length).toBe(countAtPause);
145
+
146
+ // Resume
147
+ adapter.play();
148
+ await vi.runAllTimersAsync();
149
+
150
+ // All messages plus cli_disconnected
151
+ expect(received.length).toBe(messages.length + 1);
152
+ });
153
+ });
154
+
155
+ describe("speed control", () => {
156
+ it("2x speed completes faster than 1x", async () => {
157
+ // Two messages 1000ms apart at 1x speed
158
+ const messages = [{ type: "a" }, { type: "b" }];
159
+ const adapter = new ReplayAdapter(makeRecording(messages, 1000), 2);
160
+ const received: BrowserIncomingMessage[] = [];
161
+ adapter.onBrowserMessage((msg) => received.push(msg));
162
+ adapter.onSessionMeta(() => {});
163
+ adapter.onDisconnect(() => {});
164
+
165
+ adapter.play();
166
+ // At 2x speed, 1000ms delay becomes 500ms. Advance 600ms — should see both messages.
167
+ await vi.advanceTimersByTimeAsync(600);
168
+ // First message is instant (index 0), second should have arrived by 500ms
169
+ expect(received.filter((m) => m.type !== "cli_disconnected").length).toBe(2);
170
+ });
171
+
172
+ it("setSpeed mid-play affects subsequent messages", async () => {
173
+ const messages = [{ type: "a" }, { type: "b" }, { type: "c" }];
174
+ const adapter = new ReplayAdapter(makeRecording(messages, 1000), 1);
175
+ const received: BrowserIncomingMessage[] = [];
176
+ adapter.onBrowserMessage((msg) => received.push(msg));
177
+ adapter.onSessionMeta(() => {});
178
+ adapter.onDisconnect(() => {});
179
+
180
+ adapter.play();
181
+ // First message is instant
182
+ await vi.advanceTimersByTimeAsync(0);
183
+ expect(received.length).toBeGreaterThanOrEqual(1);
184
+
185
+ // Switch to instant mode
186
+ adapter.setSpeed(Infinity);
187
+ await vi.runAllTimersAsync();
188
+
189
+ // All messages + cli_disconnected
190
+ expect(received.length).toBe(messages.length + 1);
191
+ });
192
+
193
+ it("ignores invalid speed values", () => {
194
+ const adapter = new ReplayAdapter(makeRecording([{ type: "a" }]), 5);
195
+ adapter.onBrowserMessage(() => {});
196
+ adapter.onSessionMeta(() => {});
197
+ adapter.onDisconnect(() => {});
198
+
199
+ adapter.setSpeed(0);
200
+ adapter.setSpeed(-1);
201
+ // Speed should remain unchanged (verified indirectly via progress state)
202
+ expect(adapter.getProgress().state).toBe("idle");
203
+ });
204
+
205
+ it("play() is idempotent while already playing", async () => {
206
+ const messages = [{ type: "a" }, { type: "b" }];
207
+ const adapter = new ReplayAdapter(makeRecording(messages, 100), 1);
208
+ const received: BrowserIncomingMessage[] = [];
209
+ adapter.onBrowserMessage((msg) => received.push(msg));
210
+ adapter.onSessionMeta(() => {});
211
+ adapter.onDisconnect(() => {});
212
+
213
+ adapter.play();
214
+ adapter.play(); // Should be no-op (no overlapping timers)
215
+ adapter.play();
216
+ await vi.runAllTimersAsync();
217
+
218
+ // Should still get exactly the expected number of messages (no duplicates)
219
+ expect(received.filter((m) => m.type !== "cli_disconnected").length).toBe(2);
220
+ });
221
+ });
222
+
223
+ describe("getProgress", () => {
224
+ it("reports progress correctly", async () => {
225
+ const messages = [{ type: "a" }, { type: "b" }, { type: "c" }];
226
+ const adapter = new ReplayAdapter(makeRecording(messages), Infinity);
227
+ adapter.onBrowserMessage(() => {});
228
+ adapter.onSessionMeta(() => {});
229
+ adapter.onDisconnect(() => {});
230
+
231
+ expect(adapter.getProgress()).toEqual({
232
+ current: 0,
233
+ total: 3,
234
+ percentComplete: 0,
235
+ state: "idle",
236
+ });
237
+
238
+ adapter.play();
239
+ await vi.runAllTimersAsync();
240
+
241
+ expect(adapter.getProgress()).toEqual({
242
+ current: 3,
243
+ total: 3,
244
+ percentComplete: 100,
245
+ state: "finished",
246
+ });
247
+ });
248
+ });
249
+
250
+ describe("edge cases", () => {
251
+ it("handles empty recording", async () => {
252
+ const adapter = new ReplayAdapter(
253
+ { header: { _header: true, version: 1, session_id: "s", backend_type: "claude", started_at: 0, cwd: "/" }, entries: [] },
254
+ Infinity,
255
+ );
256
+ const received: BrowserIncomingMessage[] = [];
257
+ const disconnected = vi.fn();
258
+ adapter.onBrowserMessage((msg) => received.push(msg));
259
+ adapter.onSessionMeta(() => {});
260
+ adapter.onDisconnect(disconnected);
261
+
262
+ adapter.play();
263
+ await vi.runAllTimersAsync();
264
+
265
+ // Only cli_disconnected for an empty recording
266
+ expect(received).toHaveLength(1);
267
+ expect(received[0].type).toBe("cli_disconnected");
268
+ expect(disconnected).toHaveBeenCalledOnce();
269
+ });
270
+
271
+ it("handles malformed entry JSON gracefully", async () => {
272
+ const recording: Recording = {
273
+ header: { _header: true, version: 1, session_id: "s", backend_type: "claude", started_at: 0, cwd: "/" },
274
+ entries: [
275
+ { ts: 100, dir: "out", raw: "not valid json", ch: "browser" },
276
+ { ts: 200, dir: "out", raw: JSON.stringify({ type: "assistant" }), ch: "browser" },
277
+ ],
278
+ };
279
+ const adapter = new ReplayAdapter(recording, Infinity);
280
+ const received: BrowserIncomingMessage[] = [];
281
+ adapter.onBrowserMessage((msg) => received.push(msg));
282
+ adapter.onSessionMeta(() => {});
283
+ adapter.onDisconnect(() => {});
284
+
285
+ adapter.play();
286
+ await vi.runAllTimersAsync();
287
+
288
+ // Should skip the malformed entry and emit the valid one + cli_disconnected
289
+ expect(received).toHaveLength(2);
290
+ expect(received[0].type).toBe("assistant");
291
+ expect(received[1].type).toBe("cli_disconnected");
292
+ });
293
+ });
294
+ });
@@ -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
+ }