@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,296 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ appendAndPersist,
4
+ appendHistory,
5
+ persistSession,
6
+ serializeForStore,
7
+ MESSAGE_HISTORY_LIMIT,
8
+ } from "./ws-bridge-persist.js";
9
+ import type { Session } from "./ws-bridge-types.js";
10
+ import type { BrowserIncomingMessage } from "./session-types.js";
11
+ import { SessionStateMachine } from "./session-state-machine.js";
12
+ import { SessionStore } from "./session-store.js";
13
+ import { mkdtempSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ function makeSession(overrides: Partial<Session> = {}): Session {
18
+ return {
19
+ id: "test-session",
20
+ backendType: "claude",
21
+ backendAdapter: null,
22
+ browserSockets: new Set(),
23
+ state: {
24
+ session_id: "test-session",
25
+ model: "claude-sonnet-4-6",
26
+ cwd: "/test",
27
+ tools: [],
28
+ permissionMode: "default",
29
+ claude_code_version: "1.0",
30
+ mcp_servers: [],
31
+ agents: [],
32
+ slash_commands: [],
33
+ skills: [],
34
+ total_cost_usd: 0,
35
+ num_turns: 0,
36
+ context_used_percent: 0,
37
+ is_compacting: false,
38
+ git_branch: "",
39
+ is_worktree: false,
40
+ is_containerized: false,
41
+ repo_root: "",
42
+ git_ahead: 0,
43
+ git_behind: 0,
44
+ total_lines_added: 0,
45
+ total_lines_removed: 0,
46
+ aiValidationEnabled: false,
47
+ aiValidationAutoApprove: false,
48
+ aiValidationAutoDeny: false,
49
+ },
50
+ pendingPermissions: new Map(),
51
+ messageHistory: [],
52
+ pendingMessages: [],
53
+ nextEventSeq: 1,
54
+ eventBuffer: [],
55
+ lastAckSeq: 0,
56
+ processedClientMessageIds: [],
57
+ processedClientMessageIdSet: new Set(),
58
+ lastCliActivityTs: Date.now(),
59
+ stateMachine: new SessionStateMachine("test-session"),
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ function makeAssistantMsg(id: string): BrowserIncomingMessage {
65
+ return {
66
+ type: "assistant",
67
+ message: { id, type: "message", role: "assistant", model: "claude", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } },
68
+ parent_tool_use_id: null,
69
+ timestamp: Date.now(),
70
+ };
71
+ }
72
+
73
+ // ─── appendHistory ────────────────────────────────────────────────────────────
74
+
75
+ describe("appendHistory", () => {
76
+ it("appends message to session history", () => {
77
+ const session = makeSession();
78
+ const msg = makeAssistantMsg("m1");
79
+ appendHistory(session, msg);
80
+
81
+ expect(session.messageHistory).toHaveLength(1);
82
+ expect(session.messageHistory[0]).toBe(msg);
83
+ });
84
+
85
+ it("appends multiple messages in order", () => {
86
+ const session = makeSession();
87
+ const msg1 = makeAssistantMsg("m1");
88
+ const msg2 = makeAssistantMsg("m2");
89
+ const msg3 = makeAssistantMsg("m3");
90
+
91
+ appendHistory(session, msg1);
92
+ appendHistory(session, msg2);
93
+ appendHistory(session, msg3);
94
+
95
+ expect(session.messageHistory).toHaveLength(3);
96
+ // Verify ordering is preserved
97
+ expect((session.messageHistory[0] as any).message.id).toBe("m1");
98
+ expect((session.messageHistory[1] as any).message.id).toBe("m2");
99
+ expect((session.messageHistory[2] as any).message.id).toBe("m3");
100
+ });
101
+
102
+ it("trims oldest messages when history exceeds limit", () => {
103
+ const session = makeSession();
104
+ const limit = 5;
105
+
106
+ // Add 7 messages with limit of 5
107
+ for (let i = 0; i < 7; i++) {
108
+ appendHistory(session, makeAssistantMsg(`m${i}`), limit);
109
+ }
110
+
111
+ expect(session.messageHistory).toHaveLength(5);
112
+ // Oldest 2 (m0, m1) should be trimmed; m2-m6 remain
113
+ expect((session.messageHistory[0] as any).message.id).toBe("m2");
114
+ expect((session.messageHistory[4] as any).message.id).toBe("m6");
115
+ });
116
+
117
+ it("uses MESSAGE_HISTORY_LIMIT as default", () => {
118
+ expect(MESSAGE_HISTORY_LIMIT).toBe(2000);
119
+ });
120
+ });
121
+
122
+ // ─── persistSession ───────────────────────────────────────────────────────────
123
+
124
+ describe("persistSession", () => {
125
+ let tempDir: string;
126
+ let store: SessionStore;
127
+
128
+ beforeEach(() => {
129
+ tempDir = mkdtempSync(join(tmpdir(), "persist-test-"));
130
+ store = new SessionStore(tempDir);
131
+ // Suppress console output
132
+ vi.spyOn(console, "log").mockImplementation(() => {});
133
+ vi.spyOn(console, "warn").mockImplementation(() => {});
134
+ vi.spyOn(console, "error").mockImplementation(() => {});
135
+ });
136
+
137
+ afterEach(() => {
138
+ store.dispose();
139
+ rmSync(tempDir, { recursive: true, force: true });
140
+ vi.restoreAllMocks();
141
+ });
142
+
143
+ it("calls store.save with serialized session data", () => {
144
+ const session = makeSession();
145
+ const saveSpy = vi.spyOn(store, "save");
146
+
147
+ persistSession(session, store);
148
+
149
+ expect(saveSpy).toHaveBeenCalledTimes(1);
150
+ const saved = saveSpy.mock.calls[0][0];
151
+ expect(saved.id).toBe("test-session");
152
+ expect(saved.state).toBe(session.state);
153
+ expect(saved.messageHistory).toBe(session.messageHistory);
154
+ });
155
+
156
+ it("is a no-op when store is null", () => {
157
+ const session = makeSession();
158
+ // Should not throw
159
+ expect(() => persistSession(session, null)).not.toThrow();
160
+ });
161
+ });
162
+
163
+ // ─── appendAndPersist ─────────────────────────────────────────────────────────
164
+
165
+ describe("appendAndPersist", () => {
166
+ let tempDir: string;
167
+ let store: SessionStore;
168
+
169
+ beforeEach(() => {
170
+ tempDir = mkdtempSync(join(tmpdir(), "persist-test-"));
171
+ store = new SessionStore(tempDir);
172
+ vi.spyOn(console, "log").mockImplementation(() => {});
173
+ vi.spyOn(console, "warn").mockImplementation(() => {});
174
+ vi.spyOn(console, "error").mockImplementation(() => {});
175
+ });
176
+
177
+ afterEach(() => {
178
+ store.dispose();
179
+ rmSync(tempDir, { recursive: true, force: true });
180
+ vi.restoreAllMocks();
181
+ });
182
+
183
+ it("appends message to history AND calls store.save", () => {
184
+ const session = makeSession();
185
+ const msg = makeAssistantMsg("m1");
186
+ const saveSpy = vi.spyOn(store, "save");
187
+
188
+ appendAndPersist(session, msg, store);
189
+
190
+ expect(session.messageHistory).toHaveLength(1);
191
+ expect(saveSpy).toHaveBeenCalledTimes(1);
192
+ });
193
+
194
+ it("enforces history cap while persisting", () => {
195
+ const session = makeSession();
196
+ const limit = 3;
197
+
198
+ for (let i = 0; i < 5; i++) {
199
+ appendAndPersist(session, makeAssistantMsg(`m${i}`), store, limit);
200
+ }
201
+
202
+ expect(session.messageHistory).toHaveLength(3);
203
+ expect((session.messageHistory[0] as any).message.id).toBe("m2");
204
+ });
205
+
206
+ it("works with null store (append only)", () => {
207
+ const session = makeSession();
208
+ const msg = makeAssistantMsg("m1");
209
+
210
+ appendAndPersist(session, msg, null);
211
+
212
+ expect(session.messageHistory).toHaveLength(1);
213
+ });
214
+ });
215
+
216
+ // ─── serializeForStore ────────────────────────────────────────────────────────
217
+
218
+ describe("serializeForStore", () => {
219
+ it("converts pendingPermissions Map to array of entries", () => {
220
+ const session = makeSession();
221
+ session.pendingPermissions.set("req-1", {
222
+ request_id: "req-1",
223
+ tool_name: "Bash",
224
+ input: { command: "ls" },
225
+ timestamp: 1000,
226
+ } as any);
227
+ session.pendingPermissions.set("req-2", {
228
+ request_id: "req-2",
229
+ tool_name: "Read",
230
+ input: { file_path: "/test" },
231
+ timestamp: 2000,
232
+ } as any);
233
+
234
+ const serialized = serializeForStore(session);
235
+
236
+ expect(serialized.pendingPermissions).toHaveLength(2);
237
+ expect(serialized.pendingPermissions[0][0]).toBe("req-1");
238
+ expect(serialized.pendingPermissions[0][1].tool_name).toBe("Bash");
239
+ expect(serialized.pendingPermissions[1][0]).toBe("req-2");
240
+ expect(serialized.pendingPermissions[1][1].tool_name).toBe("Read");
241
+ });
242
+
243
+ it("includes eventBuffer and sequence counters", () => {
244
+ const session = makeSession({
245
+ eventBuffer: [{ seq: 1, message: { type: "cli_connected" } }],
246
+ nextEventSeq: 42,
247
+ lastAckSeq: 10,
248
+ });
249
+
250
+ const serialized = serializeForStore(session);
251
+
252
+ expect(serialized.eventBuffer).toHaveLength(1);
253
+ expect(serialized.nextEventSeq).toBe(42);
254
+ expect(serialized.lastAckSeq).toBe(10);
255
+ });
256
+
257
+ it("includes processedClientMessageIds for browser dedup restoration", () => {
258
+ const session = makeSession({
259
+ processedClientMessageIds: ["id-1", "id-2", "id-3"],
260
+ });
261
+
262
+ const serialized = serializeForStore(session);
263
+ expect(serialized.processedClientMessageIds).toEqual(["id-1", "id-2", "id-3"]);
264
+ });
265
+
266
+ it("preserves message ordering through serialization", () => {
267
+ const session = makeSession();
268
+ for (let i = 0; i < 5; i++) {
269
+ session.messageHistory.push(makeAssistantMsg(`m${i}`));
270
+ }
271
+
272
+ const serialized = serializeForStore(session);
273
+ const parsed = JSON.parse(JSON.stringify(serialized));
274
+
275
+ // Message ordering should survive JSON round-trip
276
+ for (let i = 0; i < 5; i++) {
277
+ expect(parsed.messageHistory[i].message.id).toBe(`m${i}`);
278
+ }
279
+ });
280
+
281
+ it("produces identical output for same session state (idempotent)", () => {
282
+ const session = makeSession();
283
+ session.messageHistory.push(makeAssistantMsg("m1"));
284
+ session.pendingPermissions.set("req-1", {
285
+ request_id: "req-1",
286
+ tool_name: "Bash",
287
+ input: {},
288
+ timestamp: 1000,
289
+ } as any);
290
+
291
+ const first = JSON.stringify(serializeForStore(session));
292
+ const second = JSON.stringify(serializeForStore(session));
293
+
294
+ expect(first).toBe(second);
295
+ });
296
+ });
@@ -0,0 +1,66 @@
1
+ import type { BrowserIncomingMessage } from "./session-types.js";
2
+ import type { Session } from "./ws-bridge-types.js";
3
+ import type { SessionStore, PersistedSession } from "./session-store.js";
4
+
5
+ // ─── Persistence Pipeline ───────────────────────────────────────────────────
6
+ // Extracted from WsBridge to consolidate history append + disk persistence
7
+ // into explicit, testable functions.
8
+
9
+ export const MESSAGE_HISTORY_LIMIT = 2000;
10
+
11
+ /**
12
+ * Append a message to session history with cap enforcement, then persist to disk.
13
+ * Consolidates the common appendHistory + persistSession pattern into one call,
14
+ * eliminating the risk of appending without persisting.
15
+ */
16
+ export function appendAndPersist(
17
+ session: Session,
18
+ msg: BrowserIncomingMessage,
19
+ store: SessionStore | null,
20
+ historyLimit: number = MESSAGE_HISTORY_LIMIT,
21
+ ): void {
22
+ appendHistory(session, msg, historyLimit);
23
+ persistSession(session, store);
24
+ }
25
+
26
+ /**
27
+ * Append a message to session history with cap enforcement.
28
+ * Trims oldest messages when the history exceeds the limit.
29
+ */
30
+ export function appendHistory(
31
+ session: Session,
32
+ msg: BrowserIncomingMessage,
33
+ historyLimit: number = MESSAGE_HISTORY_LIMIT,
34
+ ): void {
35
+ session.messageHistory.push(msg);
36
+ if (session.messageHistory.length > historyLimit) {
37
+ session.messageHistory.splice(0, session.messageHistory.length - historyLimit);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Persist session state to disk (debounced via SessionStore).
43
+ * No-op if no store is attached.
44
+ */
45
+ export function persistSession(session: Session, store: SessionStore | null): void {
46
+ if (!store) return;
47
+ store.save(serializeForStore(session));
48
+ }
49
+
50
+ /**
51
+ * Serialize a Session into the shape expected by SessionStore.save().
52
+ * Converts Maps to arrays and selects the persisted fields.
53
+ */
54
+ export function serializeForStore(session: Session): PersistedSession {
55
+ return {
56
+ id: session.id,
57
+ state: session.state,
58
+ messageHistory: session.messageHistory,
59
+ pendingMessages: session.pendingMessages,
60
+ pendingPermissions: Array.from(session.pendingPermissions.entries()),
61
+ eventBuffer: session.eventBuffer,
62
+ nextEventSeq: session.nextEventSeq,
63
+ lastAckSeq: session.lastAckSeq,
64
+ processedClientMessageIds: session.processedClientMessageIds,
65
+ };
66
+ }
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ broadcastToBrowsers,
4
+ sendToBrowser,
5
+ EVENT_BUFFER_LIMIT,
6
+ } from "./ws-bridge-publish.js";
7
+ import type { Session, SocketData } from "./ws-bridge-types.js";
8
+ import type { BrowserIncomingMessage } from "./session-types.js";
9
+ import { SessionStateMachine } from "./session-state-machine.js";
10
+ import type { ServerWebSocket } from "bun";
11
+
12
+ function makeMockSocket(sessionId = "test-session") {
13
+ return {
14
+ data: { kind: "browser", sessionId } as SocketData,
15
+ send: vi.fn(),
16
+ close: vi.fn(),
17
+ readyState: 1,
18
+ } as unknown as ServerWebSocket<SocketData>;
19
+ }
20
+
21
+ function makeSession(overrides: Partial<Session> = {}): Session {
22
+ return {
23
+ id: "test-session",
24
+ backendType: "claude",
25
+ backendAdapter: null,
26
+ browserSockets: new Set(),
27
+ state: {
28
+ session_id: "test-session",
29
+ model: "claude-sonnet-4-6",
30
+ cwd: "/test",
31
+ tools: [],
32
+ permissionMode: "default",
33
+ claude_code_version: "1.0",
34
+ mcp_servers: [],
35
+ agents: [],
36
+ slash_commands: [],
37
+ skills: [],
38
+ total_cost_usd: 0,
39
+ num_turns: 0,
40
+ context_used_percent: 0,
41
+ is_compacting: false,
42
+ git_branch: "",
43
+ is_worktree: false,
44
+ is_containerized: false,
45
+ repo_root: "",
46
+ git_ahead: 0,
47
+ git_behind: 0,
48
+ total_lines_added: 0,
49
+ total_lines_removed: 0,
50
+ aiValidationEnabled: false,
51
+ aiValidationAutoApprove: false,
52
+ aiValidationAutoDeny: false,
53
+ },
54
+ pendingPermissions: new Map(),
55
+ messageHistory: [],
56
+ pendingMessages: [],
57
+ nextEventSeq: 1,
58
+ eventBuffer: [],
59
+ lastAckSeq: 0,
60
+ processedClientMessageIds: [],
61
+ processedClientMessageIdSet: new Set(),
62
+ lastCliActivityTs: Date.now(),
63
+ stateMachine: new SessionStateMachine("test-session"),
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ beforeEach(() => {
69
+ vi.spyOn(console, "log").mockImplementation(() => {});
70
+ vi.spyOn(console, "warn").mockImplementation(() => {});
71
+ vi.spyOn(console, "error").mockImplementation(() => {});
72
+ });
73
+
74
+ afterEach(() => {
75
+ vi.restoreAllMocks();
76
+ });
77
+
78
+ // ─── broadcastToBrowsers ──────────────────────────────────────────────────────
79
+
80
+ describe("broadcastToBrowsers", () => {
81
+ it("sends message to all connected browser sockets", () => {
82
+ const ws1 = makeMockSocket();
83
+ const ws2 = makeMockSocket();
84
+ const session = makeSession();
85
+ session.browserSockets.add(ws1);
86
+ session.browserSockets.add(ws2);
87
+
88
+ const msg: BrowserIncomingMessage = { type: "cli_connected" };
89
+ broadcastToBrowsers(session, msg, {
90
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
91
+ recorder: null,
92
+ persistFn: vi.fn(),
93
+ });
94
+
95
+ expect(ws1.send).toHaveBeenCalledTimes(1);
96
+ expect(ws2.send).toHaveBeenCalledTimes(1);
97
+
98
+ // Both should receive the same JSON
99
+ const sent1 = (ws1.send as any).mock.calls[0][0];
100
+ const sent2 = (ws2.send as any).mock.calls[0][0];
101
+ expect(sent1).toBe(sent2);
102
+ });
103
+
104
+ it("removes broken sockets that throw on send", () => {
105
+ const goodWs = makeMockSocket();
106
+ const badWs = makeMockSocket();
107
+ (badWs.send as any).mockImplementation(() => { throw new Error("broken"); });
108
+
109
+ const session = makeSession();
110
+ session.browserSockets.add(goodWs);
111
+ session.browserSockets.add(badWs);
112
+
113
+ broadcastToBrowsers(session, { type: "cli_connected" }, {
114
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
115
+ recorder: null,
116
+ persistFn: vi.fn(),
117
+ });
118
+
119
+ // Good socket still connected, bad one removed
120
+ expect(session.browserSockets.has(goodWs)).toBe(true);
121
+ expect(session.browserSockets.has(badWs)).toBe(false);
122
+ });
123
+
124
+ it("assigns monotonically increasing seq numbers", () => {
125
+ const ws = makeMockSocket();
126
+ const session = makeSession();
127
+ session.browserSockets.add(ws);
128
+
129
+ const opts = {
130
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
131
+ recorder: null,
132
+ persistFn: vi.fn(),
133
+ };
134
+
135
+ // Send 3 messages
136
+ broadcastToBrowsers(session, { type: "cli_connected" }, opts);
137
+ broadcastToBrowsers(session, { type: "cli_disconnected" }, opts);
138
+ broadcastToBrowsers(session, { type: "cli_connected" }, opts);
139
+
140
+ const seqs = (ws.send as any).mock.calls.map((call: any) => {
141
+ const parsed = JSON.parse(call[0]);
142
+ return parsed.seq;
143
+ });
144
+
145
+ // seq numbers should be strictly increasing
146
+ expect(seqs[0]).toBeLessThan(seqs[1]);
147
+ expect(seqs[1]).toBeLessThan(seqs[2]);
148
+ });
149
+
150
+ it("calls recorder.record when recorder is provided", () => {
151
+ const ws = makeMockSocket();
152
+ const session = makeSession();
153
+ session.browserSockets.add(ws);
154
+
155
+ const recorder = {
156
+ record: vi.fn(),
157
+ };
158
+
159
+ broadcastToBrowsers(session, { type: "cli_connected" }, {
160
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
161
+ recorder: recorder as any,
162
+ persistFn: vi.fn(),
163
+ });
164
+
165
+ expect(recorder.record).toHaveBeenCalledTimes(1);
166
+ expect(recorder.record).toHaveBeenCalledWith(
167
+ "test-session", "out", expect.any(String), "browser", "claude", "/test",
168
+ );
169
+ });
170
+
171
+ it("logs warning when broadcasting to 0 browsers for assistant/stream_event/result", () => {
172
+ const session = makeSession(); // no browser sockets
173
+ const logSpy = vi.mocked(console.log);
174
+
175
+ broadcastToBrowsers(session, { type: "assistant", message: {} as any, parent_tool_use_id: null, timestamp: 1 }, {
176
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
177
+ recorder: null,
178
+ persistFn: vi.fn(),
179
+ });
180
+
181
+ expect(logSpy).toHaveBeenCalledWith(
182
+ expect.stringContaining("Broadcasting assistant to 0 browsers"),
183
+ );
184
+ });
185
+
186
+ it("does not warn for non-critical message types with 0 browsers", () => {
187
+ const session = makeSession();
188
+ const logSpy = vi.mocked(console.log);
189
+ logSpy.mockClear();
190
+
191
+ broadcastToBrowsers(session, { type: "cli_connected" }, {
192
+ eventBufferLimit: EVENT_BUFFER_LIMIT,
193
+ recorder: null,
194
+ persistFn: vi.fn(),
195
+ });
196
+
197
+ // Should not have the "Broadcasting ... to 0 browsers" warning
198
+ const warningCalls = logSpy.mock.calls.filter(
199
+ (call) => typeof call[0] === "string" && call[0].includes("0 browsers"),
200
+ );
201
+ expect(warningCalls).toHaveLength(0);
202
+ });
203
+ });
204
+
205
+ // ─── sendToBrowser ────────────────────────────────────────────────────────────
206
+
207
+ describe("sendToBrowser", () => {
208
+ it("sends JSON-serialized message to socket", () => {
209
+ const ws = makeMockSocket();
210
+ const msg: BrowserIncomingMessage = { type: "cli_connected" };
211
+
212
+ sendToBrowser(ws, msg);
213
+
214
+ expect(ws.send).toHaveBeenCalledTimes(1);
215
+ const sent = JSON.parse((ws.send as any).mock.calls[0][0]);
216
+ expect(sent.type).toBe("cli_connected");
217
+ });
218
+
219
+ it("does not throw when socket.send fails", () => {
220
+ const ws = makeMockSocket();
221
+ (ws.send as any).mockImplementation(() => { throw new Error("broken"); });
222
+
223
+ // Should not throw
224
+ expect(() => sendToBrowser(ws, { type: "cli_connected" })).not.toThrow();
225
+ });
226
+ });
227
+
228
+ // ─── EVENT_BUFFER_LIMIT ───────────────────────────────────────────────────────
229
+
230
+ describe("EVENT_BUFFER_LIMIT", () => {
231
+ it("is 600", () => {
232
+ expect(EVENT_BUFFER_LIMIT).toBe(600);
233
+ });
234
+ });
@@ -0,0 +1,79 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { BrowserIncomingMessage } from "./session-types.js";
3
+ import type { Session, SocketData } from "./ws-bridge-types.js";
4
+ import type { RecorderManager } from "./recorder.js";
5
+ import { sequenceEvent } from "./ws-bridge-replay.js";
6
+
7
+ // ─── Publish Pipeline ───────────────────────────────────────────────────────
8
+ // Transport functions for sending messages to CLI and browser sockets.
9
+ // Extracted from WsBridge to enable isolated testing of message delivery,
10
+ // sequencing, and recording behavior.
11
+
12
+ export const EVENT_BUFFER_LIMIT = 600;
13
+
14
+ /**
15
+ * Broadcast a message to all connected browsers for a session.
16
+ * Assigns a monotonic sequence number via sequenceEvent, records the
17
+ * outgoing message, and sends to every browser socket (removing broken ones).
18
+ *
19
+ * Note: sequenceEvent internally calls persistFn when buffering events.
20
+ * Callers that also call persistSession after broadcastToBrowsers will
21
+ * trigger a redundant (but harmless) debounced save. This is intentional —
22
+ * the caller-side persist covers state mutations beyond the event buffer
23
+ * (e.g. messageHistory, pendingPermissions), while the internal persist
24
+ * covers the event buffer/seq counters. SessionStore's debouncer coalesces
25
+ * them into a single write.
26
+ */
27
+ export function broadcastToBrowsers(
28
+ session: Session,
29
+ msg: BrowserIncomingMessage,
30
+ opts: {
31
+ eventBufferLimit: number;
32
+ recorder: RecorderManager | null;
33
+ persistFn: (session: Session) => void;
34
+ },
35
+ ): void {
36
+ // Warn when messages that should be visible to users are broadcast to 0 browsers
37
+ if (
38
+ session.browserSockets.size === 0
39
+ && (msg.type === "assistant" || msg.type === "stream_event" || msg.type === "result")
40
+ ) {
41
+ console.log(
42
+ `[ws-bridge] ⚠ Broadcasting ${msg.type} to 0 browsers for session ${session.id} (stored in history: ${msg.type === "assistant" || msg.type === "result"})`,
43
+ );
44
+ }
45
+
46
+ const json = JSON.stringify(
47
+ sequenceEvent(session, msg, opts.eventBufferLimit, opts.persistFn),
48
+ );
49
+
50
+ // Record raw outgoing browser message
51
+ opts.recorder?.record(
52
+ session.id, "out", json, "browser", session.backendType, session.state.cwd,
53
+ );
54
+
55
+ for (const ws of session.browserSockets) {
56
+ try {
57
+ ws.send(json);
58
+ } catch {
59
+ session.browserSockets.delete(ws);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Send a message to a single browser socket (no sequencing).
66
+ * Used for replay, session_init, and message_history — messages that
67
+ * should NOT go through the event buffer.
68
+ */
69
+ export function sendToBrowser(
70
+ ws: ServerWebSocket<SocketData>,
71
+ msg: BrowserIncomingMessage,
72
+ ): void {
73
+ try {
74
+ ws.send(JSON.stringify(msg));
75
+ } catch {
76
+ // Socket will be cleaned up on close
77
+ }
78
+ }
79
+