heyhank 0.1.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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,342 @@
1
+ // ─── Google Media APIs ───────────────────────────────────────────────────────
2
+ // Image generation (Imagen 4 + Gemini) and Video generation (Veo) via Google AI APIs
3
+
4
+ import { mkdirSync, writeFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { HEYHANK_HOME } from "./paths.js";
7
+ import { getSettings } from "./settings-manager.js";
8
+
9
+ // ─── Constants ───────────────────────────────────────────────────────────────
10
+
11
+ const MEDIA_DIR = join(HEYHANK_HOME, "media");
12
+ const API_KEY = () => getSettings().geminiApiKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || "";
13
+
14
+ // Models that use the predict endpoint (Imagen)
15
+ const PREDICT_MODELS = new Set([
16
+ "imagen-4.0-generate-001",
17
+ "imagen-4.0-ultra-generate-001",
18
+ "imagen-4.0-fast-generate-001",
19
+ ]);
20
+
21
+ // ─── Types ───────────────────────────────────────────────────────────────────
22
+
23
+ export interface ImageGenerationResult {
24
+ filename: string;
25
+ path: string;
26
+ mimeType: string;
27
+ prompt: string;
28
+ model: string;
29
+ }
30
+
31
+ export interface VideoGenerationResult {
32
+ operationName: string;
33
+ status: "pending" | "completed" | "failed";
34
+ prompt: string;
35
+ model: string;
36
+ videoPath?: string;
37
+ }
38
+
39
+ // ─── Image Generation ────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Generate an image. Dispatches to the right API based on model:
43
+ * - imagen-4.0-*: uses predict endpoint
44
+ * - gemini-*: uses generateContent with IMAGE modality
45
+ */
46
+ export async function generateImage(
47
+ prompt: string,
48
+ opts?: {
49
+ model?: string;
50
+ aspectRatio?: string;
51
+ numberOfImages?: number;
52
+ },
53
+ ): Promise<ImageGenerationResult[]> {
54
+ const apiKey = API_KEY();
55
+ if (!apiKey) throw new Error("GOOGLE_API_KEY not configured");
56
+
57
+ mkdirSync(MEDIA_DIR, { recursive: true });
58
+
59
+ const model = opts?.model || "imagen-4.0-fast-generate-001";
60
+
61
+ if (PREDICT_MODELS.has(model)) {
62
+ return generateImageImagen(prompt, model, apiKey, opts);
63
+ }
64
+ return generateImageGemini(prompt, model, apiKey);
65
+ }
66
+
67
+ /** Imagen 4.0 via predict endpoint */
68
+ async function generateImageImagen(
69
+ prompt: string,
70
+ model: string,
71
+ apiKey: string,
72
+ opts?: { aspectRatio?: string; numberOfImages?: number },
73
+ ): Promise<ImageGenerationResult[]> {
74
+ const response = await fetch(
75
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict?key=${apiKey}`,
76
+ {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({
80
+ instances: [{ prompt }],
81
+ parameters: {
82
+ sampleCount: opts?.numberOfImages || 1,
83
+ aspectRatio: opts?.aspectRatio || "1:1",
84
+ },
85
+ }),
86
+ },
87
+ );
88
+
89
+ if (!response.ok) {
90
+ const text = await response.text();
91
+ throw new Error(`Imagen API error ${response.status}: ${text}`);
92
+ }
93
+
94
+ const data = await response.json() as {
95
+ predictions?: Array<{
96
+ bytesBase64Encoded?: string;
97
+ mimeType?: string;
98
+ }>;
99
+ };
100
+
101
+ const results: ImageGenerationResult[] = [];
102
+ for (const pred of data.predictions || []) {
103
+ if (pred.bytesBase64Encoded) {
104
+ const mime = pred.mimeType || "image/png";
105
+ const ext = mime.includes("png") ? "png" : "jpg";
106
+ const filename = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
107
+ const filepath = join(MEDIA_DIR, filename);
108
+ writeFileSync(filepath, Buffer.from(pred.bytesBase64Encoded, "base64"));
109
+ results.push({ filename, path: filepath, mimeType: mime, prompt, model });
110
+ }
111
+ }
112
+
113
+ if (results.length === 0) throw new Error("No image generated by Imagen");
114
+ return results;
115
+ }
116
+
117
+ /** Gemini models with IMAGE response modality */
118
+ async function generateImageGemini(
119
+ prompt: string,
120
+ model: string,
121
+ apiKey: string,
122
+ ): Promise<ImageGenerationResult[]> {
123
+ const response = await fetch(
124
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
125
+ {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({
129
+ contents: [{ parts: [{ text: `Generate an image: ${prompt}` }] }],
130
+ generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
131
+ }),
132
+ },
133
+ );
134
+
135
+ if (!response.ok) {
136
+ const text = await response.text();
137
+ throw new Error(`Gemini image API error ${response.status}: ${text}`);
138
+ }
139
+
140
+ const data = await response.json() as {
141
+ candidates?: Array<{
142
+ content?: {
143
+ parts?: Array<{
144
+ text?: string;
145
+ inlineData?: { mimeType: string; data: string };
146
+ }>;
147
+ };
148
+ }>;
149
+ };
150
+
151
+ const results: ImageGenerationResult[] = [];
152
+ for (const candidate of data.candidates || []) {
153
+ for (const part of candidate.content?.parts || []) {
154
+ if (part.inlineData) {
155
+ const ext = part.inlineData.mimeType === "image/png" ? "png" : "jpg";
156
+ const filename = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
157
+ const filepath = join(MEDIA_DIR, filename);
158
+ writeFileSync(filepath, Buffer.from(part.inlineData.data, "base64"));
159
+ results.push({ filename, path: filepath, mimeType: part.inlineData.mimeType, prompt, model });
160
+ }
161
+ }
162
+ }
163
+
164
+ if (results.length === 0) {
165
+ const textResponse = data.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
166
+ throw new Error(
167
+ textResponse
168
+ ? `Model returned text instead of image: ${textResponse.slice(0, 200)}`
169
+ : "No image generated",
170
+ );
171
+ }
172
+
173
+ return results;
174
+ }
175
+
176
+ // ─── Video Generation (Veo) ──────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Start video generation using Google's Veo model.
180
+ * Returns an operation that can be polled for completion.
181
+ */
182
+ export async function generateVideo(
183
+ prompt: string,
184
+ opts?: {
185
+ model?: string;
186
+ durationSeconds?: number;
187
+ aspectRatio?: string;
188
+ imageUri?: string;
189
+ },
190
+ ): Promise<VideoGenerationResult> {
191
+ const apiKey = API_KEY();
192
+ if (!apiKey) throw new Error("GOOGLE_API_KEY not configured");
193
+
194
+ mkdirSync(MEDIA_DIR, { recursive: true });
195
+
196
+ const model = opts?.model || "veo-3.1-fast-generate-preview";
197
+
198
+ const requestBody: Record<string, unknown> = {
199
+ instances: [
200
+ {
201
+ prompt,
202
+ ...(opts?.imageUri ? { image: { gcsUri: opts.imageUri } } : {}),
203
+ },
204
+ ],
205
+ parameters: {
206
+ aspectRatio: opts?.aspectRatio || "16:9",
207
+ durationSeconds: opts?.durationSeconds || 5,
208
+ sampleCount: 1,
209
+ personGeneration: "allow_adult",
210
+ },
211
+ };
212
+
213
+ const response = await fetch(
214
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:predictLongRunning?key=${apiKey}`,
215
+ {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify(requestBody),
219
+ },
220
+ );
221
+
222
+ if (!response.ok) {
223
+ const text = await response.text();
224
+ throw new Error(`Veo API error ${response.status}: ${text}`);
225
+ }
226
+
227
+ const data = await response.json() as {
228
+ name?: string;
229
+ done?: boolean;
230
+ error?: { message: string };
231
+ };
232
+
233
+ if (data.error) {
234
+ throw new Error(`Veo error: ${data.error.message}`);
235
+ }
236
+
237
+ return {
238
+ operationName: data.name || "unknown",
239
+ status: data.done ? "completed" : "pending",
240
+ prompt,
241
+ model,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Poll a Veo video generation operation for completion.
247
+ */
248
+ export async function pollVideoOperation(
249
+ operationName: string,
250
+ ): Promise<VideoGenerationResult & { videoData?: string }> {
251
+ const apiKey = API_KEY();
252
+ if (!apiKey) throw new Error("GOOGLE_API_KEY not configured");
253
+
254
+ const response = await fetch(
255
+ `https://generativelanguage.googleapis.com/v1beta/${operationName}?key=${apiKey}`,
256
+ );
257
+
258
+ if (!response.ok) {
259
+ const text = await response.text();
260
+ throw new Error(`Poll error ${response.status}: ${text}`);
261
+ }
262
+
263
+ const data = await response.json() as {
264
+ name: string;
265
+ done?: boolean;
266
+ error?: { message: string };
267
+ response?: {
268
+ // Veo format: generateVideoResponse with download URIs
269
+ generateVideoResponse?: {
270
+ generatedSamples?: Array<{
271
+ video?: { uri?: string; encoding?: string };
272
+ }>;
273
+ };
274
+ // Legacy format: predictions with base64
275
+ predictions?: Array<{
276
+ bytesBase64Encoded?: string;
277
+ mimeType?: string;
278
+ }>;
279
+ };
280
+ };
281
+
282
+ if (data.error) {
283
+ return { operationName, status: "failed", prompt: "", model: "" };
284
+ }
285
+
286
+ if (!data.done) {
287
+ return { operationName, status: "pending", prompt: "", model: "" };
288
+ }
289
+
290
+ mkdirSync(MEDIA_DIR, { recursive: true });
291
+ let videoPath: string | undefined;
292
+
293
+ // Try Veo format first (download URI)
294
+ const sample = data.response?.generateVideoResponse?.generatedSamples?.[0];
295
+ if (sample?.video?.uri) {
296
+ const apiKey = API_KEY();
297
+ const videoUrl = sample.video.uri.includes("?")
298
+ ? `${sample.video.uri}&key=${apiKey}`
299
+ : `${sample.video.uri}?key=${apiKey}`;
300
+ const videoResponse = await fetch(videoUrl);
301
+ if (videoResponse.ok) {
302
+ const buffer = Buffer.from(await videoResponse.arrayBuffer());
303
+ const filename = `vid_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.mp4`;
304
+ videoPath = join(MEDIA_DIR, filename);
305
+ writeFileSync(videoPath, buffer);
306
+ }
307
+ }
308
+
309
+ // Fallback: predictions with base64
310
+ if (!videoPath) {
311
+ const prediction = data.response?.predictions?.[0];
312
+ if (prediction?.bytesBase64Encoded) {
313
+ const ext = prediction.mimeType === "video/webm" ? "webm" : "mp4";
314
+ const filename = `vid_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
315
+ videoPath = join(MEDIA_DIR, filename);
316
+ writeFileSync(videoPath, Buffer.from(prediction.bytesBase64Encoded, "base64"));
317
+ }
318
+ }
319
+
320
+ return {
321
+ operationName,
322
+ status: "completed",
323
+ prompt: "",
324
+ model: "",
325
+ videoPath,
326
+ videoData: videoPath ? "(saved to disk)" : undefined,
327
+ };
328
+ }
329
+
330
+ /** List generated media files. */
331
+ export function listMedia(): Array<{ filename: string; path: string }> {
332
+ mkdirSync(MEDIA_DIR, { recursive: true });
333
+ try {
334
+ const files = readdirSync(MEDIA_DIR) as string[];
335
+ return files.map((f: string) => ({
336
+ filename: f,
337
+ path: join(MEDIA_DIR, f),
338
+ }));
339
+ } catch {
340
+ return [];
341
+ }
342
+ }
@@ -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();