@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,262 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ // Mock COMPANION_HOME before importing hub-store
8
+ const TEST_HOME = join(tmpdir(), `hub-test-${randomBytes(4).toString("hex")}`);
9
+ vi.mock("../paths.js", () => ({ COMPANION_HOME: TEST_HOME }));
10
+
11
+ // Must import after mock
12
+ const { HubStore } = await import("./hub-store.js");
13
+
14
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
15
+
16
+ function makeRecordingContent(options?: {
17
+ sessionId?: string;
18
+ backendType?: string;
19
+ entries?: number;
20
+ }): string {
21
+ const sessionId = options?.sessionId ?? "test-session";
22
+ const backendType = options?.backendType ?? "claude";
23
+ const entryCount = options?.entries ?? 3;
24
+
25
+ const header = JSON.stringify({
26
+ _header: true,
27
+ version: 1,
28
+ session_id: sessionId,
29
+ backend_type: backendType,
30
+ started_at: 1000000,
31
+ cwd: "/test/dir",
32
+ });
33
+
34
+ const entries: string[] = [];
35
+ for (let i = 0; i < entryCount; i++) {
36
+ entries.push(
37
+ JSON.stringify({
38
+ ts: 1000000 + i * 1000,
39
+ dir: "out",
40
+ raw: JSON.stringify({ type: i === 0 ? "session_init" : "assistant", session: {} }),
41
+ ch: "browser",
42
+ }),
43
+ );
44
+ }
45
+
46
+ return [header, ...entries].join("\n");
47
+ }
48
+
49
+ // ─── Tests ───────────────────────────────────────────────────────────────────
50
+
51
+ describe("HubStore", () => {
52
+ beforeEach(() => {
53
+ // Ensure clean test directory
54
+ if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true });
55
+ mkdirSync(join(TEST_HOME, "hub", "recordings"), { recursive: true });
56
+ });
57
+
58
+ afterEach(() => {
59
+ if (existsSync(TEST_HOME)) rmSync(TEST_HOME, { recursive: true });
60
+ });
61
+
62
+ describe("importContent", () => {
63
+ it("imports valid JSONL content and returns metadata", () => {
64
+ const store = new HubStore();
65
+ const content = makeRecordingContent();
66
+ const meta = store.importContent(content, "test.jsonl");
67
+
68
+ expect(meta.id).toBeTruthy();
69
+ expect(meta.sessionId).toBe("test-session");
70
+ expect(meta.backendType).toBe("claude");
71
+ expect(meta.entryCount).toBe(3);
72
+ expect(meta.filename).toBe("test.jsonl");
73
+ expect(meta.tags).toEqual([]);
74
+ });
75
+
76
+ it("rejects empty content", () => {
77
+ const store = new HubStore();
78
+ expect(() => store.importContent("")).toThrow("empty");
79
+ });
80
+
81
+ it("rejects invalid header", () => {
82
+ const store = new HubStore();
83
+ expect(() => store.importContent('{"version": 2}')).toThrow("Invalid recording header");
84
+ });
85
+
86
+ it("rejects invalid backend_type", () => {
87
+ const store = new HubStore();
88
+ const header = JSON.stringify({
89
+ _header: true,
90
+ version: 1,
91
+ session_id: "s",
92
+ backend_type: "invalid",
93
+ started_at: 0,
94
+ cwd: "/",
95
+ });
96
+ expect(() => store.importContent(header)).toThrow("Invalid backend_type");
97
+ });
98
+
99
+ it("rejects malformed entry JSON", () => {
100
+ const store = new HubStore();
101
+ const header = JSON.stringify({
102
+ _header: true,
103
+ version: 1,
104
+ session_id: "s",
105
+ backend_type: "claude",
106
+ started_at: 0,
107
+ cwd: "/",
108
+ });
109
+ expect(() => store.importContent(header + "\n{not json}")).toThrow("Malformed JSON");
110
+ });
111
+ });
112
+
113
+ describe("list", () => {
114
+ it("returns empty array when no recordings", () => {
115
+ const store = new HubStore();
116
+ expect(store.list()).toEqual([]);
117
+ });
118
+
119
+ it("returns all imported recordings", () => {
120
+ const store = new HubStore();
121
+ store.importContent(makeRecordingContent({ sessionId: "s1" }));
122
+ store.importContent(makeRecordingContent({ sessionId: "s2" }));
123
+
124
+ const list = store.list();
125
+ expect(list).toHaveLength(2);
126
+ const sessionIds = list.map((m) => m.sessionId);
127
+ expect(sessionIds).toContain("s1");
128
+ expect(sessionIds).toContain("s2");
129
+ });
130
+ });
131
+
132
+ describe("get", () => {
133
+ it("returns null for unknown id", () => {
134
+ const store = new HubStore();
135
+ expect(store.get("nonexistent")).toBeNull();
136
+ });
137
+
138
+ it("returns meta for known id", () => {
139
+ const store = new HubStore();
140
+ const meta = store.importContent(makeRecordingContent());
141
+ expect(store.get(meta.id)).toEqual(meta);
142
+ });
143
+ });
144
+
145
+ describe("delete", () => {
146
+ it("returns false for unknown id", () => {
147
+ const store = new HubStore();
148
+ expect(store.delete("nonexistent")).toBe(false);
149
+ });
150
+
151
+ it("removes recording and returns true", () => {
152
+ const store = new HubStore();
153
+ const meta = store.importContent(makeRecordingContent());
154
+ expect(store.delete(meta.id)).toBe(true);
155
+ expect(store.get(meta.id)).toBeNull();
156
+ expect(store.list()).toHaveLength(0);
157
+ });
158
+ });
159
+
160
+ describe("updateTags", () => {
161
+ it("updates tags on existing recording", () => {
162
+ const store = new HubStore();
163
+ const meta = store.importContent(makeRecordingContent());
164
+ const updated = store.updateTags(meta.id, ["regression", "claude-code"]);
165
+ expect(updated?.tags).toEqual(["regression", "claude-code"]);
166
+ });
167
+
168
+ it("returns null for unknown id", () => {
169
+ const store = new HubStore();
170
+ expect(store.updateTags("nonexistent", ["tag"])).toBeNull();
171
+ });
172
+ });
173
+
174
+ describe("loadRecording", () => {
175
+ it("loads full recording content", () => {
176
+ const store = new HubStore();
177
+ const meta = store.importContent(makeRecordingContent({ entries: 5 }));
178
+ const recording = store.loadRecording(meta.id);
179
+ expect(recording).not.toBeNull();
180
+ expect(recording!.header.session_id).toBe("test-session");
181
+ expect(recording!.entries).toHaveLength(5);
182
+ });
183
+
184
+ it("returns null for unknown id", () => {
185
+ const store = new HubStore();
186
+ expect(store.loadRecording("nonexistent")).toBeNull();
187
+ });
188
+ });
189
+
190
+ describe("getSummary", () => {
191
+ it("returns summary with tool names and permission count", () => {
192
+ const store = new HubStore();
193
+ const header = JSON.stringify({
194
+ _header: true,
195
+ version: 1,
196
+ session_id: "s",
197
+ backend_type: "claude",
198
+ started_at: 0,
199
+ cwd: "/",
200
+ });
201
+ const entries = [
202
+ JSON.stringify({
203
+ ts: 100,
204
+ dir: "out",
205
+ raw: JSON.stringify({ type: "permission_request", tool_name: "Bash" }),
206
+ ch: "browser",
207
+ }),
208
+ JSON.stringify({
209
+ ts: 200,
210
+ dir: "out",
211
+ raw: JSON.stringify({ type: "permission_request", tool_name: "Edit" }),
212
+ ch: "browser",
213
+ }),
214
+ JSON.stringify({
215
+ ts: 300,
216
+ dir: "out",
217
+ raw: JSON.stringify({ type: "assistant", text: "hello" }),
218
+ ch: "browser",
219
+ }),
220
+ ];
221
+ const content = [header, ...entries].join("\n");
222
+ const meta = store.importContent(content);
223
+ const summary = store.getSummary(meta.id);
224
+
225
+ expect(summary).not.toBeNull();
226
+ expect(summary!.toolNames).toContain("Bash");
227
+ expect(summary!.toolNames).toContain("Edit");
228
+ expect(summary!.permissionCount).toBe(2);
229
+ });
230
+ });
231
+
232
+ describe("importLocal", () => {
233
+ it("copies a recording file from the auto-recordings directory", () => {
234
+ const store = new HubStore();
235
+ // Create a source file in a temp location
236
+ const sourceDir = join(TEST_HOME, "recordings");
237
+ mkdirSync(sourceDir, { recursive: true });
238
+ const sourcePath = join(sourceDir, "source.jsonl");
239
+ writeFileSync(sourcePath, makeRecordingContent());
240
+
241
+ const meta = store.importLocal(sourcePath);
242
+ expect(meta.sessionId).toBe("test-session");
243
+ // Source file should still exist (copy, not move)
244
+ expect(existsSync(sourcePath)).toBe(true);
245
+ // Hub file should exist
246
+ expect(existsSync(store.recordingPath(meta.id))).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe("persistence", () => {
251
+ it("persists index across HubStore instances", () => {
252
+ // Import with first store
253
+ const store1 = new HubStore();
254
+ const meta = store1.importContent(makeRecordingContent());
255
+
256
+ // Load with second store instance
257
+ const store2 = new HubStore();
258
+ expect(store2.get(meta.id)).toBeTruthy();
259
+ expect(store2.get(meta.id)!.sessionId).toBe("test-session");
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Storage and indexing for curated recording files.
3
+ *
4
+ * Recordings uploaded or imported into the hub live in ~/.companion/hub/recordings/
5
+ * (separate from the auto-recording directory to avoid rotation cleanup).
6
+ * An index file (index.json) provides fast listing without re-parsing JSONL.
7
+ */
8
+
9
+ import {
10
+ mkdirSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ copyFileSync,
14
+ unlinkSync,
15
+ existsSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+ import { COMPANION_HOME } from "../paths.js";
21
+ import { loadRecording } from "../replay.js";
22
+ import type { RecordingHeader, RecordingEntry } from "../recorder.js";
23
+ import type { BackendType } from "../session-types.js";
24
+ import { getMaxUploadBytes } from "./hub-config.js";
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface HubRecordingMeta {
29
+ id: string;
30
+ filename: string;
31
+ sessionId: string;
32
+ backendType: BackendType;
33
+ startedAt: number;
34
+ duration: number;
35
+ entryCount: number;
36
+ cwd: string;
37
+ tags: string[];
38
+ importedAt: number;
39
+ messageTypeSummary: Record<string, number>;
40
+ }
41
+
42
+ export interface HubRecordingSummary extends HubRecordingMeta {
43
+ toolNames: string[];
44
+ permissionCount: number;
45
+ }
46
+
47
+ // ─── HubStore ────────────────────────────────────────────────────────────────
48
+
49
+ const HUB_DIR = join(COMPANION_HOME, "hub");
50
+ const RECORDINGS_DIR = join(HUB_DIR, "recordings");
51
+ const INDEX_PATH = join(HUB_DIR, "index.json");
52
+
53
+ export class HubStore {
54
+ private index: Map<string, HubRecordingMeta> = new Map();
55
+ private dirCreated = false;
56
+
57
+ constructor() {
58
+ this.ensureDir();
59
+ this.loadIndex();
60
+ }
61
+
62
+ // ── Public API ──────────────────────────────────────────────────────────
63
+
64
+ /** Import a recording from the auto-recordings directory by copying it. */
65
+ importLocal(sourcePath: string): HubRecordingMeta {
66
+ this.validateFileSize(sourcePath);
67
+ const recording = loadRecording(sourcePath);
68
+ const id = randomUUID();
69
+ const destFilename = `${id}.jsonl`;
70
+ const destPath = join(RECORDINGS_DIR, destFilename);
71
+ copyFileSync(sourcePath, destPath);
72
+ const meta = this.buildMeta(id, destFilename, recording.header, recording.entries);
73
+ this.index.set(id, meta);
74
+ this.saveIndex();
75
+ return meta;
76
+ }
77
+
78
+ /** Import from raw JSONL content (e.g. from an upload). */
79
+ importContent(content: string, originalFilename?: string): HubRecordingMeta {
80
+ const sizeBytes = Buffer.byteLength(content, "utf-8");
81
+ if (sizeBytes > getMaxUploadBytes()) {
82
+ throw new Error(`File too large: ${Math.round(sizeBytes / 1024 / 1024)}MB exceeds limit`);
83
+ }
84
+
85
+ // Validate by parsing
86
+ const lines = content.split("\n").filter((l) => l.trim());
87
+ if (lines.length === 0) throw new Error("Recording file is empty");
88
+
89
+ const header = JSON.parse(lines[0]) as RecordingHeader;
90
+ if (!header._header || header.version !== 1) {
91
+ throw new Error("Invalid recording header: missing _header or version !== 1");
92
+ }
93
+ if (header.backend_type !== "claude" && header.backend_type !== "codex") {
94
+ throw new Error(`Invalid backend_type: ${header.backend_type}`);
95
+ }
96
+
97
+ // Spot-check entries
98
+ const entries: RecordingEntry[] = [];
99
+ for (let i = 1; i < lines.length; i++) {
100
+ try {
101
+ const entry = JSON.parse(lines[i]) as RecordingEntry;
102
+ if (typeof entry.ts !== "number" || !entry.dir || typeof entry.raw !== "string" || !entry.ch) {
103
+ throw new Error(`Malformed entry at line ${i + 1}`);
104
+ }
105
+ entries.push(entry);
106
+ } catch (err) {
107
+ if (err instanceof SyntaxError) {
108
+ throw new Error(`Malformed JSON at line ${i + 1}`);
109
+ }
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ const id = randomUUID();
115
+ const destFilename = `${id}.jsonl`;
116
+ const destPath = join(RECORDINGS_DIR, destFilename);
117
+ writeFileSync(destPath, content, "utf-8");
118
+
119
+ const meta = this.buildMeta(id, originalFilename || destFilename, header, entries);
120
+ this.index.set(id, meta);
121
+ this.saveIndex();
122
+ return meta;
123
+ }
124
+
125
+ list(): HubRecordingMeta[] {
126
+ return Array.from(this.index.values()).sort((a, b) => b.importedAt - a.importedAt);
127
+ }
128
+
129
+ get(id: string): HubRecordingMeta | null {
130
+ return this.index.get(id) ?? null;
131
+ }
132
+
133
+ /** Load the full recording content from disk. */
134
+ loadRecording(id: string) {
135
+ const meta = this.index.get(id);
136
+ if (!meta) return null;
137
+ const filePath = this.recordingPath(id);
138
+ if (!existsSync(filePath)) return null;
139
+ return loadRecording(filePath);
140
+ }
141
+
142
+ /** Get the file path for a recording. */
143
+ recordingPath(id: string): string {
144
+ return join(RECORDINGS_DIR, `${id}.jsonl`);
145
+ }
146
+
147
+ delete(id: string): boolean {
148
+ const meta = this.index.get(id);
149
+ if (!meta) return false;
150
+ const filePath = this.recordingPath(id);
151
+ try {
152
+ unlinkSync(filePath);
153
+ } catch {
154
+ // File may already be gone
155
+ }
156
+ this.index.delete(id);
157
+ this.saveIndex();
158
+ return true;
159
+ }
160
+
161
+ updateTags(id: string, tags: string[]): HubRecordingMeta | null {
162
+ const meta = this.index.get(id);
163
+ if (!meta) return null;
164
+ meta.tags = tags;
165
+ this.saveIndex();
166
+ return meta;
167
+ }
168
+
169
+ /** Get a summary with tool names and permission count. */
170
+ getSummary(id: string): HubRecordingSummary | null {
171
+ const recording = this.loadRecording(id);
172
+ if (!recording) return null;
173
+ const meta = this.index.get(id);
174
+ if (!meta) return null;
175
+
176
+ const toolNames = new Set<string>();
177
+ let permissionCount = 0;
178
+
179
+ for (const entry of recording.entries) {
180
+ if (entry.dir !== "out" || entry.ch !== "browser") continue;
181
+ try {
182
+ const msg = JSON.parse(entry.raw);
183
+ if (msg.type === "permission_request" && msg.tool_name) {
184
+ toolNames.add(msg.tool_name);
185
+ permissionCount++;
186
+ }
187
+ } catch {
188
+ // Skip unparseable
189
+ }
190
+ }
191
+
192
+ return { ...meta, toolNames: Array.from(toolNames), permissionCount };
193
+ }
194
+
195
+ // ── Private helpers ─────────────────────────────────────────────────────
196
+
197
+ private buildMeta(
198
+ id: string,
199
+ filename: string,
200
+ header: RecordingHeader,
201
+ entries: RecordingEntry[],
202
+ ): HubRecordingMeta {
203
+ const typeSummary: Record<string, number> = {};
204
+ for (const entry of entries) {
205
+ if (entry.dir !== "out" || entry.ch !== "browser") continue;
206
+ try {
207
+ const msg = JSON.parse(entry.raw);
208
+ const type = msg.type || "unknown";
209
+ typeSummary[type] = (typeSummary[type] || 0) + 1;
210
+ } catch {
211
+ // Skip
212
+ }
213
+ }
214
+
215
+ const firstTs = entries[0]?.ts ?? header.started_at;
216
+ const lastTs = entries[entries.length - 1]?.ts ?? firstTs;
217
+
218
+ return {
219
+ id,
220
+ filename,
221
+ sessionId: header.session_id,
222
+ backendType: header.backend_type,
223
+ startedAt: header.started_at,
224
+ duration: lastTs - firstTs,
225
+ entryCount: entries.length,
226
+ cwd: header.cwd,
227
+ tags: [],
228
+ importedAt: Date.now(),
229
+ messageTypeSummary: typeSummary,
230
+ };
231
+ }
232
+
233
+ private validateFileSize(path: string): void {
234
+ const stat = statSync(path);
235
+ if (stat.size > getMaxUploadBytes()) {
236
+ throw new Error(`File too large: ${Math.round(stat.size / 1024 / 1024)}MB exceeds limit`);
237
+ }
238
+ }
239
+
240
+ private ensureDir(): void {
241
+ if (this.dirCreated) return;
242
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
243
+ this.dirCreated = true;
244
+ }
245
+
246
+ private loadIndex(): void {
247
+ try {
248
+ if (existsSync(INDEX_PATH)) {
249
+ const raw = readFileSync(INDEX_PATH, "utf-8");
250
+ const entries = JSON.parse(raw) as HubRecordingMeta[];
251
+ for (const entry of entries) {
252
+ this.index.set(entry.id, entry);
253
+ }
254
+ }
255
+ } catch {
256
+ // Start fresh if index is corrupted
257
+ this.index.clear();
258
+ }
259
+ }
260
+
261
+ private saveIndex(): void {
262
+ const entries = Array.from(this.index.values());
263
+ writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2), "utf-8");
264
+ }
265
+ }