@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,279 @@
1
+ import { join, dirname } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { containerManager, ContainerManager } from "./container-manager.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface ImagePullState {
11
+ image: string;
12
+ status: "idle" | "pulling" | "ready" | "error";
13
+ /** Last N lines of pull/build output (ring buffer) */
14
+ progress: string[];
15
+ error?: string;
16
+ startedAt?: number;
17
+ completedAt?: number;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const MAX_PROGRESS_LINES = 50;
25
+ const WEB_DIR = dirname(fileURLToPath(import.meta.url));
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // ImagePullManager — singleton that tracks background image pulls
29
+ // ---------------------------------------------------------------------------
30
+
31
+ type ReadyListener = () => void;
32
+
33
+ class ImagePullManager {
34
+ private states = new Map<string, ImagePullState>();
35
+ /** Listeners waiting for a specific image to become ready */
36
+ private readyListeners = new Map<string, ReadyListener[]>();
37
+
38
+ /**
39
+ * Get the current state for an image.
40
+ * If the image exists locally and we have no tracking entry, return "ready".
41
+ */
42
+ getState(image: string): ImagePullState {
43
+ const existing = this.states.get(image);
44
+ if (existing) return existing;
45
+
46
+ // Check if already available locally
47
+ const ready = containerManager.imageExists(image);
48
+ return {
49
+ image,
50
+ status: ready ? "ready" : "idle",
51
+ progress: [],
52
+ };
53
+ }
54
+
55
+ /** Quick check: is the image available locally right now? */
56
+ isReady(image: string): boolean {
57
+ return this.getState(image).status === "ready";
58
+ }
59
+
60
+ /**
61
+ * Ensure the image is available. Starts a background pull if missing.
62
+ * No-op if already pulling or ready.
63
+ */
64
+ ensureImage(image: string): void {
65
+ const state = this.getState(image);
66
+ if (state.status === "ready" || state.status === "pulling") return;
67
+ this.startPull(image);
68
+ }
69
+
70
+ /**
71
+ * Wait for an image that is currently pulling to become ready.
72
+ * Resolves true if ready, false if pull failed or timed out.
73
+ * If image is already ready, resolves immediately.
74
+ */
75
+ waitForReady(image: string, timeoutMs = 300_000): Promise<boolean> {
76
+ const state = this.getState(image);
77
+ if (state.status === "ready") return Promise.resolve(true);
78
+ if (state.status === "error") return Promise.resolve(false);
79
+ if (state.status === "idle") {
80
+ // Not pulling yet — start it
81
+ this.startPull(image);
82
+ }
83
+
84
+ return new Promise<boolean>((resolve) => {
85
+ let settled = false;
86
+ const done = (result: boolean) => {
87
+ if (settled) return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ // Clean up the listener to avoid memory leaks
91
+ const arr = this.readyListeners.get(image);
92
+ if (arr) {
93
+ const idx = arr.indexOf(listener);
94
+ if (idx >= 0) arr.splice(idx, 1);
95
+ if (arr.length === 0) this.readyListeners.delete(image);
96
+ }
97
+ resolve(result);
98
+ };
99
+
100
+ const timer = setTimeout(() => done(false), timeoutMs);
101
+
102
+ const listener: ReadyListener = () => {
103
+ const s = this.getState(image);
104
+ if (s.status === "ready") done(true);
105
+ else if (s.status === "error") done(false);
106
+ // else still pulling — keep waiting
107
+ };
108
+
109
+ const listeners = this.readyListeners.get(image) ?? [];
110
+ listeners.push(listener);
111
+ this.readyListeners.set(image, listeners);
112
+
113
+ // Re-check after registering the listener to catch races where
114
+ // the pull completed synchronously before the listener was added.
115
+ const currentState = this.getState(image);
116
+ if (currentState.status === "ready") done(true);
117
+ else if (currentState.status === "error") done(false);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Trigger a pull even if image is already present (for updates).
123
+ */
124
+ pull(image: string): void {
125
+ const state = this.getState(image);
126
+ if (state.status === "pulling") return; // already in progress
127
+ this.startPull(image);
128
+ }
129
+
130
+ /**
131
+ * Subscribe to progress lines for a specific image.
132
+ * Returns an unsubscribe function.
133
+ * The callback fires for each new progress line while pulling.
134
+ */
135
+ onProgress(image: string, cb: (line: string) => void): () => void {
136
+ const key = `progress:${image}`;
137
+ const listeners = (this.progressListeners.get(key) ?? []);
138
+ listeners.push(cb);
139
+ this.progressListeners.set(key, listeners);
140
+ return () => {
141
+ const arr = this.progressListeners.get(key);
142
+ if (arr) {
143
+ const idx = arr.indexOf(cb);
144
+ if (idx >= 0) arr.splice(idx, 1);
145
+ }
146
+ };
147
+ }
148
+ private progressListeners = new Map<string, Array<(line: string) => void>>();
149
+
150
+ /**
151
+ * On server startup, check all environments and pre-pull missing images.
152
+ * Environments no longer carry Docker fields — this is now a no-op stub
153
+ * kept for backwards compatibility with callers.
154
+ */
155
+ initFromEnvironments(): void {
156
+ // Environments no longer have imageTag/baseImage (moved to Sandboxes).
157
+ // Nothing to pre-pull from envs.
158
+ }
159
+
160
+ // ─── Internal ─────────────────────────────────────────────────────────────
161
+
162
+ private startPull(image: string): void {
163
+ const state: ImagePullState = {
164
+ image,
165
+ status: "pulling",
166
+ progress: [],
167
+ startedAt: Date.now(),
168
+ };
169
+ this.states.set(image, state);
170
+
171
+ // Determine if we can pull from registry
172
+ const registryImage = ContainerManager.getRegistryImage(image);
173
+
174
+ if (registryImage) {
175
+ this.doPullFromRegistry(image, registryImage);
176
+ } else {
177
+ // No registry mapping — mark as error since we can't pull custom images
178
+ this.markError(image, `No registry mapping for image "${image}". Build it from a Dockerfile instead.`);
179
+ }
180
+ }
181
+
182
+ private async doPullFromRegistry(localTag: string, registryImage: string): Promise<void> {
183
+ try {
184
+ const pulled = await containerManager.pullImage(registryImage, localTag, (line) => {
185
+ this.appendProgress(localTag, line);
186
+ });
187
+
188
+ if (pulled) {
189
+ this.markReady(localTag);
190
+ } else {
191
+ // Pull failed — try local build for default image
192
+ if (localTag === "the-companion:latest") {
193
+ this.appendProgress(localTag, "Pull failed, falling back to local build...");
194
+ await this.doLocalBuild(localTag);
195
+ } else {
196
+ this.markError(localTag, "Pull failed from registry");
197
+ }
198
+ }
199
+ } catch (e) {
200
+ const reason = e instanceof Error ? e.message : String(e);
201
+ // Try local build fallback for default image
202
+ if (localTag === "the-companion:latest") {
203
+ this.appendProgress(localTag, `Pull error (${reason}), falling back to local build...`);
204
+ await this.doLocalBuild(localTag);
205
+ } else {
206
+ this.markError(localTag, reason);
207
+ }
208
+ }
209
+ }
210
+
211
+ private async doLocalBuild(localTag: string): Promise<void> {
212
+ const dockerfilePath = join(WEB_DIR, "docker", "Dockerfile.the-companion");
213
+ if (!existsSync(dockerfilePath)) {
214
+ this.markError(localTag, `Dockerfile not found at ${dockerfilePath}`);
215
+ return;
216
+ }
217
+
218
+ try {
219
+ this.appendProgress(localTag, `Building ${localTag} from local Dockerfile...`);
220
+ containerManager.buildImage(dockerfilePath, localTag);
221
+ this.markReady(localTag);
222
+ } catch (e) {
223
+ const reason = e instanceof Error ? e.message : String(e);
224
+ this.markError(localTag, `Build failed: ${reason}`);
225
+ }
226
+ }
227
+
228
+ private appendProgress(image: string, line: string): void {
229
+ const state = this.states.get(image);
230
+ if (!state) return;
231
+ state.progress.push(line);
232
+ if (state.progress.length > MAX_PROGRESS_LINES) {
233
+ state.progress.splice(0, state.progress.length - MAX_PROGRESS_LINES);
234
+ }
235
+
236
+ // Notify progress listeners
237
+ const key = `progress:${image}`;
238
+ const listeners = this.progressListeners.get(key);
239
+ if (listeners) {
240
+ for (const cb of listeners) {
241
+ try { cb(line); } catch { /* ignore */ }
242
+ }
243
+ }
244
+ }
245
+
246
+ private markReady(image: string): void {
247
+ const state = this.states.get(image);
248
+ if (state) {
249
+ state.status = "ready";
250
+ state.completedAt = Date.now();
251
+ this.appendProgress(image, "Image ready");
252
+ }
253
+ this.notifyListeners(image);
254
+ }
255
+
256
+ private markError(image: string, error: string): void {
257
+ const state = this.states.get(image);
258
+ if (state) {
259
+ state.status = "error";
260
+ state.error = error;
261
+ state.completedAt = Date.now();
262
+ this.appendProgress(image, `Error: ${error}`);
263
+ }
264
+ this.notifyListeners(image);
265
+ }
266
+
267
+ private notifyListeners(image: string): void {
268
+ const listeners = this.readyListeners.get(image);
269
+ if (listeners) {
270
+ for (const listener of listeners) {
271
+ try { listener(); } catch { /* ignore */ }
272
+ }
273
+ this.readyListeners.delete(image);
274
+ }
275
+ }
276
+ }
277
+
278
+ // Singleton export
279
+ export const imagePullManager = new ImagePullManager();
@@ -0,0 +1,396 @@
1
+ process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
2
+
3
+ // Enrich process PATH at startup so binary resolution and `which` calls can find
4
+ // binaries installed via version managers (nvm, volta, fnm, etc.).
5
+ // Critical when running as a launchd/systemd service with a restricted PATH.
6
+ import { getEnrichedPath } from "./path-resolver.js";
7
+ process.env.PATH = getEnrichedPath();
8
+
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { Hono } from "hono";
12
+ import { cors } from "hono/cors";
13
+ import { serveStatic } from "hono/bun";
14
+ import { cacheControlMiddleware } from "./cache-headers.js";
15
+ import { createRoutes } from "./routes.js";
16
+ import { CliLauncher } from "./cli-launcher.js";
17
+ import { WsBridge } from "./ws-bridge.js";
18
+ import { SessionStore } from "./session-store.js";
19
+ import { WorktreeTracker } from "./worktree-tracker.js";
20
+ import { containerManager } from "./container-manager.js";
21
+ import { join } from "node:path";
22
+ import { COMPANION_HOME } from "./paths.js";
23
+ import { TerminalManager } from "./terminal-manager.js";
24
+ import { PRPoller } from "./pr-poller.js";
25
+ import { RecorderManager } from "./recorder.js";
26
+ import { initLogFile, closeLogFile } from "./logger.js";
27
+ import { CronScheduler } from "./cron-scheduler.js";
28
+ import { AgentExecutor } from "./agent-executor.js";
29
+ import { SessionOrchestrator } from "./session-orchestrator.js";
30
+ import { migrateCronJobsToAgents } from "./agent-cron-migrator.js";
31
+ import { migrateLinearCredentialsToAgents } from "./linear-credential-migration.js";
32
+ import { authenticateManagedWebSocket } from "./ws-auth.js";
33
+ import { LinearAgentBridge } from "./linear-agent-bridge.js";
34
+ import { NoVncProxy } from "./novnc-proxy.js";
35
+
36
+ import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
37
+ import { imagePullManager } from "./image-pull-manager.js";
38
+ import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
39
+ import { isRunningAsService } from "./service.js";
40
+ import { getToken, verifyToken } from "./auth-manager.js";
41
+ import { getCookie } from "hono/cookie";
42
+ import type { SocketData } from "./ws-bridge.js";
43
+ import type { ServerWebSocket } from "bun";
44
+
45
+ const __dirname = dirname(fileURLToPath(import.meta.url));
46
+ const packageRoot = process.env.__COMPANION_PACKAGE_ROOT || resolve(__dirname, "..");
47
+
48
+ import { DEFAULT_PORT_DEV, DEFAULT_PORT_PROD } from "./constants.js";
49
+
50
+ const defaultPort = process.env.NODE_ENV === "production" ? DEFAULT_PORT_PROD : DEFAULT_PORT_DEV;
51
+ const port = Number(process.env.PORT) || defaultPort;
52
+ const host = process.env.HOST || "0.0.0.0";
53
+ const sessionStore = new SessionStore(process.env.COMPANION_SESSION_DIR);
54
+ const wsBridge = new WsBridge();
55
+ const launcher = new CliLauncher(port);
56
+ const worktreeTracker = new WorktreeTracker();
57
+ const CONTAINER_STATE_PATH = join(COMPANION_HOME, "containers.json");
58
+ const terminalManager = new TerminalManager();
59
+ const noVncProxy = new NoVncProxy();
60
+ const prPoller = new PRPoller(wsBridge);
61
+ const recorder = new RecorderManager();
62
+ const cronScheduler = new CronScheduler(launcher, wsBridge);
63
+ const agentExecutor = new AgentExecutor(launcher, wsBridge);
64
+ const linearAgentBridge = new LinearAgentBridge(agentExecutor, wsBridge);
65
+
66
+ const orchestrator = new SessionOrchestrator({
67
+ launcher, wsBridge, sessionStore, worktreeTracker,
68
+ prPoller, agentExecutor,
69
+ });
70
+
71
+ // ── Cloud relay connection (for receiving webhooks behind a firewall) ────────
72
+ // The relay forwards platform webhooks (e.g. GitHub, Slack) to the Companion
73
+ // instance via an outbound WebSocket. Currently no webhook handlers are
74
+ // registered (Chat SDK was removed). The relay is left disabled until handlers
75
+ // are wired up (e.g. LinearAgentBridge or future platform integrations).
76
+ if (process.env.COMPANION_RELAY_URL && process.env.COMPANION_RELAY_SECRET) {
77
+ console.warn(
78
+ "[server] COMPANION_RELAY_URL is set but no relay webhook handlers are registered. " +
79
+ "The relay client will not be started. Remove COMPANION_RELAY_URL/COMPANION_RELAY_SECRET " +
80
+ "or wire up webhook handlers to use relay mode.",
81
+ );
82
+ }
83
+
84
+ // ── Restore persisted sessions from disk ────────────────────────────────────
85
+ wsBridge.setStore(sessionStore);
86
+ wsBridge.setRecorder(recorder);
87
+ launcher.setStore(sessionStore);
88
+ launcher.setRecorder(recorder);
89
+ launcher.restoreFromDisk();
90
+ wsBridge.restoreFromDisk();
91
+ containerManager.restoreState(CONTAINER_STATE_PATH);
92
+
93
+ // ── Session orchestrator — centralizes lifecycle event wiring ────────────────
94
+ orchestrator.initialize();
95
+
96
+ console.log(`[server] Session persistence: ${sessionStore.directory}`);
97
+ if (recorder.isGloballyEnabled()) {
98
+ console.log(`[server] Recording enabled (dir: ${recorder.getRecordingsDir()}, max: ${recorder.getMaxLines()} lines)`);
99
+ }
100
+
101
+ // ── Log file persistence — writes all log output to ~/.companion/logs/ ───────
102
+ const logFileWriter = initLogFile();
103
+ if (logFileWriter) {
104
+ console.log(`[server] Log file enabled (dir: ${logFileWriter.getLogsDir()}, max: ${logFileWriter.getMaxLines()} lines, file: ${logFileWriter.filePath})`);
105
+ }
106
+
107
+ const app = new Hono();
108
+
109
+ // ── Health endpoint — always unauthenticated (used by Fly.io + control plane) ─
110
+ const startTime = Date.now();
111
+ app.get("/health", (c) => {
112
+ return c.json({
113
+ ok: true,
114
+ uptime: Math.floor((Date.now() - startTime) / 1000),
115
+ sessions: launcher.listSessions().length,
116
+ });
117
+ });
118
+
119
+ // ── Managed auth middleware — only active when COMPANION_AUTH_ENABLED=1 ────
120
+ const hasManagedAuthSecret = Boolean(process.env.COMPANION_AUTH_SECRET?.trim());
121
+ const managedAuthEnabled =
122
+ process.env.COMPANION_AUTH_ENABLED === "1" ||
123
+ (hasManagedAuthSecret && process.env.COMPANION_AUTH_ENABLED !== "0");
124
+
125
+ if (managedAuthEnabled) {
126
+ const { managedAuth } = await import("./middleware/managed-auth.js");
127
+ app.use("/*", managedAuth);
128
+ console.log("[server] Managed auth enabled");
129
+ } else {
130
+ console.log("[server] Managed auth disabled");
131
+ }
132
+
133
+ app.use("/api/*", cors());
134
+ app.route("/api", createRoutes(orchestrator, launcher, wsBridge, terminalManager, prPoller, recorder, cronScheduler, agentExecutor, linearAgentBridge, port));
135
+
136
+ // Dynamic manifest — embeds auth token in start_url so PWA auto-authenticates
137
+ // on first launch. iOS gives standalone PWAs isolated storage from Safari,
138
+ // so this is the only way to bridge auth across the install boundary.
139
+ app.get("/manifest.json", (c) => {
140
+ const manifest = {
141
+ name: "The Companion",
142
+ short_name: "Companion",
143
+ description: "Web UI for Claude Code and Codex",
144
+ start_url: "/",
145
+ scope: "/",
146
+ display: "standalone" as const,
147
+ background_color: "#262624",
148
+ theme_color: "#d97757",
149
+ icons: [
150
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
151
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
152
+ ],
153
+ };
154
+
155
+ // If the user has an auth cookie (set during login), embed token in start_url.
156
+ // Safari sends this cookie when fetching the manifest at "Add to Home Screen" time.
157
+ const authCookie = getCookie(c, "companion_auth");
158
+ if (authCookie && verifyToken(authCookie)) {
159
+ manifest.start_url = `/?token=${authCookie}`;
160
+ } else {
161
+ // Localhost bypass — always embed the token for same-machine installs
162
+ const bunServer = c.env as { requestIP?: (req: Request) => { address: string } | null };
163
+ const ip = bunServer?.requestIP?.(c.req.raw);
164
+ const addr = ip?.address ?? "";
165
+ if (addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1") {
166
+ manifest.start_url = `/?token=${getToken()}`;
167
+ }
168
+ }
169
+
170
+ c.header("Content-Type", "application/manifest+json");
171
+ return c.json(manifest);
172
+ });
173
+
174
+ // In production, serve built frontend using absolute path (works when installed as npm package)
175
+ if (process.env.NODE_ENV === "production") {
176
+ const distDir = resolve(packageRoot, "dist");
177
+ app.use("/*", cacheControlMiddleware());
178
+ app.use("/*", serveStatic({ root: distDir }));
179
+ app.get("/*", serveStatic({ path: resolve(distDir, "index.html") }));
180
+ }
181
+
182
+ const server = Bun.serve<SocketData>({
183
+ hostname: host,
184
+ port,
185
+ idleTimeout: 0, // Disable top-level idle timeout — it kills idle browser WebSockets (code 1006)
186
+ async fetch(req, server) {
187
+ const url = new URL(req.url);
188
+
189
+ // ── CLI WebSocket — Claude Code CLI connects here via --sdk-url ────
190
+ const cliMatch = url.pathname.match(/^\/ws\/cli\/([a-f0-9-]+)$/);
191
+ if (cliMatch) {
192
+ const sessionId = cliMatch[1];
193
+ const upgraded = server.upgrade(req, {
194
+ data: { kind: "cli" as const, sessionId },
195
+ });
196
+ if (upgraded) return undefined;
197
+ return new Response("WebSocket upgrade failed", { status: 400 });
198
+ }
199
+
200
+ // Helper: check if request is from localhost (same machine)
201
+ const reqIp = server.requestIP(req);
202
+ const reqAddr = reqIp?.address ?? "";
203
+ const isLocalhost = reqAddr === "127.0.0.1" || reqAddr === "::1" || reqAddr === "::ffff:127.0.0.1";
204
+
205
+ // ── Browser WebSocket — connects to a specific session ─────────────
206
+ const browserMatch = url.pathname.match(/^\/ws\/browser\/([a-f0-9-]+)$/);
207
+ if (browserMatch) {
208
+ if (managedAuthEnabled) {
209
+ const auth = await authenticateManagedWebSocket(req);
210
+ if (!auth.ok) {
211
+ return new Response(auth.body || "Unauthorized", { status: auth.status });
212
+ }
213
+ } else {
214
+ const wsToken = url.searchParams.get("token");
215
+ if (!isLocalhost && !verifyToken(wsToken)) {
216
+ return new Response("Unauthorized", { status: 401 });
217
+ }
218
+ }
219
+ const sessionId = browserMatch[1];
220
+ const upgraded = server.upgrade(req, {
221
+ data: { kind: "browser" as const, sessionId },
222
+ });
223
+ if (upgraded) return undefined;
224
+ return new Response("WebSocket upgrade failed", { status: 400 });
225
+ }
226
+
227
+ // ── Terminal WebSocket — embedded terminal PTY connection ─────────
228
+ const termMatch = url.pathname.match(/^\/ws\/terminal\/([a-f0-9-]+)$/);
229
+ if (termMatch) {
230
+ if (managedAuthEnabled) {
231
+ const auth = await authenticateManagedWebSocket(req);
232
+ if (!auth.ok) {
233
+ return new Response(auth.body || "Unauthorized", { status: auth.status });
234
+ }
235
+ } else {
236
+ const wsToken = url.searchParams.get("token");
237
+ if (!isLocalhost && !verifyToken(wsToken)) {
238
+ return new Response("Unauthorized", { status: 401 });
239
+ }
240
+ }
241
+ const terminalId = termMatch[1];
242
+ const upgraded = server.upgrade(req, {
243
+ data: { kind: "terminal" as const, terminalId },
244
+ });
245
+ if (upgraded) return undefined;
246
+ return new Response("WebSocket upgrade failed", { status: 400 });
247
+ }
248
+
249
+ // ── noVNC WebSocket — proxies VNC data to container's websockify ────
250
+ const novncMatch = url.pathname.match(/^\/ws\/novnc\/([a-f0-9-]+)$/);
251
+ if (novncMatch) {
252
+ if (managedAuthEnabled) {
253
+ const auth = await authenticateManagedWebSocket(req);
254
+ if (!auth.ok) {
255
+ return new Response(auth.body || "Unauthorized", { status: auth.status });
256
+ }
257
+ } else {
258
+ const wsToken = url.searchParams.get("token");
259
+ if (!isLocalhost && !verifyToken(wsToken)) {
260
+ return new Response("Unauthorized", { status: 401 });
261
+ }
262
+ }
263
+ const sessionId = novncMatch[1];
264
+ const upgraded = server.upgrade(req, {
265
+ data: { kind: "novnc" as const, sessionId },
266
+ });
267
+ if (upgraded) return undefined;
268
+ return new Response("WebSocket upgrade failed", { status: 400 });
269
+ }
270
+
271
+ // Hono handles the rest
272
+ return app.fetch(req, server);
273
+ },
274
+ websocket: {
275
+ idleTimeout: 0,
276
+ sendPings: false, // Disable Bun ping timeout that kills CLI connections (code 1006)
277
+ open(ws: ServerWebSocket<SocketData>) {
278
+ const data = ws.data;
279
+ if (data.kind === "cli") {
280
+ wsBridge.handleCLIOpen(ws, data.sessionId);
281
+ launcher.markConnected(data.sessionId);
282
+ } else if (data.kind === "browser") {
283
+ wsBridge.handleBrowserOpen(ws, data.sessionId);
284
+ } else if (data.kind === "terminal") {
285
+ terminalManager.addBrowserSocket(ws);
286
+ } else if (data.kind === "novnc") {
287
+ noVncProxy.handleOpen(ws, data.sessionId);
288
+ }
289
+ },
290
+ message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
291
+ const data = ws.data;
292
+ if (data.kind === "cli") {
293
+ wsBridge.handleCLIMessage(ws, msg);
294
+ } else if (data.kind === "browser") {
295
+ wsBridge.handleBrowserMessage(ws, msg);
296
+ } else if (data.kind === "terminal") {
297
+ terminalManager.handleBrowserMessage(ws, msg);
298
+ } else if (data.kind === "novnc") {
299
+ noVncProxy.handleMessage(ws, msg);
300
+ }
301
+ },
302
+ close(ws: ServerWebSocket<SocketData>, code?: number, _reason?: string) {
303
+ console.log("[ws-close]", ws.data.kind, "code=" + code);
304
+ const data = ws.data;
305
+ if (data.kind === "cli") {
306
+ wsBridge.handleCLIClose(ws);
307
+ } else if (data.kind === "browser") {
308
+ wsBridge.handleBrowserClose(ws);
309
+ } else if (data.kind === "terminal") {
310
+ terminalManager.removeBrowserSocket(ws);
311
+ } else if (data.kind === "novnc") {
312
+ noVncProxy.handleClose(ws);
313
+ }
314
+ },
315
+ },
316
+ });
317
+
318
+ const authToken = getToken();
319
+ console.log(`Server running on http://${host}:${server.port}`);
320
+ console.log();
321
+ console.log(` Auth token: ${authToken}`);
322
+ if (process.env.COMPANION_AUTH_TOKEN) {
323
+ console.log(" (using COMPANION_AUTH_TOKEN env var)");
324
+ }
325
+ console.log();
326
+ console.log(` CLI WebSocket: ws://localhost:${server.port}/ws/cli/:sessionId`);
327
+ console.log(` Browser WebSocket: ws://localhost:${server.port}/ws/browser/:sessionId`);
328
+
329
+ if (process.env.NODE_ENV !== "production") {
330
+ console.log("Dev mode: frontend at http://localhost:5174");
331
+ }
332
+
333
+ // ── Cron scheduler ──────────────────────────────────────────────────────────
334
+ cronScheduler.startAll();
335
+
336
+ // ── Agent system ────────────────────────────────────────────────────────────
337
+ migrateCronJobsToAgents();
338
+ migrateLinearCredentialsToAgents();
339
+ agentExecutor.startAll();
340
+
341
+ // ── Image pull manager — pre-pull missing Docker images for environments ────
342
+ imagePullManager.initFromEnvironments();
343
+
344
+ // ── Tailscale Funnel restoration ────────────────────────────────────────────
345
+ restoreTailscaleFunnel(port).catch((err) => {
346
+ console.warn("[server] Tailscale Funnel restoration failed:", err);
347
+ });
348
+
349
+ // ── Update checker ──────────────────────────────────────────────────────────
350
+ startPeriodicCheck();
351
+ if (isRunningAsService()) {
352
+ setServiceMode(true);
353
+ console.log("[server] Running as background service (auto-update available)");
354
+ }
355
+
356
+ // ── Runtime diagnostics ──────────────────────────────────────────────────────
357
+ import { log } from "./logger.js";
358
+ import { metricsCollector } from "./metrics-collector.js";
359
+
360
+ const DIAGNOSTICS_INTERVAL_MS = 5 * 60_000; // every 5 minutes
361
+ setInterval(() => {
362
+ const snap = metricsCollector.getSnapshot(wsBridge);
363
+ const mem = snap.gauges.memory;
364
+ const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
365
+ const sessionStats = wsBridge.getSessionMemoryStats();
366
+ const topSessions = sessionStats
367
+ .sort((a, b) => b.historyLen - a.historyLen)
368
+ .slice(0, 3)
369
+ .map((s) => `${s.id.slice(0, 8)}(h=${s.historyLen},b=${s.browsers})`)
370
+ .join(", ");
371
+
372
+ log.info("diagnostics", "Runtime snapshot", {
373
+ rss: `${mb(mem.rss)}MB`,
374
+ heap: `${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB`,
375
+ external: `${mb(mem.external)}MB`,
376
+ sessions: snap.gauges.totalActiveSessions,
377
+ browsers: snap.gauges.connectedBrowsers,
378
+ historyMsgs: snap.gauges.totalHistoryMessages,
379
+ pendingMsgs: snap.gauges.totalPendingMessages,
380
+ eventBuffer: snap.gauges.totalEventBufferSize,
381
+ errors: Object.values(snap.counters.errors).reduce((a, b) => a + b, 0),
382
+ topSessions: topSessions || "none",
383
+ });
384
+ }, DIAGNOSTICS_INTERVAL_MS);
385
+
386
+ // ── Graceful shutdown — persist container state ──────────────────────────────
387
+ function gracefulShutdown() {
388
+ console.log("[server] Persisting container state before shutdown...");
389
+ containerManager.persistState(CONTAINER_STATE_PATH);
390
+ cleanupTailscaleFunnel(port);
391
+ closeLogFile();
392
+ process.exit(0);
393
+ }
394
+ process.on("SIGTERM", gracefulShutdown);
395
+ process.on("SIGINT", gracefulShutdown);
396
+