@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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Session Lock Manager
3
+ *
4
+ * Manages exclusive locks for chat sessions to prevent conflicts between
5
+ * multiple clients trying to use the same session simultaneously.
6
+ *
7
+ * Lock Types:
8
+ * - 'chat': Exclusive lock for chat queries (blocks shell input)
9
+ * - 'shell': Advisory lock for shell activity (warns chat users)
10
+ */
11
+
12
+ class SessionLock {
13
+ constructor() {
14
+ // sessionKey -> { clientId, mode, acquiredAt, metadata }
15
+ this.locks = new Map();
16
+
17
+ // Event listeners for lock state changes
18
+ this.listeners = new Set();
19
+ }
20
+
21
+ /**
22
+ * Attempt to acquire a lock for a session
23
+ * @param {string} sessionKey - The session identifier
24
+ * @param {string} clientId - The client requesting the lock
25
+ * @param {string} mode - 'chat' or 'shell'
26
+ * @param {object} metadata - Optional metadata (e.g., queryId)
27
+ * @returns {{ success: boolean, holder?: LockInfo, reason?: string }}
28
+ */
29
+ acquireLock(sessionKey, clientId, mode, metadata = {}) {
30
+ const existing = this.locks.get(sessionKey);
31
+
32
+ if (existing) {
33
+ // Check if same client already holds the lock
34
+ if (existing.clientId === clientId) {
35
+ // Update mode if needed
36
+ if (existing.mode !== mode) {
37
+ existing.mode = mode;
38
+ existing.metadata = { ...existing.metadata, ...metadata };
39
+ this.notifyListeners("updated", sessionKey, existing);
40
+ }
41
+ return { success: true };
42
+ }
43
+
44
+ // Another client holds the lock
45
+ return {
46
+ success: false,
47
+ holder: { ...existing },
48
+ reason: `Session is locked by ${existing.mode} operation`,
49
+ };
50
+ }
51
+
52
+ // No existing lock, acquire it
53
+ const lockInfo = {
54
+ clientId,
55
+ mode,
56
+ acquiredAt: Date.now(),
57
+ metadata,
58
+ };
59
+
60
+ this.locks.set(sessionKey, lockInfo);
61
+ this.notifyListeners("acquired", sessionKey, lockInfo);
62
+
63
+ return { success: true };
64
+ }
65
+
66
+ /**
67
+ * Release a lock held by a specific client
68
+ * @param {string} sessionKey - The session identifier
69
+ * @param {string} clientId - The client releasing the lock
70
+ * @returns {boolean} - True if lock was released
71
+ */
72
+ releaseLock(sessionKey, clientId) {
73
+ const existing = this.locks.get(sessionKey);
74
+
75
+ if (!existing) {
76
+ return false;
77
+ }
78
+
79
+ if (existing.clientId !== clientId) {
80
+ // Can't release someone else's lock
81
+ return false;
82
+ }
83
+
84
+ this.locks.delete(sessionKey);
85
+ this.notifyListeners("released", sessionKey, existing);
86
+
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Get the current lock status for a session
92
+ * @param {string} sessionKey - The session identifier
93
+ * @returns {LockInfo | null}
94
+ */
95
+ getLockStatus(sessionKey) {
96
+ const lock = this.locks.get(sessionKey);
97
+ return lock ? { ...lock } : null;
98
+ }
99
+
100
+ /**
101
+ * Check if a session is locked
102
+ * @param {string} sessionKey - The session identifier
103
+ * @returns {boolean}
104
+ */
105
+ isLocked(sessionKey) {
106
+ return this.locks.has(sessionKey);
107
+ }
108
+
109
+ /**
110
+ * Check if a session is locked by a specific mode
111
+ * @param {string} sessionKey - The session identifier
112
+ * @param {string} mode - 'chat' or 'shell'
113
+ * @returns {boolean}
114
+ */
115
+ isLockedByMode(sessionKey, mode) {
116
+ const lock = this.locks.get(sessionKey);
117
+ return lock?.mode === mode;
118
+ }
119
+
120
+ /**
121
+ * Force release a lock (for kick functionality)
122
+ * @param {string} sessionKey - The session identifier
123
+ * @returns {LockInfo | null} - The released lock info, or null if none
124
+ */
125
+ forceRelease(sessionKey) {
126
+ const existing = this.locks.get(sessionKey);
127
+
128
+ if (!existing) {
129
+ return null;
130
+ }
131
+
132
+ this.locks.delete(sessionKey);
133
+ this.notifyListeners("force-released", sessionKey, existing);
134
+
135
+ return existing;
136
+ }
137
+
138
+ /**
139
+ * Get all active locks
140
+ * @returns {Map<string, LockInfo>}
141
+ */
142
+ getAllLocks() {
143
+ return new Map(this.locks);
144
+ }
145
+
146
+ /**
147
+ * Get locks for a specific client
148
+ * @param {string} clientId - The client identifier
149
+ * @returns {Array<{ sessionKey: string, lock: LockInfo }>}
150
+ */
151
+ getClientLocks(clientId) {
152
+ const result = [];
153
+ for (const [sessionKey, lock] of this.locks) {
154
+ if (lock.clientId === clientId) {
155
+ result.push({ sessionKey, lock: { ...lock } });
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Release all locks held by a client (e.g., on disconnect)
163
+ * @param {string} clientId - The client identifier
164
+ * @returns {number} - Number of locks released
165
+ */
166
+ releaseClientLocks(clientId) {
167
+ const toRelease = [];
168
+
169
+ for (const [sessionKey, lock] of this.locks) {
170
+ if (lock.clientId === clientId) {
171
+ toRelease.push(sessionKey);
172
+ }
173
+ }
174
+
175
+ for (const sessionKey of toRelease) {
176
+ const lock = this.locks.get(sessionKey);
177
+ this.locks.delete(sessionKey);
178
+ this.notifyListeners("released", sessionKey, lock);
179
+ }
180
+
181
+ return toRelease.length;
182
+ }
183
+
184
+ /**
185
+ * Add a listener for lock state changes
186
+ * @param {function} listener - Callback function(event, sessionKey, lockInfo)
187
+ */
188
+ addListener(listener) {
189
+ this.listeners.add(listener);
190
+ }
191
+
192
+ /**
193
+ * Remove a listener
194
+ * @param {function} listener - The listener to remove
195
+ */
196
+ removeListener(listener) {
197
+ this.listeners.delete(listener);
198
+ }
199
+
200
+ /**
201
+ * Notify all listeners of a lock state change
202
+ * @private
203
+ */
204
+ notifyListeners(event, sessionKey, lockInfo) {
205
+ for (const listener of this.listeners) {
206
+ try {
207
+ listener(event, sessionKey, lockInfo);
208
+ } catch (err) {
209
+ console.error("[SessionLock] Listener error:", err);
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Clean up stale locks (locks held longer than maxAge)
216
+ * @param {number} maxAge - Maximum age in milliseconds
217
+ * @returns {number} - Number of locks cleaned up
218
+ */
219
+ cleanupStaleLocks(maxAge = 30 * 60 * 1000) {
220
+ const now = Date.now();
221
+ const toCleanup = [];
222
+
223
+ for (const [sessionKey, lock] of this.locks) {
224
+ if (now - lock.acquiredAt > maxAge) {
225
+ toCleanup.push(sessionKey);
226
+ }
227
+ }
228
+
229
+ for (const sessionKey of toCleanup) {
230
+ const lock = this.locks.get(sessionKey);
231
+ this.locks.delete(sessionKey);
232
+ this.notifyListeners("expired", sessionKey, lock);
233
+ }
234
+
235
+ return toCleanup.length;
236
+ }
237
+ }
238
+
239
+ // Singleton instance
240
+ const sessionLock = new SessionLock();
241
+
242
+ // Periodic cleanup of stale locks (every 5 minutes)
243
+ setInterval(
244
+ () => {
245
+ const cleaned = sessionLock.cleanupStaleLocks();
246
+ if (cleaned > 0) {
247
+ console.log(`[SessionLock] Cleaned up ${cleaned} stale locks`);
248
+ }
249
+ },
250
+ 5 * 60 * 1000,
251
+ );
252
+
253
+ export { SessionLock, sessionLock };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * SESSIONS CACHE MODULE
3
+ * ====================
4
+ *
5
+ * In-memory cache for sessions data with ETag support.
6
+ * Updated by the chokidar watcher when project files change.
7
+ */
8
+
9
+ import crypto from "crypto";
10
+
11
+ // Cache state
12
+ let cachedSessions = [];
13
+ let cacheVersion = 0;
14
+ let cacheTimestamp = null;
15
+ let lastProjectsData = null;
16
+
17
+ /**
18
+ * Timeframe definitions in milliseconds
19
+ */
20
+ const TIMEFRAME_MS = {
21
+ "1h": 60 * 60 * 1000,
22
+ "8h": 8 * 60 * 60 * 1000,
23
+ "1d": 24 * 60 * 60 * 1000,
24
+ "1w": 7 * 24 * 60 * 60 * 1000,
25
+ "2w": 14 * 24 * 60 * 60 * 1000,
26
+ "1m": 30 * 24 * 60 * 60 * 1000,
27
+ all: Infinity,
28
+ };
29
+
30
+ /**
31
+ * Update the sessions cache from projects data
32
+ * Called after getProjects() completes
33
+ */
34
+ function updateSessionsCache(projects) {
35
+ const sessions = [];
36
+
37
+ for (const project of projects) {
38
+ // Process Claude sessions
39
+ if (project.sessions) {
40
+ for (const session of project.sessions) {
41
+ sessions.push({
42
+ id: session.id,
43
+ summary: session.summary || "New Session",
44
+ lastActivity: session.lastActivity,
45
+ messageCount: session.messageCount || 0,
46
+ provider: "claude",
47
+ cwd: session.cwd || project.path,
48
+ project: {
49
+ name: project.name,
50
+ displayName: project.displayName,
51
+ fullPath: project.fullPath || project.path,
52
+ },
53
+ });
54
+ }
55
+ }
56
+
57
+ // Process Cursor sessions
58
+ if (project.cursorSessions) {
59
+ for (const session of project.cursorSessions) {
60
+ sessions.push({
61
+ id: session.id,
62
+ summary: session.name || "Cursor Session",
63
+ lastActivity: session.createdAt || session.lastActivity,
64
+ messageCount: session.messageCount || 0,
65
+ provider: "cursor",
66
+ cwd: session.projectPath || project.path,
67
+ project: {
68
+ name: project.name,
69
+ displayName: project.displayName,
70
+ fullPath: project.fullPath || project.path,
71
+ },
72
+ });
73
+ }
74
+ }
75
+
76
+ // Process Codex sessions
77
+ if (project.codexSessions) {
78
+ for (const session of project.codexSessions) {
79
+ sessions.push({
80
+ id: session.id,
81
+ summary: session.summary || session.name || "Codex Session",
82
+ lastActivity: session.lastActivity || session.createdAt,
83
+ messageCount: session.messageCount || 0,
84
+ provider: "codex",
85
+ cwd: session.cwd || project.path,
86
+ project: {
87
+ name: project.name,
88
+ displayName: project.displayName,
89
+ fullPath: project.fullPath || project.path,
90
+ },
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ // Sort by lastActivity descending
97
+ sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
98
+
99
+ cachedSessions = sessions;
100
+ cacheVersion++;
101
+ cacheTimestamp = new Date().toISOString();
102
+ lastProjectsData = projects;
103
+ }
104
+
105
+ /**
106
+ * Get sessions filtered by timeframe
107
+ */
108
+ function getSessionsByTimeframe(timeframe = "1w") {
109
+ const now = Date.now();
110
+ const cutoffMs = TIMEFRAME_MS[timeframe] || TIMEFRAME_MS["1w"];
111
+
112
+ if (cutoffMs === Infinity) {
113
+ return {
114
+ sessions: cachedSessions,
115
+ totalCount: cachedSessions.length,
116
+ filteredCount: cachedSessions.length,
117
+ };
118
+ }
119
+
120
+ const cutoffTime = now - cutoffMs;
121
+ const filteredSessions = cachedSessions.filter((session) => {
122
+ const sessionTime = new Date(session.lastActivity).getTime();
123
+ return sessionTime >= cutoffTime;
124
+ });
125
+
126
+ return {
127
+ sessions: filteredSessions,
128
+ totalCount: cachedSessions.length,
129
+ filteredCount: filteredSessions.length,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Generate ETag for current cache state + timeframe
135
+ */
136
+ function generateETag(timeframe = "1w") {
137
+ const hash = crypto.createHash("md5");
138
+ hash.update(`${cacheVersion}-${cacheTimestamp}-${timeframe}`);
139
+ return `"${hash.digest("hex")}"`;
140
+ }
141
+
142
+ /**
143
+ * Get cache metadata
144
+ */
145
+ function getCacheMeta() {
146
+ return {
147
+ version: cacheVersion,
148
+ timestamp: cacheTimestamp,
149
+ sessionCount: cachedSessions.length,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Check if cache is initialized
155
+ */
156
+ function isCacheInitialized() {
157
+ return cacheTimestamp !== null;
158
+ }
159
+
160
+ /**
161
+ * Get the raw cached sessions (for initial load)
162
+ */
163
+ function getCachedSessions() {
164
+ return cachedSessions;
165
+ }
166
+
167
+ /**
168
+ * Get last projects data (for refreshing the cache)
169
+ */
170
+ function getLastProjectsData() {
171
+ return lastProjectsData;
172
+ }
173
+
174
+ export {
175
+ updateSessionsCache,
176
+ getSessionsByTimeframe,
177
+ generateETag,
178
+ getCacheMeta,
179
+ isCacheInitialized,
180
+ getCachedSessions,
181
+ getLastProjectsData,
182
+ TIMEFRAME_MS,
183
+ };