@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.
- package/dist/assets/index-D0xTNXrF.js +1247 -0
- package/dist/assets/index-DKDK7xNY.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/database/db.js +124 -0
- package/server/database/init.sql +15 -1
- package/server/external-session-detector.js +403 -0
- package/server/index.js +816 -110
- package/server/orchestrator/client.js +37 -1
- package/server/projects-cache.js +196 -0
- package/server/projects.js +759 -464
- package/server/routes/projects.js +248 -92
- package/server/routes/sessions.js +106 -0
- package/server/session-lock.js +253 -0
- package/server/sessions-cache.js +183 -0
- package/server/tmux-manager.js +403 -0
- package/dist/assets/index-DfR9xEkp.css +0 -32
- package/dist/assets/index-DvlVn6Eb.js +0 -1231
|
@@ -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
|
+
};
|