@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,186 @@
1
+ /**
2
+ * PATH discovery and binary resolution for service environments.
3
+ *
4
+ * When The Companion runs as a macOS launchd or Linux systemd service, it inherits
5
+ * a restricted PATH that omits directories from version managers (nvm, fnm, volta,
6
+ * mise, etc.) and user-local installs (~/.local/bin, ~/.cargo/bin). This module
7
+ * captures the user's real shell PATH at runtime and provides binary resolution
8
+ * that works regardless of how the server was started.
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ /**
17
+ * Capture the user's full interactive shell PATH by spawning a login shell.
18
+ * This picks up all version manager initializations (nvm, fnm, volta, mise, etc.).
19
+ * Falls back to probing common directories if shell sourcing fails.
20
+ */
21
+ export function captureUserShellPath(): string {
22
+ try {
23
+ const shell = process.env.SHELL || "/bin/bash";
24
+ const captured = execSync(
25
+ `${shell} -lic 'echo "___PATH_START___$PATH___PATH_END___"'`,
26
+ {
27
+ encoding: "utf-8",
28
+ timeout: 10_000,
29
+ env: { HOME: homedir(), USER: process.env.USER, SHELL: shell },
30
+ },
31
+ );
32
+ const match = captured.match(/___PATH_START___(.+)___PATH_END___/);
33
+ if (match?.[1]) {
34
+ return match[1];
35
+ }
36
+ } catch {
37
+ // Shell sourcing failed (timeout, compinit prompt, etc.)
38
+ }
39
+
40
+ return buildFallbackPath();
41
+ }
42
+
43
+ /**
44
+ * Build a PATH by probing common binary installation directories.
45
+ * Used as fallback when shell-sourcing fails.
46
+ */
47
+ export function buildFallbackPath(): string {
48
+ const home = homedir();
49
+ const candidates = [
50
+ // Standard system paths
51
+ "/opt/homebrew/bin",
52
+ "/opt/homebrew/sbin",
53
+ "/usr/local/bin",
54
+ "/usr/bin",
55
+ "/bin",
56
+ "/usr/sbin",
57
+ "/sbin",
58
+ // Bun
59
+ join(home, ".bun", "bin"),
60
+ // Claude CLI / user-local installs
61
+ join(home, ".local", "bin"),
62
+ // Cargo / Rust
63
+ join(home, ".cargo", "bin"),
64
+ // Volta (Node version manager)
65
+ join(home, ".volta", "bin"),
66
+ // mise (formerly rtx)
67
+ join(home, ".local", "share", "mise", "shims"),
68
+ // pyenv
69
+ join(home, ".pyenv", "bin"),
70
+ join(home, ".pyenv", "shims"),
71
+ // Go
72
+ join(home, "go", "bin"),
73
+ "/usr/local/go/bin",
74
+ // Deno
75
+ join(home, ".deno", "bin"),
76
+ ];
77
+
78
+ // Probe nvm-managed node versions
79
+ const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
80
+ const nvmVersionsDir = join(nvmDir, "versions", "node");
81
+ if (existsSync(nvmVersionsDir)) {
82
+ try {
83
+ for (const v of readdirSync(nvmVersionsDir)) {
84
+ candidates.push(join(nvmVersionsDir, v, "bin"));
85
+ }
86
+ } catch { /* ignore */ }
87
+ }
88
+
89
+ // fnm (Fast Node Manager) — versions stored in fnm multishell or XDG data
90
+ const fnmDir = join(home, "Library", "Application Support", "fnm", "node-versions");
91
+ if (existsSync(fnmDir)) {
92
+ try {
93
+ for (const v of readdirSync(fnmDir)) {
94
+ candidates.push(join(fnmDir, v, "installation", "bin"));
95
+ }
96
+ } catch { /* ignore */ }
97
+ }
98
+
99
+ const pathSep = process.platform === "win32" ? ";" : ":";
100
+ return [...new Set(candidates.filter((dir) => existsSync(dir)))].join(pathSep);
101
+ }
102
+
103
+ // ─── Enriched PATH (cached) ───────────────────────────────────────────────────
104
+
105
+ let _cachedPath: string | null = null;
106
+
107
+ /**
108
+ * Returns an enriched PATH that merges the user's shell PATH (or probed common
109
+ * directories) with the current process PATH. Deduplicates entries.
110
+ * Result is cached after the first call.
111
+ */
112
+ export function getEnrichedPath(): string {
113
+ if (_cachedPath) return _cachedPath;
114
+
115
+ const currentPath = process.env.PATH || "";
116
+ const userPath = captureUserShellPath();
117
+ const pathSep = process.platform === "win32" ? ";" : ":";
118
+
119
+ // Merge: user shell PATH first (takes precedence), then current process PATH
120
+ const allDirs = [...userPath.split(pathSep), ...currentPath.split(pathSep)];
121
+ const seen = new Set<string>();
122
+ const deduped: string[] = [];
123
+ for (const dir of allDirs) {
124
+ if (dir && !seen.has(dir)) {
125
+ seen.add(dir);
126
+ deduped.push(dir);
127
+ }
128
+ }
129
+
130
+ _cachedPath = deduped.join(pathSep);
131
+ return _cachedPath;
132
+ }
133
+
134
+ /** Reset the cached PATH (for testing). */
135
+ export function _resetPathCache(): void {
136
+ _cachedPath = null;
137
+ }
138
+
139
+ // ─── Binary resolution ────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Resolve a binary name to an absolute path using the enriched PATH.
143
+ * Returns null if the binary is not found anywhere.
144
+ */
145
+ export function resolveBinary(name: string): string | null {
146
+ if (name.startsWith("/")) {
147
+ return existsSync(name) ? name : null;
148
+ }
149
+ // On Windows, also accept absolute paths like C:\... or D:\...
150
+ if (process.platform === "win32" && /^[a-zA-Z]:[/\\]/.test(name)) {
151
+ return existsSync(name) ? name : null;
152
+ }
153
+
154
+ const sanitized = name.replace(/[^a-zA-Z0-9._@/-]/g, "");
155
+ const enrichedPath = getEnrichedPath();
156
+
157
+ // Try `where` first on Windows (returns native Win32 paths), then `which` as fallback
158
+ const commands = process.platform === "win32" ? ["where", "which"] : ["which"];
159
+ for (const cmd of commands) {
160
+ try {
161
+ const result = execSync(`${cmd} ${sanitized}`, {
162
+ encoding: "utf-8",
163
+ timeout: 5_000,
164
+ env: { ...process.env, PATH: enrichedPath },
165
+ }).trim();
166
+ if (!result) continue;
167
+ // `where` on Windows may return multiple lines; prefer .cmd for Bun.spawn compatibility
168
+ if (cmd === "where") {
169
+ const lines = result.split(/\r?\n/).filter(Boolean);
170
+ return lines.find(l => l.endsWith(".cmd")) || lines[0];
171
+ }
172
+ return result;
173
+ } catch {
174
+ continue;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Returns a PATH string suitable for embedding in service definitions
182
+ * (plist/systemd unit). Captures the user's shell PATH at install time.
183
+ */
184
+ export function getServicePath(): string {
185
+ return getEnrichedPath();
186
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ describe("paths", () => {
6
+ const originalEnv = process.env.COMPANION_HOME;
7
+
8
+ afterEach(() => {
9
+ // Restore original env
10
+ if (originalEnv === undefined) {
11
+ delete process.env.COMPANION_HOME;
12
+ } else {
13
+ process.env.COMPANION_HOME = originalEnv;
14
+ }
15
+ });
16
+
17
+ it("defaults to ~/.companion/ when COMPANION_HOME is not set", async () => {
18
+ delete process.env.COMPANION_HOME;
19
+ // Dynamic import to pick up env change (module is already cached, so we
20
+ // test the value computed at import time — which uses the env at startup)
21
+ const { COMPANION_HOME } = await import("./paths.js");
22
+ // When env var is unset at module load time, it should be ~/.companion
23
+ expect(COMPANION_HOME).toBe(join(homedir(), ".companion"));
24
+ });
25
+
26
+ it("exports a string path", async () => {
27
+ const { COMPANION_HOME } = await import("./paths.js");
28
+ expect(typeof COMPANION_HOME).toBe("string");
29
+ expect(COMPANION_HOME.length).toBeGreaterThan(0);
30
+ });
31
+ });
@@ -0,0 +1,11 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ /**
5
+ * Base directory for all Companion configuration and state.
6
+ * Defaults to ~/.companion/ for self-hosted installs.
7
+ * Override with COMPANION_HOME env var for managed deployments
8
+ * (e.g. COMPANION_HOME=/data/companion on Fly.io volumes).
9
+ */
10
+ export const COMPANION_HOME =
11
+ process.env.COMPANION_HOME || join(homedir(), ".companion");
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // ─── Mocks ──────────────────────────────────────────────────────────────────
4
+
5
+ const mockFetchPRInfoAsync = vi.hoisted(() => vi.fn());
6
+ const mockComputeAdaptiveTTL = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("./github-pr.js", () => ({
9
+ fetchPRInfoAsync: mockFetchPRInfoAsync,
10
+ computeAdaptiveTTL: mockComputeAdaptiveTTL,
11
+ }));
12
+
13
+ import { PRPoller } from "./pr-poller.js";
14
+ import type { GitHubPRInfo } from "./github-pr.js";
15
+
16
+ function makeMockBridge() {
17
+ return {
18
+ broadcastToSession: vi.fn(),
19
+ } as any;
20
+ }
21
+
22
+ function makePR(overrides?: Partial<GitHubPRInfo>): GitHubPRInfo {
23
+ return {
24
+ number: 42,
25
+ title: "test pr",
26
+ url: "https://github.com/org/repo/pull/42",
27
+ state: "OPEN",
28
+ isDraft: false,
29
+ reviewDecision: null,
30
+ additions: 10,
31
+ deletions: 5,
32
+ changedFiles: 2,
33
+ checks: [],
34
+ checksSummary: { total: 0, success: 0, failure: 0, pending: 0 },
35
+ reviewThreads: { total: 0, resolved: 0, unresolved: 0 },
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ /** Flush microtasks so async callbacks in the poller can settle. */
41
+ async function flushMicrotasks() {
42
+ await new Promise((r) => setTimeout(r, 0));
43
+ }
44
+
45
+ // ─── Tests ──────────────────────────────────────────────────────────────────
46
+
47
+ describe("PRPoller", () => {
48
+ let poller: PRPoller;
49
+ let bridge: ReturnType<typeof makeMockBridge>;
50
+
51
+ beforeEach(() => {
52
+ mockFetchPRInfoAsync.mockReset();
53
+ mockComputeAdaptiveTTL.mockReset();
54
+ mockComputeAdaptiveTTL.mockReturnValue(30_000);
55
+ bridge = makeMockBridge();
56
+ poller = new PRPoller(bridge);
57
+ });
58
+
59
+ afterEach(() => {
60
+ poller.destroy();
61
+ });
62
+
63
+ it("returns null on initial watch (no cached data)", () => {
64
+ mockFetchPRInfoAsync.mockResolvedValue(null);
65
+ const result = poller.watch("s1", "/repo", "main");
66
+ expect(result).toBeNull();
67
+ });
68
+
69
+ it("fetches and broadcasts PR data after watch", async () => {
70
+ const pr = makePR();
71
+ mockFetchPRInfoAsync.mockResolvedValue(pr);
72
+
73
+ poller.watch("s1", "/repo", "feat/test");
74
+
75
+ // Let the async fetch settle
76
+ await flushMicrotasks();
77
+
78
+ expect(mockFetchPRInfoAsync).toHaveBeenCalledWith("/repo", "feat/test");
79
+ expect(bridge.broadcastToSession).toHaveBeenCalledWith("s1", {
80
+ type: "pr_status_update",
81
+ pr,
82
+ available: true,
83
+ });
84
+ });
85
+
86
+ it("shares one timer across multiple sessions watching the same branch", async () => {
87
+ const pr = makePR();
88
+ mockFetchPRInfoAsync.mockResolvedValue(pr);
89
+
90
+ poller.watch("s1", "/repo", "main");
91
+ poller.watch("s2", "/repo", "main");
92
+
93
+ await flushMicrotasks();
94
+
95
+ // Should only have fetched once (shared timer)
96
+ expect(mockFetchPRInfoAsync).toHaveBeenCalledTimes(1);
97
+ // But should broadcast to both sessions
98
+ expect(bridge.broadcastToSession).toHaveBeenCalledWith("s1", expect.objectContaining({ type: "pr_status_update" }));
99
+ expect(bridge.broadcastToSession).toHaveBeenCalledWith("s2", expect.objectContaining({ type: "pr_status_update" }));
100
+ });
101
+
102
+ it("returns cached data on subsequent watch calls", async () => {
103
+ const pr = makePR();
104
+ mockFetchPRInfoAsync.mockResolvedValue(pr);
105
+
106
+ poller.watch("s1", "/repo", "main");
107
+ await flushMicrotasks();
108
+
109
+ // Second session watches the same branch — should get cached data
110
+ const cached = poller.watch("s2", "/repo", "main");
111
+ expect(cached).toEqual(pr);
112
+ });
113
+
114
+ it("cleans up when last session unwatches", async () => {
115
+ mockFetchPRInfoAsync.mockResolvedValue(makePR());
116
+
117
+ poller.watch("s1", "/repo", "main");
118
+ poller.watch("s2", "/repo", "main");
119
+ await flushMicrotasks();
120
+
121
+ poller.unwatch("s1");
122
+ // Still one session watching — cache should remain
123
+ expect(poller.getCached("/repo", "main")).not.toBeNull();
124
+
125
+ poller.unwatch("s2");
126
+ // No sessions left — should be cleaned up
127
+ expect(poller.getCached("/repo", "main")).toBeNull();
128
+ });
129
+
130
+ it("getCached returns null for unknown branches", () => {
131
+ expect(poller.getCached("/repo", "nonexistent")).toBeNull();
132
+ });
133
+
134
+ it("uses computeAdaptiveTTL for scheduling", async () => {
135
+ const pr = makePR({ checksSummary: { total: 3, success: 1, failure: 0, pending: 2 } });
136
+ mockFetchPRInfoAsync.mockResolvedValue(pr);
137
+ mockComputeAdaptiveTTL.mockReturnValue(10_000);
138
+
139
+ poller.watch("s1", "/repo", "feat/ci");
140
+ await flushMicrotasks();
141
+
142
+ expect(mockComputeAdaptiveTTL).toHaveBeenCalledWith(pr);
143
+ });
144
+
145
+ it("handles session switching branches (unwatches old, watches new)", async () => {
146
+ mockFetchPRInfoAsync.mockResolvedValue(makePR());
147
+
148
+ poller.watch("s1", "/repo", "branch-a");
149
+ await flushMicrotasks();
150
+
151
+ // Same session now watches a different branch
152
+ poller.watch("s1", "/repo", "branch-b");
153
+ await flushMicrotasks();
154
+
155
+ // Old branch should have been cleaned up (only session was s1)
156
+ expect(poller.getCached("/repo", "branch-a")).toBeNull();
157
+ expect(poller.getCached("/repo", "branch-b")).not.toBeNull();
158
+ });
159
+
160
+ it("handles fetch errors gracefully", async () => {
161
+ mockFetchPRInfoAsync.mockRejectedValue(new Error("network error"));
162
+
163
+ poller.watch("s1", "/repo", "main");
164
+ await flushMicrotasks();
165
+
166
+ // Should not throw, should not broadcast (no data on error)
167
+ expect(bridge.broadcastToSession).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("prevents concurrent fetches for the same key", async () => {
171
+ // Create a fetch that takes time to resolve
172
+ let resolveFirst: (value: GitHubPRInfo) => void;
173
+ mockFetchPRInfoAsync.mockReturnValueOnce(
174
+ new Promise<GitHubPRInfo>((r) => { resolveFirst = r; }),
175
+ );
176
+
177
+ poller.watch("s1", "/repo", "main");
178
+ // Try to trigger another fetch immediately (e.g., from a second session)
179
+ poller.watch("s2", "/repo", "main");
180
+
181
+ // Only one fetch should have started
182
+ expect(mockFetchPRInfoAsync).toHaveBeenCalledTimes(1);
183
+
184
+ // Resolve the first fetch
185
+ resolveFirst!(makePR());
186
+ await flushMicrotasks();
187
+
188
+ // Now broadcast should have gone to both sessions
189
+ expect(bridge.broadcastToSession).toHaveBeenCalledTimes(2);
190
+ });
191
+ });
@@ -0,0 +1,162 @@
1
+ import { fetchPRInfoAsync, computeAdaptiveTTL, type GitHubPRInfo } from "./github-pr.js";
2
+ import type { WsBridge } from "./ws-bridge.js";
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ interface WatchedPR {
7
+ cwd: string;
8
+ branch: string;
9
+ /** Sessions interested in this PR (same cwd:branch may be shared) */
10
+ sessionIds: Set<string>;
11
+ lastData: GitHubPRInfo | null;
12
+ timer: ReturnType<typeof setTimeout> | null;
13
+ lastFetchTime: number;
14
+ currentInterval: number;
15
+ fetching: boolean;
16
+ }
17
+
18
+ // ─── PR Poller ───────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Server-side poller that fetches GitHub PR status at adaptive intervals
22
+ * and pushes updates to browsers via WebSocket.
23
+ *
24
+ * One timer per unique cwd:branch — shared across sessions on the same branch.
25
+ */
26
+ export class PRPoller {
27
+ private watched = new Map<string, WatchedPR>();
28
+ private wsBridge: WsBridge;
29
+ /** Reverse index: sessionId → cwd:branch key (a session can only watch one PR at a time) */
30
+ private sessionToKey = new Map<string, string>();
31
+
32
+ constructor(wsBridge: WsBridge) {
33
+ this.wsBridge = wsBridge;
34
+ }
35
+
36
+ /**
37
+ * Start watching a PR for a session.
38
+ * Returns cached data immediately if available.
39
+ * Triggers an async fetch if cache is stale or missing.
40
+ */
41
+ watch(sessionId: string, cwd: string, branch: string): GitHubPRInfo | null {
42
+ const key = `${cwd}:${branch}`;
43
+
44
+ // If this session was watching a different PR, unregister from the old one
45
+ const prevKey = this.sessionToKey.get(sessionId);
46
+ if (prevKey && prevKey !== key) {
47
+ this.unwatchKey(sessionId, prevKey);
48
+ }
49
+ this.sessionToKey.set(sessionId, key);
50
+
51
+ const existing = this.watched.get(key);
52
+ if (existing) {
53
+ existing.sessionIds.add(sessionId);
54
+ // If cache is stale, trigger a refresh
55
+ if (Date.now() - existing.lastFetchTime > existing.currentInterval) {
56
+ this.fetchAndBroadcast(key);
57
+ }
58
+ return existing.lastData;
59
+ }
60
+
61
+ // New watch — create entry and fetch immediately
62
+ const entry: WatchedPR = {
63
+ cwd,
64
+ branch,
65
+ sessionIds: new Set([sessionId]),
66
+ lastData: null,
67
+ timer: null,
68
+ lastFetchTime: 0,
69
+ currentInterval: 10_000, // start aggressive for fast initial load
70
+ fetching: false,
71
+ };
72
+ this.watched.set(key, entry);
73
+ this.fetchAndBroadcast(key);
74
+
75
+ return null;
76
+ }
77
+
78
+ /** Stop watching for a specific session. */
79
+ unwatch(sessionId: string): void {
80
+ const key = this.sessionToKey.get(sessionId);
81
+ if (!key) return;
82
+ this.sessionToKey.delete(sessionId);
83
+ this.unwatchKey(sessionId, key);
84
+ }
85
+
86
+ /** Get current cached data for a cwd:branch pair (for REST fallback). */
87
+ getCached(cwd: string, branch: string): { available: boolean; pr: GitHubPRInfo | null } | null {
88
+ const key = `${cwd}:${branch}`;
89
+ const entry = this.watched.get(key);
90
+ if (!entry) return null;
91
+ return { available: true, pr: entry.lastData };
92
+ }
93
+
94
+ /** Stop all timers (for testing / cleanup). */
95
+ destroy(): void {
96
+ for (const entry of this.watched.values()) {
97
+ if (entry.timer) clearTimeout(entry.timer);
98
+ }
99
+ this.watched.clear();
100
+ this.sessionToKey.clear();
101
+ }
102
+
103
+ // ─── Internal ──────────────────────────────────────────────────────────
104
+
105
+ private unwatchKey(sessionId: string, key: string): void {
106
+ const entry = this.watched.get(key);
107
+ if (!entry) return;
108
+ entry.sessionIds.delete(sessionId);
109
+ if (entry.sessionIds.size === 0) {
110
+ if (entry.timer) clearTimeout(entry.timer);
111
+ this.watched.delete(key);
112
+ }
113
+ }
114
+
115
+ private async fetchAndBroadcast(key: string): Promise<void> {
116
+ const entry = this.watched.get(key);
117
+ if (!entry) return;
118
+
119
+ // Prevent concurrent fetches for the same key
120
+ if (entry.fetching) return;
121
+ entry.fetching = true;
122
+
123
+ // Clear existing timer (will be rescheduled after fetch)
124
+ if (entry.timer) {
125
+ clearTimeout(entry.timer);
126
+ entry.timer = null;
127
+ }
128
+
129
+ try {
130
+ const prInfo = await fetchPRInfoAsync(entry.cwd, entry.branch);
131
+ // Re-check entry still exists (may have been unwatched during async fetch)
132
+ const current = this.watched.get(key);
133
+ if (!current) return;
134
+
135
+ current.lastData = prInfo;
136
+ current.lastFetchTime = Date.now();
137
+ current.currentInterval = computeAdaptiveTTL(prInfo);
138
+
139
+ // Push to all sessions watching this PR
140
+ for (const sessionId of current.sessionIds) {
141
+ this.wsBridge.broadcastToSession(sessionId, {
142
+ type: "pr_status_update",
143
+ pr: prInfo,
144
+ available: true,
145
+ });
146
+ }
147
+ } catch {
148
+ // On error, use a moderate interval
149
+ const current = this.watched.get(key);
150
+ if (current) {
151
+ current.currentInterval = 30_000;
152
+ }
153
+ } finally {
154
+ const current = this.watched.get(key);
155
+ if (current) {
156
+ current.fetching = false;
157
+ // Schedule next fetch
158
+ current.timer = setTimeout(() => this.fetchAndBroadcast(key), current.currentInterval);
159
+ }
160
+ }
161
+ }
162
+ }