@epiphytic/claudecodeui 1.2.0 → 1.2.2

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 (53) hide show
  1. package/dist/assets/index-BGneYLVE.css +32 -0
  2. package/dist/assets/{index-DqxzEd_8.js → index-DM1BeYBg.js} +182 -182
  3. package/dist/index.html +2 -2
  4. package/dist/sw.js +25 -1
  5. package/package.json +2 -1
  6. package/public/api-docs.html +879 -0
  7. package/public/clear-cache.html +85 -0
  8. package/public/convert-icons.md +53 -0
  9. package/public/favicon.png +0 -0
  10. package/public/favicon.svg +9 -0
  11. package/public/generate-icons.js +49 -0
  12. package/public/icons/claude-ai-icon.svg +1 -0
  13. package/public/icons/codex-white.svg +3 -0
  14. package/public/icons/codex.svg +3 -0
  15. package/public/icons/cursor-white.svg +12 -0
  16. package/public/icons/cursor.svg +1 -0
  17. package/public/icons/generate-icons.md +19 -0
  18. package/public/icons/icon-128x128.png +0 -0
  19. package/public/icons/icon-128x128.svg +12 -0
  20. package/public/icons/icon-144x144.png +0 -0
  21. package/public/icons/icon-144x144.svg +12 -0
  22. package/public/icons/icon-152x152.png +0 -0
  23. package/public/icons/icon-152x152.svg +12 -0
  24. package/public/icons/icon-192x192.png +0 -0
  25. package/public/icons/icon-192x192.svg +12 -0
  26. package/public/icons/icon-384x384.png +0 -0
  27. package/public/icons/icon-384x384.svg +12 -0
  28. package/public/icons/icon-512x512.png +0 -0
  29. package/public/icons/icon-512x512.svg +12 -0
  30. package/public/icons/icon-72x72.png +0 -0
  31. package/public/icons/icon-72x72.svg +12 -0
  32. package/public/icons/icon-96x96.png +0 -0
  33. package/public/icons/icon-96x96.svg +12 -0
  34. package/public/icons/icon-template.svg +12 -0
  35. package/public/logo-128.png +0 -0
  36. package/public/logo-256.png +0 -0
  37. package/public/logo-32.png +0 -0
  38. package/public/logo-512.png +0 -0
  39. package/public/logo-64.png +0 -0
  40. package/public/logo.svg +17 -0
  41. package/public/manifest.json +61 -0
  42. package/public/screenshots/cli-selection.png +0 -0
  43. package/public/screenshots/desktop-main.png +0 -0
  44. package/public/screenshots/mobile-chat.png +0 -0
  45. package/public/screenshots/tools-modal.png +0 -0
  46. package/public/sw.js +131 -0
  47. package/server/external-session-detector.js +188 -48
  48. package/server/index.js +141 -1
  49. package/server/projects-cache.js +42 -0
  50. package/server/routes/projects.js +11 -6
  51. package/server/routes/sessions.js +11 -6
  52. package/server/sessions-cache.js +42 -0
  53. package/dist/assets/index-r43D8sh4.css +0 -32
@@ -27,10 +27,16 @@ const currentPid = process.pid;
27
27
 
28
28
  /**
29
29
  * Detect external Claude processes
30
- * @returns {Array<{ pid: number, command: string, cwd: string | null }>}
30
+ * @returns {{ processes: Array<{ pid: number, command: string, cwd: string | null }>, detectionAvailable: boolean, error: string | null }}
31
31
  */
32
32
  function detectClaudeProcesses() {
33
33
  const processes = [];
34
+ let detectionAvailable = true;
35
+ let error = null;
36
+
37
+ console.log("[ExternalSessionDetector] detectClaudeProcesses() called");
38
+ console.log("[ExternalSessionDetector] Platform:", os.platform());
39
+ console.log("[ExternalSessionDetector] Current PID:", currentPid);
34
40
 
35
41
  if (os.platform() === "win32") {
36
42
  // Windows: use wmic or tasklist
@@ -58,72 +64,165 @@ function detectClaudeProcesses() {
58
64
  }
59
65
  }
60
66
  }
67
+ } else if (result.error) {
68
+ detectionAvailable = false;
69
+ error = `wmic not available: ${result.error.message}`;
61
70
  }
62
- } catch {
63
- // Ignore errors on Windows
71
+ } catch (e) {
72
+ detectionAvailable = false;
73
+ error = `Windows process detection failed: ${e.message}`;
64
74
  }
65
75
  } else {
66
76
  // Unix: use pgrep and ps
67
77
  try {
68
- // First, get PIDs of claude processes
69
- const pgrepResult = spawnSync("pgrep", ["-f", "claude"], {
78
+ // First, check if pgrep is available
79
+ const pgrepCheck = spawnSync("which", ["pgrep"], {
70
80
  encoding: "utf8",
71
81
  stdio: "pipe",
72
82
  });
83
+ console.log(
84
+ "[ExternalSessionDetector] pgrep available:",
85
+ pgrepCheck.status === 0,
86
+ );
73
87
 
74
- if (pgrepResult.status === 0) {
75
- const pids = pgrepResult.stdout.trim().split("\n").filter(Boolean);
76
-
77
- for (const pidStr of pids) {
78
- const pid = parseInt(pidStr, 10);
79
-
80
- // Skip our own process and child processes
81
- if (pid === currentPid) continue;
82
-
83
- // Get command details
84
- const psResult = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
88
+ if (pgrepCheck.status !== 0) {
89
+ // pgrep not available, try ps aux as fallback
90
+ console.log("[ExternalSessionDetector] Using ps aux fallback");
91
+ try {
92
+ const psResult = spawnSync("ps", ["aux"], {
85
93
  encoding: "utf8",
86
94
  stdio: "pipe",
87
95
  });
88
96
 
89
97
  if (psResult.status === 0) {
90
- const command = psResult.stdout.trim();
91
-
92
- // Filter out our own subprocesses (claude-sdk spawned by this app)
93
- // and only include standalone claude CLI invocations
94
- if (isExternalClaudeProcess(command)) {
95
- // Try to get working directory via lsof
96
- let cwd = null;
97
- try {
98
- const lsofResult = spawnSync(
99
- "lsof",
100
- ["-p", String(pid), "-Fn"],
101
- {
102
- encoding: "utf8",
103
- stdio: "pipe",
104
- },
105
- );
106
- if (lsofResult.status === 0) {
107
- const cwdMatch = lsofResult.stdout.match(/n(\/[^\n]+)/);
108
- if (cwdMatch) {
109
- cwd = cwdMatch[1];
98
+ const lines = psResult.stdout.split("\n");
99
+ const claudeLines = lines.filter(
100
+ (line) =>
101
+ line.toLowerCase().includes("claude") &&
102
+ !line.includes(String(currentPid)),
103
+ );
104
+ console.log(
105
+ "[ExternalSessionDetector] ps aux found",
106
+ claudeLines.length,
107
+ 'lines containing "claude"',
108
+ );
109
+
110
+ for (const line of claudeLines) {
111
+ const parts = line.trim().split(/\s+/);
112
+ if (parts.length >= 2) {
113
+ const pid = parseInt(parts[1], 10);
114
+ if (!isNaN(pid) && pid !== currentPid) {
115
+ const command = parts.slice(10).join(" ");
116
+ const isExternal = isExternalClaudeProcess(command);
117
+ console.log(
118
+ `[ExternalSessionDetector] PID ${pid}: "${command.slice(0, 60)}..." isExternal=${isExternal}`,
119
+ );
120
+ if (isExternal) {
121
+ processes.push({ pid, command, cwd: null });
110
122
  }
111
123
  }
112
- } catch {
113
- // lsof may not be available
114
124
  }
125
+ }
126
+ } else {
127
+ detectionAvailable = false;
128
+ error = "Neither pgrep nor ps aux available";
129
+ console.log("[ExternalSessionDetector] ps aux failed");
130
+ }
131
+ } catch (e) {
132
+ detectionAvailable = false;
133
+ error = `Process detection failed: ${e.message}`;
134
+ console.log("[ExternalSessionDetector] ps aux exception:", e.message);
135
+ }
136
+ } else {
137
+ // pgrep is available, use it
138
+ const pgrepResult = spawnSync("pgrep", ["-f", "claude"], {
139
+ encoding: "utf8",
140
+ stdio: "pipe",
141
+ });
142
+ console.log(
143
+ "[ExternalSessionDetector] pgrep status:",
144
+ pgrepResult.status,
145
+ );
146
+
147
+ if (pgrepResult.status === 0) {
148
+ const pids = pgrepResult.stdout.trim().split("\n").filter(Boolean);
149
+ console.log("[ExternalSessionDetector] pgrep found PIDs:", pids);
150
+
151
+ for (const pidStr of pids) {
152
+ const pid = parseInt(pidStr, 10);
153
+
154
+ // Skip our own process and child processes
155
+ if (pid === currentPid) {
156
+ console.log(
157
+ `[ExternalSessionDetector] Skipping our own PID ${pid}`,
158
+ );
159
+ continue;
160
+ }
161
+
162
+ // Get command details
163
+ const psResult = spawnSync(
164
+ "ps",
165
+ ["-p", String(pid), "-o", "args="],
166
+ {
167
+ encoding: "utf8",
168
+ stdio: "pipe",
169
+ },
170
+ );
171
+
172
+ if (psResult.status === 0) {
173
+ const command = psResult.stdout.trim();
174
+ const isExternal = isExternalClaudeProcess(command);
175
+ console.log(
176
+ `[ExternalSessionDetector] PID ${pid}: "${command.slice(0, 80)}..." isExternal=${isExternal}`,
177
+ );
178
+
179
+ // Filter out our own subprocesses (claude-sdk spawned by this app)
180
+ // and only include standalone claude CLI invocations
181
+ if (isExternal) {
182
+ // Try to get working directory via lsof
183
+ let cwd = null;
184
+ try {
185
+ const lsofResult = spawnSync(
186
+ "lsof",
187
+ ["-p", String(pid), "-Fn"],
188
+ {
189
+ encoding: "utf8",
190
+ stdio: "pipe",
191
+ },
192
+ );
193
+ if (lsofResult.status === 0) {
194
+ const cwdMatch = lsofResult.stdout.match(/n(\/[^\n]+)/);
195
+ if (cwdMatch) {
196
+ cwd = cwdMatch[1];
197
+ }
198
+ }
199
+ } catch {
200
+ // lsof may not be available - not critical
201
+ }
115
202
 
116
- processes.push({ pid, command, cwd });
203
+ processes.push({ pid, command, cwd });
204
+ }
117
205
  }
118
206
  }
207
+ } else {
208
+ console.log(
209
+ "[ExternalSessionDetector] pgrep found no claude processes (status:",
210
+ pgrepResult.status,
211
+ ")",
212
+ );
119
213
  }
120
214
  }
121
- } catch {
122
- // Ignore errors
215
+ } catch (e) {
216
+ detectionAvailable = false;
217
+ error = `Unix process detection failed: ${e.message}`;
218
+ console.log(
219
+ "[ExternalSessionDetector] Unix detection exception:",
220
+ e.message,
221
+ );
123
222
  }
124
223
  }
125
224
 
126
- return processes;
225
+ return { processes, detectionAvailable, error };
127
226
  }
128
227
 
129
228
  /**
@@ -133,18 +232,30 @@ function detectClaudeProcesses() {
133
232
  */
134
233
  function isExternalClaudeProcess(command) {
135
234
  // Skip node processes (SDK internals)
136
- if (command.startsWith("node ")) return false;
235
+ if (command.startsWith("node ")) {
236
+ console.log("[isExternalClaudeProcess] Rejected: starts with 'node '");
237
+ return false;
238
+ }
137
239
 
138
240
  // Skip our own server
139
- if (command.includes("claudecodeui/server")) return false;
241
+ if (command.includes("claudecodeui/server")) {
242
+ console.log(
243
+ "[isExternalClaudeProcess] Rejected: contains 'claudecodeui/server'",
244
+ );
245
+ return false;
246
+ }
140
247
 
141
248
  // Look for actual claude CLI invocations
142
- return (
249
+ const isExternal =
143
250
  command.includes("claude ") ||
144
251
  command.includes("claude-code") ||
145
252
  command.match(/\/claude\s/) ||
146
- command.endsWith("/claude")
253
+ command.endsWith("/claude");
254
+
255
+ console.log(
256
+ `[isExternalClaudeProcess] "${command.slice(0, 60)}..." => ${isExternal}`,
147
257
  );
258
+ return isExternal;
148
259
  }
149
260
 
150
261
  /**
@@ -284,34 +395,63 @@ function processExists(pid) {
284
395
  /**
285
396
  * Main detection function - detect all external Claude sessions
286
397
  * @param {string} projectPath - The project directory to check
287
- * @returns {{ hasExternalSession: boolean, processes: Array, tmuxSessions: Array, lockFile: object }}
398
+ * @returns {{ hasExternalSession: boolean, processes: Array, tmuxSessions: Array, lockFile: object, detectionAvailable: boolean, detectionError: string | null }}
288
399
  */
289
400
  function detectExternalClaude(projectPath) {
401
+ console.log("[detectExternalClaude] Called with projectPath:", projectPath);
402
+
290
403
  // Check cache
291
404
  const cacheKey = projectPath || "__global__";
292
405
  const cached = detectionCache.get(cacheKey);
293
406
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
407
+ console.log("[detectExternalClaude] Returning cached result");
294
408
  return cached.result;
295
409
  }
410
+ console.log("[detectExternalClaude] Cache miss, performing fresh detection");
296
411
 
297
412
  const result = {
298
413
  hasExternalSession: false,
299
414
  processes: [],
300
415
  tmuxSessions: [],
301
416
  lockFile: { exists: false, lockFile: null, content: null },
417
+ detectionAvailable: true,
418
+ detectionError: null,
302
419
  };
303
420
 
304
421
  // Detect processes
305
- result.processes = detectClaudeProcesses();
422
+ const processDetection = detectClaudeProcesses();
423
+ result.processes = processDetection.processes;
424
+ result.detectionAvailable = processDetection.detectionAvailable;
425
+ result.detectionError = processDetection.error;
426
+ console.log(
427
+ "[detectExternalClaude] Process detection result:",
428
+ processDetection.processes.length,
429
+ "processes, available:",
430
+ processDetection.detectionAvailable,
431
+ "error:",
432
+ processDetection.error,
433
+ );
434
+
306
435
  if (projectPath) {
307
436
  // Filter to processes in this project
437
+ const beforeFilter = result.processes.length;
308
438
  result.processes = result.processes.filter(
309
439
  (p) => !p.cwd || p.cwd.startsWith(projectPath),
310
440
  );
441
+ console.log(
442
+ "[detectExternalClaude] Filtered processes for project:",
443
+ beforeFilter,
444
+ "->",
445
+ result.processes.length,
446
+ );
311
447
  }
312
448
 
313
449
  // Detect tmux sessions
314
450
  result.tmuxSessions = detectClaudeTmuxSessions();
451
+ console.log(
452
+ "[detectExternalClaude] tmux sessions found:",
453
+ result.tmuxSessions.length,
454
+ );
315
455
 
316
456
  // Check lock file
317
457
  if (projectPath) {
package/server/index.js CHANGED
@@ -379,6 +379,70 @@ const wss = new WebSocketServer({
379
379
  app.locals.wss = wss;
380
380
 
381
381
  app.use(cors());
382
+
383
+ // Request/Response logging middleware for debugging
384
+ app.use((req, res, next) => {
385
+ const startTime = Date.now();
386
+ const originalSend = res.send;
387
+ const originalJson = res.json;
388
+
389
+ // Capture response for logging
390
+ res.send = function (body) {
391
+ const duration = Date.now() - startTime;
392
+ const statusCode = res.statusCode;
393
+
394
+ // Log 4xx errors with headers
395
+ if (statusCode >= 400 && statusCode < 500) {
396
+ console.log(
397
+ `[HTTP ${statusCode}] ${req.method} ${req.originalUrl} (${duration}ms)`,
398
+ );
399
+ console.log(` Request Headers:`, JSON.stringify(req.headers, null, 2));
400
+ console.log(
401
+ ` Response:`,
402
+ typeof body === "string" ? body.slice(0, 500) : body,
403
+ );
404
+ }
405
+ // Log 5xx errors with full details
406
+ else if (statusCode >= 500) {
407
+ console.error(
408
+ `[HTTP ${statusCode}] ${req.method} ${req.originalUrl} (${duration}ms)`,
409
+ );
410
+ console.error(` Request Headers:`, JSON.stringify(req.headers, null, 2));
411
+ console.error(` Request Body:`, req.body);
412
+ console.error(` Response:`, body);
413
+ }
414
+
415
+ return originalSend.call(this, body);
416
+ };
417
+
418
+ res.json = function (body) {
419
+ const duration = Date.now() - startTime;
420
+ const statusCode = res.statusCode;
421
+
422
+ // Log 4xx errors with headers
423
+ if (statusCode >= 400 && statusCode < 500) {
424
+ console.log(
425
+ `[HTTP ${statusCode}] ${req.method} ${req.originalUrl} (${duration}ms)`,
426
+ );
427
+ console.log(` Request Headers:`, JSON.stringify(req.headers, null, 2));
428
+ console.log(` Response:`, JSON.stringify(body, null, 2).slice(0, 500));
429
+ }
430
+ // Log 5xx errors with full details
431
+ else if (statusCode >= 500) {
432
+ console.error(
433
+ `[HTTP ${statusCode}] ${req.method} ${req.originalUrl} (${duration}ms)`,
434
+ );
435
+ console.error(` Request Headers:`, JSON.stringify(req.headers, null, 2));
436
+ console.error(` Request Body:`, req.body);
437
+ console.error(` Response:`, JSON.stringify(body, null, 2));
438
+ }
439
+
440
+ return originalJson.call(this, body);
441
+ };
442
+
443
+ next();
444
+ });
445
+
382
446
  app.use(
383
447
  express.json({
384
448
  limit: "50mb",
@@ -402,6 +466,56 @@ app.get("/health", (req, res) => {
402
466
  });
403
467
  });
404
468
 
469
+ // Explicit route for manifest.json (PWA manifest)
470
+ app.get("/manifest.json", (req, res) => {
471
+ const manifestPath = path.join(__dirname, "../public/manifest.json");
472
+ const distManifestPath = path.join(__dirname, "../dist/manifest.json");
473
+
474
+ console.log("[manifest.json] Request received");
475
+ console.log("[manifest.json] __dirname:", __dirname);
476
+ console.log("[manifest.json] Checking paths:");
477
+ console.log(
478
+ " - public:",
479
+ manifestPath,
480
+ "exists:",
481
+ fs.existsSync(manifestPath),
482
+ );
483
+ console.log(
484
+ " - dist:",
485
+ distManifestPath,
486
+ "exists:",
487
+ fs.existsSync(distManifestPath),
488
+ );
489
+
490
+ // Try public first, then dist
491
+ if (fs.existsSync(manifestPath)) {
492
+ console.log("[manifest.json] Serving from public");
493
+ res.setHeader("Content-Type", "application/manifest+json");
494
+ res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
495
+ res.sendFile(manifestPath, (err) => {
496
+ if (err) {
497
+ console.error("[manifest.json] sendFile error:", err);
498
+ res.status(500).json({ error: "Failed to send manifest.json" });
499
+ }
500
+ });
501
+ } else if (fs.existsSync(distManifestPath)) {
502
+ console.log("[manifest.json] Serving from dist");
503
+ res.setHeader("Content-Type", "application/manifest+json");
504
+ res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
505
+ res.sendFile(distManifestPath, (err) => {
506
+ if (err) {
507
+ console.error("[manifest.json] sendFile error:", err);
508
+ res.status(500).json({ error: "Failed to send manifest.json" });
509
+ }
510
+ });
511
+ } else {
512
+ console.error("[ERROR] manifest.json not found in public or dist");
513
+ console.error(" Checked:", manifestPath);
514
+ console.error(" Checked:", distManifestPath);
515
+ res.status(404).json({ error: "manifest.json not found" });
516
+ }
517
+ });
518
+
405
519
  // Optional API key validation (if configured)
406
520
  app.use("/api", validateApiKey);
407
521
 
@@ -447,7 +561,7 @@ app.use("/api/sessions", authenticateToken, sessionsRoutes);
447
561
  // Agent API Routes (uses API key authentication)
448
562
  app.use("/api/agent", agentRoutes);
449
563
 
450
- // Serve public files (like api-docs.html, icons)
564
+ // Serve public files (like api-docs.html, icons, manifest.json)
451
565
  // Enable ETag generation for conditional requests (304 support)
452
566
  app.use(
453
567
  express.static(path.join(__dirname, "../public"), {
@@ -461,6 +575,9 @@ app.use(
461
575
  "Cache-Control",
462
576
  "public, max-age=604800, must-revalidate",
463
577
  );
578
+ } else if (filePath.endsWith(".json")) {
579
+ // JSON files (like manifest.json) - short cache with revalidation
580
+ res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
464
581
  } else if (filePath.endsWith(".html")) {
465
582
  // HTML files should not be cached
466
583
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -483,6 +600,9 @@ app.use(
483
600
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
484
601
  res.setHeader("Pragma", "no-cache");
485
602
  res.setHeader("Expires", "0");
603
+ } else if (filePath.endsWith(".json")) {
604
+ // JSON files (like manifest.json) - short cache with revalidation
605
+ res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
486
606
  } else if (
487
607
  filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)
488
608
  ) {
@@ -1150,12 +1270,26 @@ async function handleChatMessage(ws, writer, messageData) {
1150
1270
  // Handle proactive external session check (before user submits a prompt)
1151
1271
  if (data.type === "check-external-session") {
1152
1272
  const projectPath = data.projectPath;
1273
+ console.log(
1274
+ "[ExternalSessionCheck] Checking for external sessions:",
1275
+ projectPath,
1276
+ );
1153
1277
  if (projectPath) {
1154
1278
  const externalCheck = detectExternalClaude(projectPath);
1279
+ console.log("[ExternalSessionCheck] Result:", {
1280
+ hasExternalSession: externalCheck.hasExternalSession,
1281
+ detectionAvailable: externalCheck.detectionAvailable,
1282
+ detectionError: externalCheck.detectionError,
1283
+ processCount: externalCheck.processes.length,
1284
+ tmuxCount: externalCheck.tmuxSessions.length,
1285
+ hasLockFile: externalCheck.lockFile.exists,
1286
+ });
1155
1287
  writer.send({
1156
1288
  type: "external-session-check-result",
1157
1289
  projectPath,
1158
1290
  hasExternalSession: externalCheck.hasExternalSession,
1291
+ detectionAvailable: externalCheck.detectionAvailable,
1292
+ detectionError: externalCheck.detectionError,
1159
1293
  details: externalCheck.hasExternalSession
1160
1294
  ? {
1161
1295
  processIds: externalCheck.processes.map((p) => p.pid),
@@ -1169,6 +1303,8 @@ async function handleChatMessage(ws, writer, messageData) {
1169
1303
  }
1170
1304
  : null,
1171
1305
  });
1306
+ } else {
1307
+ console.log("[ExternalSessionCheck] No projectPath provided");
1172
1308
  }
1173
1309
  return;
1174
1310
  }
@@ -1208,6 +1344,7 @@ async function handleChatMessage(ws, writer, messageData) {
1208
1344
  writer.send({
1209
1345
  type: "external-session-detected",
1210
1346
  projectPath,
1347
+ detectionAvailable: externalCheck.detectionAvailable,
1211
1348
  details: {
1212
1349
  processIds: externalCheck.processes.map((p) => p.pid),
1213
1350
  tmuxSessions: externalCheck.tmuxSessions.map(
@@ -2652,6 +2789,9 @@ app.get(
2652
2789
  app.get("*", (req, res) => {
2653
2790
  // Skip requests for static assets (files with extensions)
2654
2791
  if (path.extname(req.path)) {
2792
+ console.log(
2793
+ `[404] Static file not found: ${req.path} (not served by express.static)`,
2794
+ );
2655
2795
  return res.status(404).send("Not found");
2656
2796
  }
2657
2797
 
@@ -14,6 +14,10 @@ let cachedProjects = [];
14
14
  let cacheVersion = 0;
15
15
  let cacheTimestamp = null;
16
16
 
17
+ // Promise-based initialization waiting
18
+ let initResolvers = [];
19
+ const MAX_WAIT_MS = 30000; // 30 second timeout
20
+
17
21
  /**
18
22
  * Timeframe definitions in milliseconds
19
23
  * (Same as sessions-cache.js for consistency)
@@ -109,6 +113,14 @@ function updateProjectsCache(projects) {
109
113
 
110
114
  cacheVersion++;
111
115
  cacheTimestamp = new Date().toISOString();
116
+
117
+ // Resolve any waiting promises
118
+ if (initResolvers.length > 0) {
119
+ for (const resolve of initResolvers) {
120
+ resolve();
121
+ }
122
+ initResolvers = [];
123
+ }
112
124
  }
113
125
 
114
126
  /**
@@ -170,6 +182,35 @@ function isCacheInitialized() {
170
182
  return cacheTimestamp !== null;
171
183
  }
172
184
 
185
+ /**
186
+ * Wait for cache to be initialized
187
+ * Returns immediately if already initialized, otherwise waits up to MAX_WAIT_MS
188
+ */
189
+ function waitForInitialization() {
190
+ if (cacheTimestamp !== null) {
191
+ return Promise.resolve();
192
+ }
193
+
194
+ return new Promise((resolve, reject) => {
195
+ const timeout = setTimeout(() => {
196
+ // Remove this resolver from the list
197
+ const idx = initResolvers.indexOf(resolve);
198
+ if (idx !== -1) {
199
+ initResolvers.splice(idx, 1);
200
+ }
201
+ reject(new Error("Cache initialization timeout"));
202
+ }, MAX_WAIT_MS);
203
+
204
+ // Wrap resolver to clear timeout
205
+ const wrappedResolve = () => {
206
+ clearTimeout(timeout);
207
+ resolve();
208
+ };
209
+
210
+ initResolvers.push(wrappedResolve);
211
+ });
212
+ }
213
+
173
214
  /**
174
215
  * Get the raw cached projects (for initial load)
175
216
  */
@@ -190,6 +231,7 @@ export {
190
231
  generateETag,
191
232
  getCacheMeta,
192
233
  isCacheInitialized,
234
+ waitForInitialization,
193
235
  getCachedProjects,
194
236
  getProjectFromCache,
195
237
  TIMEFRAME_MS,
@@ -9,6 +9,7 @@ import {
9
9
  generateETag,
10
10
  getCacheMeta,
11
11
  isCacheInitialized,
12
+ waitForInitialization,
12
13
  TIMEFRAME_MS,
13
14
  } from "../projects-cache.js";
14
15
 
@@ -26,14 +27,18 @@ const router = express.Router();
26
27
  * Headers:
27
28
  * - If-None-Match: ETag from previous response (for 304 support)
28
29
  */
29
- router.get("/list", (req, res) => {
30
+ router.get("/list", async (req, res) => {
30
31
  try {
31
- // Check if cache is initialized
32
+ // Wait for cache to be initialized (blocks until ready or timeout)
32
33
  if (!isCacheInitialized()) {
33
- return res.status(503).json({
34
- error: "Projects cache not yet initialized",
35
- message: "Please wait for initial project scan to complete",
36
- });
34
+ try {
35
+ await waitForInitialization();
36
+ } catch (err) {
37
+ return res.status(503).json({
38
+ error: "Projects cache initialization timeout",
39
+ message: err.message,
40
+ });
41
+ }
37
42
  }
38
43
 
39
44
  // Get timeframe from query (validate against known values)
@@ -13,6 +13,7 @@ import {
13
13
  generateETag,
14
14
  getCacheMeta,
15
15
  isCacheInitialized,
16
+ waitForInitialization,
16
17
  TIMEFRAME_MS,
17
18
  } from "../sessions-cache.js";
18
19
 
@@ -31,14 +32,18 @@ const router = express.Router();
31
32
  * - 304 Not Modified (if ETag matches)
32
33
  * - 200 OK with sessions data
33
34
  */
34
- router.get("/list", (req, res) => {
35
+ router.get("/list", async (req, res) => {
35
36
  try {
36
- // Check if cache is initialized
37
+ // Wait for cache to be initialized (blocks until ready or timeout)
37
38
  if (!isCacheInitialized()) {
38
- return res.status(503).json({
39
- error: "Sessions cache not yet initialized",
40
- message: "Please wait for initial project scan to complete",
41
- });
39
+ try {
40
+ await waitForInitialization();
41
+ } catch (err) {
42
+ return res.status(503).json({
43
+ error: "Sessions cache initialization timeout",
44
+ message: err.message,
45
+ });
46
+ }
42
47
  }
43
48
 
44
49
  // Get timeframe from query (validate against known values)