@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,454 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ readdirSync,
7
+ existsSync,
8
+ utimesSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { SessionRecorder, RecorderManager } from "./recorder.js";
13
+
14
+ let tempDir: string;
15
+
16
+ beforeEach(() => {
17
+ tempDir = mkdtempSync(join(tmpdir(), "recorder-test-"));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function readDirSafe(dir: string): string[] {
27
+ try {
28
+ return readdirSync(dir) as string[];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Create a fake JSONL recording file with a given number of entry lines.
36
+ * Returns the full path. The header counts as 1 line, so total lines = 1 + entryCount.
37
+ */
38
+ function createFakeRecording(
39
+ dir: string,
40
+ filename: string,
41
+ entryCount: number,
42
+ mtime?: Date,
43
+ ): string {
44
+ const header = JSON.stringify({
45
+ _header: true,
46
+ version: 1,
47
+ session_id: "fake",
48
+ backend_type: "claude",
49
+ started_at: Date.now(),
50
+ cwd: "/fake",
51
+ });
52
+ const entry = JSON.stringify({ ts: Date.now(), dir: "in", raw: "x", ch: "cli" });
53
+ const lines = [header, ...Array(entryCount).fill(entry)];
54
+ const filePath = join(dir, filename);
55
+ writeFileSync(filePath, lines.join("\n") + "\n");
56
+ if (mtime) {
57
+ utimesSync(filePath, mtime, mtime);
58
+ }
59
+ return filePath;
60
+ }
61
+
62
+ // ─── SessionRecorder ─────────────────────────────────────────────────────────
63
+
64
+ describe("SessionRecorder", () => {
65
+ it("writes a header as the first line with correct metadata", () => {
66
+ const rec = new SessionRecorder("sess-1", "claude", "/project", tempDir);
67
+ rec.close();
68
+
69
+ const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
70
+ expect(lines.length).toBe(1);
71
+
72
+ const header = JSON.parse(lines[0]);
73
+ expect(header._header).toBe(true);
74
+ expect(header.version).toBe(1);
75
+ expect(header.session_id).toBe("sess-1");
76
+ expect(header.backend_type).toBe("claude");
77
+ expect(header.cwd).toBe("/project");
78
+ expect(typeof header.started_at).toBe("number");
79
+ });
80
+
81
+ it("preserves raw strings exactly without re-serialization", () => {
82
+ // The raw string has intentional formatting (extra spaces, specific order)
83
+ // that must be preserved verbatim — not re-parsed and re-serialized.
84
+ const rawMsg = '{"type":"system", "subtype":"init", "extra_field": true}';
85
+ const rec = new SessionRecorder("sess-2", "claude", "/project", tempDir);
86
+ rec.record("in", rawMsg, "cli");
87
+ rec.close();
88
+
89
+ const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
90
+ expect(lines.length).toBe(2);
91
+
92
+ const entry = JSON.parse(lines[1]);
93
+ expect(entry.raw).toBe(rawMsg);
94
+ });
95
+
96
+ it("records entries with monotonically increasing timestamps", () => {
97
+ const rec = new SessionRecorder("sess-3", "codex", "/project", tempDir);
98
+ rec.record("in", "msg1", "cli");
99
+ rec.record("out", "msg2", "cli");
100
+ rec.record("in", "msg3", "browser");
101
+ rec.close();
102
+
103
+ const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
104
+ expect(lines.length).toBe(4);
105
+
106
+ const entries = lines.slice(1).map((l) => JSON.parse(l));
107
+ for (let i = 1; i < entries.length; i++) {
108
+ expect(entries[i].ts).toBeGreaterThanOrEqual(entries[i - 1].ts);
109
+ }
110
+ });
111
+
112
+ it("records direction and channel correctly", () => {
113
+ const rec = new SessionRecorder("sess-4", "claude", "/cwd", tempDir);
114
+ rec.record("in", "hello", "cli");
115
+ rec.record("out", "world", "browser");
116
+ rec.close();
117
+
118
+ const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
119
+ const e1 = JSON.parse(lines[1]);
120
+ const e2 = JSON.parse(lines[2]);
121
+
122
+ expect(e1.dir).toBe("in");
123
+ expect(e1.ch).toBe("cli");
124
+ expect(e2.dir).toBe("out");
125
+ expect(e2.ch).toBe("browser");
126
+ });
127
+
128
+ it("does not record after close()", () => {
129
+ const rec = new SessionRecorder("sess-5", "claude", "/cwd", tempDir);
130
+ rec.record("in", "before-close", "cli");
131
+ rec.close();
132
+ rec.record("in", "after-close", "cli");
133
+
134
+ const lines = readFileSync(rec.filePath, "utf-8").trim().split("\n");
135
+ expect(lines.length).toBe(2);
136
+ expect(JSON.parse(lines[1]).raw).toBe("before-close");
137
+ });
138
+
139
+ it("generates a filename with session ID and backend type", () => {
140
+ const rec = new SessionRecorder("my-session", "codex", "/cwd", tempDir);
141
+ rec.close();
142
+
143
+ expect(rec.filePath).toContain("my-session");
144
+ expect(rec.filePath).toContain("codex");
145
+ expect(rec.filePath).toMatch(/\.jsonl$/);
146
+ });
147
+
148
+ it("tracks lineCount correctly (header + entries)", () => {
149
+ // lineCount starts at 1 (the header), increments for each recorded entry
150
+ const rec = new SessionRecorder("sess-lc", "claude", "/cwd", tempDir);
151
+ expect(rec.lineCount).toBe(1);
152
+
153
+ rec.record("in", "a", "cli");
154
+ rec.record("in", "b", "cli");
155
+ rec.record("out", "c", "browser");
156
+ rec.record("in", "d", "cli");
157
+ rec.record("out", "e", "browser");
158
+ expect(rec.lineCount).toBe(6);
159
+
160
+ rec.close();
161
+ // lineCount doesn't change after close
162
+ expect(rec.lineCount).toBe(6);
163
+ });
164
+ });
165
+
166
+ // ─── RecorderManager ─────────────────────────────────────────────────────────
167
+
168
+ describe("RecorderManager", () => {
169
+ it("enabled by default when no options provided", () => {
170
+ // Recording is always on unless explicitly disabled
171
+ const mgr = new RecorderManager({ recordingsDir: tempDir });
172
+ expect(mgr.isGloballyEnabled()).toBe(true);
173
+ expect(mgr.isRecording("any-session")).toBe(true);
174
+ mgr.closeAll();
175
+ });
176
+
177
+ it("respects globalEnabled: true", () => {
178
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
179
+ expect(mgr.isGloballyEnabled()).toBe(true);
180
+ expect(mgr.isRecording("any-session")).toBe(true);
181
+ mgr.closeAll();
182
+ });
183
+
184
+ it("does not record when disabled globally and per-session", () => {
185
+ const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
186
+ expect(mgr.isRecording("sess-1")).toBe(false);
187
+
188
+ mgr.record("sess-1", "in", "test", "cli", "claude", "/cwd");
189
+
190
+ const files = readDirSafe(tempDir);
191
+ expect(files.length).toBe(0);
192
+ });
193
+
194
+ it("supports per-session enable/disable", () => {
195
+ const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
196
+
197
+ expect(mgr.isRecording("sess-1")).toBe(false);
198
+
199
+ mgr.enableForSession("sess-1");
200
+ expect(mgr.isRecording("sess-1")).toBe(true);
201
+ expect(mgr.isRecording("sess-2")).toBe(false);
202
+
203
+ mgr.disableForSession("sess-1");
204
+ expect(mgr.isRecording("sess-1")).toBe(false);
205
+ });
206
+
207
+ it("lazily creates a recorder on first record() call", () => {
208
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
209
+
210
+ expect(readDirSafe(tempDir).length).toBe(0);
211
+
212
+ mgr.record("sess-1", "in", "first-msg", "cli", "claude", "/cwd");
213
+
214
+ const files = readDirSafe(tempDir);
215
+ expect(files.length).toBe(1);
216
+ expect(files[0]).toMatch(/^sess-1_claude_.*\.jsonl$/);
217
+ mgr.closeAll();
218
+ });
219
+
220
+ it("creates separate files for concurrent sessions", () => {
221
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
222
+
223
+ mgr.record("sess-a", "in", "msg-a", "cli", "claude", "/cwd");
224
+ mgr.record("sess-b", "in", "msg-b", "cli", "codex", "/cwd");
225
+
226
+ const files = readDirSafe(tempDir);
227
+ expect(files.length).toBe(2);
228
+ expect(files.some((f) => f.includes("sess-a"))).toBe(true);
229
+ expect(files.some((f) => f.includes("sess-b"))).toBe(true);
230
+ mgr.closeAll();
231
+ });
232
+
233
+ it("stopRecording closes the recorder and removes it", () => {
234
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
235
+ mgr.record("sess-1", "in", "msg1", "cli", "claude", "/cwd");
236
+
237
+ mgr.stopRecording("sess-1");
238
+
239
+ mgr.record("sess-1", "in", "msg2", "cli", "claude", "/cwd");
240
+
241
+ const files = readDirSafe(tempDir);
242
+ expect(files.length).toBe(2);
243
+ mgr.closeAll();
244
+ });
245
+
246
+ it("getRecordingStatus returns filePath when active", () => {
247
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
248
+ mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
249
+
250
+ const status = mgr.getRecordingStatus("sess-1");
251
+ expect(status.filePath).toBeDefined();
252
+ expect(status.filePath!).toMatch(/sess-1.*\.jsonl$/);
253
+ mgr.closeAll();
254
+ });
255
+
256
+ it("getRecordingStatus returns empty when not active", () => {
257
+ const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
258
+ const status = mgr.getRecordingStatus("sess-1");
259
+ expect(status.filePath).toBeUndefined();
260
+ });
261
+
262
+ it("listRecordings returns correct metadata and line counts", () => {
263
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
264
+ // sess-1: header + 1 entry = 2 lines
265
+ mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
266
+ // sess-2: header + 1 entry = 2 lines
267
+ mgr.record("sess-2", "in", "msg", "cli", "codex", "/cwd");
268
+
269
+ const recordings = mgr.listRecordings();
270
+ expect(recordings.length).toBe(2);
271
+
272
+ const r1 = recordings.find((r) => r.sessionId === "sess-1");
273
+ expect(r1).toBeDefined();
274
+ expect(r1!.backendType).toBe("claude");
275
+ expect(r1!.lines).toBe(2);
276
+
277
+ const r2 = recordings.find((r) => r.sessionId === "sess-2");
278
+ expect(r2).toBeDefined();
279
+ expect(r2!.backendType).toBe("codex");
280
+ expect(r2!.lines).toBe(2);
281
+ mgr.closeAll();
282
+ });
283
+
284
+ it("listRecordings returns empty array when directory does not exist", () => {
285
+ const mgr = new RecorderManager({
286
+ globalEnabled: false,
287
+ recordingsDir: join(tempDir, "nonexistent"),
288
+ });
289
+ expect(mgr.listRecordings()).toEqual([]);
290
+ });
291
+
292
+ it("closeAll closes all active recorders and stops cleanup timer", () => {
293
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
294
+ mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
295
+ mgr.record("sess-2", "in", "msg", "cli", "codex", "/cwd");
296
+
297
+ mgr.closeAll();
298
+
299
+ expect(mgr.getRecordingStatus("sess-1").filePath).toBeUndefined();
300
+ expect(mgr.getRecordingStatus("sess-2").filePath).toBeUndefined();
301
+ });
302
+
303
+ it("disableForSession also stops and closes the recorder", () => {
304
+ const mgr = new RecorderManager({ globalEnabled: false, recordingsDir: tempDir });
305
+ mgr.enableForSession("sess-1");
306
+ mgr.record("sess-1", "in", "msg", "cli", "claude", "/cwd");
307
+
308
+ expect(mgr.getRecordingStatus("sess-1").filePath).toBeDefined();
309
+
310
+ mgr.disableForSession("sess-1");
311
+
312
+ expect(mgr.getRecordingStatus("sess-1").filePath).toBeUndefined();
313
+ });
314
+
315
+ it("disableForSession overrides globalEnabled and prevents new recordings", () => {
316
+ // When globalEnabled is true, disableForSession must still stop recording
317
+ // for that specific session by adding it to the perSessionDisabled set.
318
+ const mgr = new RecorderManager({ globalEnabled: true, recordingsDir: tempDir });
319
+ mgr.record("sess-1", "in", "msg1", "cli", "claude", "/cwd");
320
+
321
+ expect(mgr.isRecording("sess-1")).toBe(true);
322
+
323
+ mgr.disableForSession("sess-1");
324
+
325
+ // Session is no longer recording despite globalEnabled=true
326
+ expect(mgr.isRecording("sess-1")).toBe(false);
327
+
328
+ // New record() calls should be no-ops (no new file created)
329
+ const filesBefore = readDirSafe(tempDir).length;
330
+ mgr.record("sess-1", "in", "msg2", "cli", "claude", "/cwd");
331
+ expect(readDirSafe(tempDir).length).toBe(filesBefore);
332
+
333
+ // Re-enabling should work
334
+ mgr.enableForSession("sess-1");
335
+ expect(mgr.isRecording("sess-1")).toBe(true);
336
+
337
+ mgr.closeAll();
338
+ });
339
+
340
+ it("getMaxLines returns configured limit", () => {
341
+ const mgr = new RecorderManager({
342
+ globalEnabled: false,
343
+ recordingsDir: tempDir,
344
+ maxLines: 42,
345
+ });
346
+ expect(mgr.getMaxLines()).toBe(42);
347
+ });
348
+ });
349
+
350
+ // ─── Cleanup / Rotation ─────────────────────────────────────────────────────
351
+
352
+ describe("cleanup / rotation", () => {
353
+ it("deletes oldest files when total lines exceed maxLines", () => {
354
+ // Create 3 files with 10 entries each (= 11 lines each including header, 33 total)
355
+ // Use different mtimes so we control which is "oldest"
356
+ const now = Date.now();
357
+ createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
358
+ createFakeRecording(tempDir, "mid_claude_2025-01-02.jsonl", 10, new Date(now - 2000));
359
+ createFakeRecording(tempDir, "new_claude_2025-01-03.jsonl", 10, new Date(now - 1000));
360
+
361
+ // maxLines = 20 → total 33 lines exceeds limit → should delete oldest first
362
+ const mgr = new RecorderManager({
363
+ globalEnabled: false, // don't start auto-cleanup timer
364
+ recordingsDir: tempDir,
365
+ maxLines: 20,
366
+ });
367
+
368
+ const deleted = mgr.cleanup();
369
+
370
+ // Should have deleted at least the oldest file (11 lines), bringing total to 22,
371
+ // still > 20, so the mid file (11 lines) gets deleted too → total 11 lines
372
+ expect(deleted).toBe(2);
373
+
374
+ const remaining = readDirSafe(tempDir);
375
+ expect(remaining.length).toBe(1);
376
+ expect(remaining[0]).toContain("new_claude");
377
+ });
378
+
379
+ it("does not delete files from active recording sessions", () => {
380
+ // Create an old file that would normally be deleted
381
+ const now = Date.now();
382
+ createFakeRecording(tempDir, "stale_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
383
+
384
+ // Start an active recording — this file's path will be in the active set
385
+ const mgr = new RecorderManager({
386
+ globalEnabled: true,
387
+ recordingsDir: tempDir,
388
+ maxLines: 5, // Very low limit to force cleanup
389
+ });
390
+ mgr.record("active-sess", "in", "msg", "cli", "claude", "/cwd");
391
+
392
+ // Now cleanup should delete the stale file but NOT the active recording's file
393
+ const deleted = mgr.cleanup();
394
+
395
+ // stale file deleted
396
+ expect(existsSync(join(tempDir, "stale_claude_2025-01-01.jsonl"))).toBe(false);
397
+
398
+ // active session's file should still exist
399
+ const status = mgr.getRecordingStatus("active-sess");
400
+ expect(status.filePath).toBeDefined();
401
+ expect(existsSync(status.filePath!)).toBe(true);
402
+
403
+ mgr.closeAll();
404
+ });
405
+
406
+ it("is a no-op when total lines are under the limit", () => {
407
+ // 2 files × 3 entries = 2 × 4 lines = 8 total, well under 100
408
+ createFakeRecording(tempDir, "a_claude_2025-01-01.jsonl", 3);
409
+ createFakeRecording(tempDir, "b_claude_2025-01-02.jsonl", 3);
410
+
411
+ const mgr = new RecorderManager({
412
+ globalEnabled: false,
413
+ recordingsDir: tempDir,
414
+ maxLines: 100,
415
+ });
416
+
417
+ const deleted = mgr.cleanup();
418
+ expect(deleted).toBe(0);
419
+
420
+ expect(readDirSafe(tempDir).length).toBe(2);
421
+ });
422
+
423
+ it("handles empty recordings directory gracefully", () => {
424
+ const mgr = new RecorderManager({
425
+ globalEnabled: false,
426
+ recordingsDir: tempDir,
427
+ maxLines: 10,
428
+ });
429
+
430
+ const deleted = mgr.cleanup();
431
+ expect(deleted).toBe(0);
432
+ });
433
+
434
+ it("runs cleanup at construction when globally enabled", () => {
435
+ // Pre-fill the directory over the limit
436
+ const now = Date.now();
437
+ createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 20, new Date(now - 2000));
438
+ createFakeRecording(tempDir, "new_claude_2025-01-02.jsonl", 5, new Date(now - 1000));
439
+
440
+ // Total = 21 + 6 = 27 lines, maxLines = 10
441
+ // Constructor should run cleanup immediately, deleting the old file
442
+ const mgr = new RecorderManager({
443
+ globalEnabled: true,
444
+ recordingsDir: tempDir,
445
+ maxLines: 10,
446
+ });
447
+
448
+ const remaining = readDirSafe(tempDir);
449
+ expect(remaining.length).toBe(1);
450
+ expect(remaining[0]).toContain("new_claude");
451
+
452
+ mgr.closeAll();
453
+ });
454
+ });