@epiphytic/claudecodeui 1.0.0 → 1.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.
@@ -277,7 +277,8 @@ export class OrchestratorClient extends EventEmitter {
277
277
  // Set timeout for pong response
278
278
  this.heartbeatTimeoutTimer = setTimeout(() => {
279
279
  console.warn("[ORCHESTRATOR] Heartbeat timeout, reconnecting...");
280
- this.ws?.terminate();
280
+ // Force close without waiting - terminate() can block if socket is dead
281
+ this.forceReconnect();
281
282
  }, DEFAULTS.heartbeatTimeout);
282
283
  }
283
284
 
@@ -879,6 +880,41 @@ export class OrchestratorClient extends EventEmitter {
879
880
  return result;
880
881
  }
881
882
 
883
+ /**
884
+ * Force reconnect without waiting for socket close
885
+ * This handles the case where the socket is dead (e.g., after macOS sleep)
886
+ * and terminate() would block indefinitely
887
+ */
888
+ forceReconnect() {
889
+ console.log("[ORCHESTRATOR] Force reconnecting...");
890
+
891
+ // Mark as disconnected immediately
892
+ this.isConnected = false;
893
+ this.isRegistered = false;
894
+ this.stopHeartbeat();
895
+
896
+ // Try to close the socket, but don't wait for it
897
+ if (this.ws) {
898
+ try {
899
+ // Destroy the underlying socket directly to avoid blocking
900
+ if (this.ws._socket) {
901
+ this.ws._socket.destroy();
902
+ }
903
+ this.ws.terminate();
904
+ } catch (e) {
905
+ // Ignore errors - socket may already be dead
906
+ }
907
+ this.ws = null;
908
+ }
909
+
910
+ this.emit("disconnected", { code: 1006, reason: "Force reconnect" });
911
+
912
+ // Schedule reconnect
913
+ if (this.shouldReconnect) {
914
+ this.scheduleReconnect();
915
+ }
916
+ }
917
+
882
918
  /**
883
919
  * Starts the heartbeat interval
884
920
  */
@@ -0,0 +1,196 @@
1
+ /**
2
+ * PROJECTS CACHE MODULE
3
+ * =====================
4
+ *
5
+ * In-memory cache for projects data with ETag support.
6
+ * Updated by the chokidar watcher when project files change.
7
+ * Mirrors the sessions-cache.js pattern for consistency.
8
+ */
9
+
10
+ import crypto from "crypto";
11
+
12
+ // Cache state
13
+ let cachedProjects = [];
14
+ let cacheVersion = 0;
15
+ let cacheTimestamp = null;
16
+
17
+ /**
18
+ * Timeframe definitions in milliseconds
19
+ * (Same as sessions-cache.js for consistency)
20
+ */
21
+ const TIMEFRAME_MS = {
22
+ "1h": 60 * 60 * 1000,
23
+ "8h": 8 * 60 * 60 * 1000,
24
+ "1d": 24 * 60 * 60 * 1000,
25
+ "1w": 7 * 24 * 60 * 60 * 1000,
26
+ "2w": 14 * 24 * 60 * 60 * 1000,
27
+ "1m": 30 * 24 * 60 * 60 * 1000,
28
+ all: Infinity,
29
+ };
30
+
31
+ /**
32
+ * Calculate last activity timestamp for a project
33
+ * Based on the most recent session across all providers
34
+ */
35
+ function calculateLastActivity(project) {
36
+ let lastActivity = null;
37
+
38
+ // Check Claude sessions
39
+ if (project.sessions && project.sessions.length > 0) {
40
+ for (const session of project.sessions) {
41
+ const sessionDate = new Date(session.lastActivity);
42
+ if (!lastActivity || sessionDate > lastActivity) {
43
+ lastActivity = sessionDate;
44
+ }
45
+ }
46
+ }
47
+
48
+ // Check Cursor sessions
49
+ if (project.cursorSessions && project.cursorSessions.length > 0) {
50
+ for (const session of project.cursorSessions) {
51
+ const sessionDate = new Date(session.createdAt || session.lastActivity);
52
+ if (!lastActivity || sessionDate > lastActivity) {
53
+ lastActivity = sessionDate;
54
+ }
55
+ }
56
+ }
57
+
58
+ // Check Codex sessions
59
+ if (project.codexSessions && project.codexSessions.length > 0) {
60
+ for (const session of project.codexSessions) {
61
+ const sessionDate = new Date(session.lastActivity || session.createdAt);
62
+ if (!lastActivity || sessionDate > lastActivity) {
63
+ lastActivity = sessionDate;
64
+ }
65
+ }
66
+ }
67
+
68
+ return lastActivity ? lastActivity.toISOString() : null;
69
+ }
70
+
71
+ /**
72
+ * Transform full project data to slim format
73
+ */
74
+ function toSlimProject(project) {
75
+ const claudeCount = project.sessions?.length || 0;
76
+ const cursorCount = project.cursorSessions?.length || 0;
77
+ const codexCount = project.codexSessions?.length || 0;
78
+
79
+ return {
80
+ name: project.name,
81
+ displayName: project.displayName,
82
+ fullPath: project.fullPath || project.path,
83
+ sessionCount: claudeCount + cursorCount + codexCount,
84
+ lastActivity: calculateLastActivity(project),
85
+ hasClaudeSessions: claudeCount > 0,
86
+ hasCursorSessions: cursorCount > 0,
87
+ hasCodexSessions: codexCount > 0,
88
+ hasTaskmaster: project.taskmaster?.hasTaskmaster || false,
89
+ sessionMeta: project.sessionMeta,
90
+ isManuallyAdded: project.isManuallyAdded || false,
91
+ isCustomName: project.isCustomName || false,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Update the projects cache from full projects data
97
+ * Called after getProjects() completes
98
+ */
99
+ function updateProjectsCache(projects) {
100
+ // Transform to slim format
101
+ cachedProjects = projects.map(toSlimProject);
102
+
103
+ // Sort by lastActivity descending (most recent first)
104
+ cachedProjects.sort((a, b) => {
105
+ const dateA = a.lastActivity ? new Date(a.lastActivity) : new Date(0);
106
+ const dateB = b.lastActivity ? new Date(b.lastActivity) : new Date(0);
107
+ return dateB - dateA;
108
+ });
109
+
110
+ cacheVersion++;
111
+ cacheTimestamp = new Date().toISOString();
112
+ }
113
+
114
+ /**
115
+ * Get projects filtered by timeframe
116
+ * Projects are included if their lastActivity is within the timeframe
117
+ */
118
+ function getProjectsByTimeframe(timeframe = "1w") {
119
+ const now = Date.now();
120
+ const cutoffMs = TIMEFRAME_MS[timeframe] || TIMEFRAME_MS["1w"];
121
+
122
+ if (cutoffMs === Infinity) {
123
+ return {
124
+ projects: cachedProjects,
125
+ totalCount: cachedProjects.length,
126
+ filteredCount: cachedProjects.length,
127
+ };
128
+ }
129
+
130
+ const cutoffTime = now - cutoffMs;
131
+ const filteredProjects = cachedProjects.filter((project) => {
132
+ if (!project.lastActivity) {
133
+ return false; // Exclude projects with no sessions
134
+ }
135
+ const projectTime = new Date(project.lastActivity).getTime();
136
+ return projectTime >= cutoffTime;
137
+ });
138
+
139
+ return {
140
+ projects: filteredProjects,
141
+ totalCount: cachedProjects.length,
142
+ filteredCount: filteredProjects.length,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Generate ETag for current cache state + timeframe
148
+ */
149
+ function generateETag(timeframe = "1w") {
150
+ const hash = crypto.createHash("md5");
151
+ hash.update(`projects-${cacheVersion}-${cacheTimestamp}-${timeframe}`);
152
+ return `"${hash.digest("hex")}"`;
153
+ }
154
+
155
+ /**
156
+ * Get cache metadata
157
+ */
158
+ function getCacheMeta() {
159
+ return {
160
+ version: cacheVersion,
161
+ timestamp: cacheTimestamp,
162
+ projectCount: cachedProjects.length,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Check if cache is initialized
168
+ */
169
+ function isCacheInitialized() {
170
+ return cacheTimestamp !== null;
171
+ }
172
+
173
+ /**
174
+ * Get the raw cached projects (for initial load)
175
+ */
176
+ function getCachedProjects() {
177
+ return cachedProjects;
178
+ }
179
+
180
+ /**
181
+ * Get a single project from cache by name
182
+ */
183
+ function getProjectFromCache(projectName) {
184
+ return cachedProjects.find((p) => p.name === projectName) || null;
185
+ }
186
+
187
+ export {
188
+ updateProjectsCache,
189
+ getProjectsByTimeframe,
190
+ generateETag,
191
+ getCacheMeta,
192
+ isCacheInitialized,
193
+ getCachedProjects,
194
+ getProjectFromCache,
195
+ TIMEFRAME_MS,
196
+ };