@epiphytic/claudecodeui 1.2.2 → 1.3.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-CceDF8mT.css +32 -0
- package/dist/assets/index-Dlv06cpK.js +1245 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +44 -4
- package/package.json +4 -2
- package/public/sw.js +44 -4
- package/server/database/db.js +26 -16
- package/server/database/init.sql +2 -1
- package/server/database.js +861 -0
- package/server/db-indexer.js +401 -0
- package/server/external-session-detector.js +48 -392
- package/server/history-cache.js +354 -0
- package/server/index.js +457 -48
- package/server/logger.js +59 -0
- package/server/maintenance-scheduler.js +172 -0
- package/server/messages-cache.js +485 -0
- package/server/openai-codex.js +110 -102
- package/server/orchestrator/client.js +52 -0
- package/server/process-cache.js +513 -0
- package/server/projects.js +64 -47
- package/server/routes/auth.js +18 -12
- package/server/routes/sessions.js +59 -33
- package/server/session-lock.js +2 -10
- package/server/sessions-cache.js +16 -0
- package/dist/assets/index-BGneYLVE.css +0 -32
- package/dist/assets/index-DM1BeYBg.js +0 -1245
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process Cache Module
|
|
3
|
+
*
|
|
4
|
+
* Keeps Claude process information in memory with periodic updates.
|
|
5
|
+
* This avoids expensive process scans on every API request.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Background refresh every 60 seconds
|
|
9
|
+
* - Immediate availability of cached data
|
|
10
|
+
* - Detection of Claude CLI processes
|
|
11
|
+
* - Detection of Claude tmux sessions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "child_process";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import { createLogger } from "./logger.js";
|
|
17
|
+
|
|
18
|
+
const log = createLogger("process-cache");
|
|
19
|
+
|
|
20
|
+
// Adaptive cache update intervals
|
|
21
|
+
const IDLE_CACHE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes when idle
|
|
22
|
+
const ACTIVE_CACHE_UPDATE_INTERVAL = 60 * 1000; // 1 minute when active
|
|
23
|
+
|
|
24
|
+
// Legacy export for compatibility
|
|
25
|
+
const CACHE_UPDATE_INTERVAL = IDLE_CACHE_UPDATE_INTERVAL;
|
|
26
|
+
|
|
27
|
+
// Active mode flag (active when WebSocket clients are connected)
|
|
28
|
+
let isActiveMode = false;
|
|
29
|
+
|
|
30
|
+
// Cached data structure
|
|
31
|
+
const processCache = {
|
|
32
|
+
processes: [],
|
|
33
|
+
tmuxSessions: [],
|
|
34
|
+
detectionAvailable: true,
|
|
35
|
+
detectionError: null,
|
|
36
|
+
lastUpdated: null,
|
|
37
|
+
isUpdating: false,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Current process ID for exclusion
|
|
41
|
+
const currentPid = process.pid;
|
|
42
|
+
|
|
43
|
+
// Update interval reference
|
|
44
|
+
let updateInterval = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if a command is an external Claude process (not spawned by us)
|
|
48
|
+
* @param {string} command - The process command line
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function isExternalClaudeProcess(command) {
|
|
52
|
+
// Skip node processes (SDK internals)
|
|
53
|
+
if (command.startsWith("node ")) {
|
|
54
|
+
log.debug({ command: command.slice(0, 60) }, "Rejected: starts with node");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip our own server
|
|
59
|
+
if (command.includes("claudecodeui/server")) {
|
|
60
|
+
log.debug(
|
|
61
|
+
{ command: command.slice(0, 60) },
|
|
62
|
+
"Rejected: contains claudecodeui/server",
|
|
63
|
+
);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Look for actual claude CLI invocations
|
|
68
|
+
const isExternal =
|
|
69
|
+
command.includes("claude ") ||
|
|
70
|
+
command.includes("claude-code") ||
|
|
71
|
+
command.match(/\/claude\s/) ||
|
|
72
|
+
command.endsWith("/claude");
|
|
73
|
+
|
|
74
|
+
log.debug({ command: command.slice(0, 60), isExternal }, "Process check");
|
|
75
|
+
return isExternal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect Claude processes on the system
|
|
80
|
+
* Uses efficient batch lsof call instead of per-process calls
|
|
81
|
+
* @returns {{ processes: Array, detectionAvailable: boolean, error: string | null }}
|
|
82
|
+
*/
|
|
83
|
+
function scanClaudeProcesses() {
|
|
84
|
+
const processes = [];
|
|
85
|
+
let detectionAvailable = true;
|
|
86
|
+
let error = null;
|
|
87
|
+
|
|
88
|
+
log.debug({ platform: os.platform(), currentPid }, "Scanning for processes");
|
|
89
|
+
|
|
90
|
+
if (os.platform() === "win32") {
|
|
91
|
+
// Windows: use wmic or tasklist
|
|
92
|
+
try {
|
|
93
|
+
const result = spawnSync(
|
|
94
|
+
"wmic",
|
|
95
|
+
[
|
|
96
|
+
"process",
|
|
97
|
+
"where",
|
|
98
|
+
"name like '%claude%'",
|
|
99
|
+
"get",
|
|
100
|
+
"processid,commandline",
|
|
101
|
+
],
|
|
102
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (result.status === 0) {
|
|
106
|
+
const lines = result.stdout.trim().split("\n").slice(1);
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const match = line.match(/(\d+)\s*$/);
|
|
109
|
+
if (match) {
|
|
110
|
+
const pid = parseInt(match[1], 10);
|
|
111
|
+
if (pid !== currentPid) {
|
|
112
|
+
processes.push({ pid, command: line.trim(), cwd: null });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else if (result.error) {
|
|
117
|
+
detectionAvailable = false;
|
|
118
|
+
error = `wmic not available: ${result.error.message}`;
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
detectionAvailable = false;
|
|
122
|
+
error = `Windows process detection failed: ${e.message}`;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Unix: use pgrep and efficient batch lsof
|
|
126
|
+
try {
|
|
127
|
+
const pgrepCheck = spawnSync("which", ["pgrep"], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: "pipe",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (pgrepCheck.status !== 0) {
|
|
133
|
+
// pgrep not available, try ps aux as fallback
|
|
134
|
+
log.debug("Using ps aux fallback");
|
|
135
|
+
try {
|
|
136
|
+
const psResult = spawnSync("ps", ["aux"], {
|
|
137
|
+
encoding: "utf8",
|
|
138
|
+
stdio: "pipe",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (psResult.status === 0) {
|
|
142
|
+
const lines = psResult.stdout.split("\n");
|
|
143
|
+
const claudeLines = lines.filter(
|
|
144
|
+
(line) =>
|
|
145
|
+
line.toLowerCase().includes("claude") &&
|
|
146
|
+
!line.includes(String(currentPid)),
|
|
147
|
+
);
|
|
148
|
+
log.debug({ count: claudeLines.length }, "Found claude lines");
|
|
149
|
+
|
|
150
|
+
for (const line of claudeLines) {
|
|
151
|
+
const parts = line.trim().split(/\s+/);
|
|
152
|
+
if (parts.length >= 2) {
|
|
153
|
+
const pid = parseInt(parts[1], 10);
|
|
154
|
+
if (!isNaN(pid) && pid !== currentPid) {
|
|
155
|
+
const command = parts.slice(10).join(" ");
|
|
156
|
+
if (isExternalClaudeProcess(command)) {
|
|
157
|
+
processes.push({ pid, command, cwd: null });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
detectionAvailable = false;
|
|
164
|
+
error = "Neither pgrep nor ps aux available";
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
detectionAvailable = false;
|
|
168
|
+
error = `Process detection failed: ${e.message}`;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// pgrep is available - get all PIDs at once
|
|
172
|
+
const pgrepResult = spawnSync("pgrep", ["-f", "claude"], {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
stdio: "pipe",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (pgrepResult.status === 0) {
|
|
178
|
+
const allPids = pgrepResult.stdout.trim().split("\n").filter(Boolean);
|
|
179
|
+
const pids = allPids
|
|
180
|
+
.map((p) => parseInt(p, 10))
|
|
181
|
+
.filter((p) => p !== currentPid);
|
|
182
|
+
|
|
183
|
+
log.debug({ foundPids: allPids.length, filteredPids: pids.length }, "pgrep found PIDs");
|
|
184
|
+
|
|
185
|
+
if (pids.length === 0) {
|
|
186
|
+
return { processes, detectionAvailable, error };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Get command details for all PIDs in a single ps call
|
|
190
|
+
const psResult = spawnSync(
|
|
191
|
+
"ps",
|
|
192
|
+
["-p", pids.join(","), "-o", "pid=,args="],
|
|
193
|
+
{
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
stdio: "pipe",
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Build a map of PID -> command, filtering to only external Claude processes
|
|
200
|
+
const pidCommands = new Map();
|
|
201
|
+
if (psResult.status === 0) {
|
|
202
|
+
const lines = psResult.stdout.trim().split("\n").filter(Boolean);
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
205
|
+
if (match) {
|
|
206
|
+
const pid = parseInt(match[1], 10);
|
|
207
|
+
const command = match[2];
|
|
208
|
+
if (isExternalClaudeProcess(command)) {
|
|
209
|
+
pidCommands.set(pid, command);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (pidCommands.size === 0) {
|
|
216
|
+
log.debug("No external Claude processes after filtering");
|
|
217
|
+
return { processes, detectionAvailable, error };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Get cwds for all valid PIDs in a SINGLE lsof call
|
|
221
|
+
// Using -d cwd to only get cwd file descriptors (much faster than full file list)
|
|
222
|
+
// Note: lsof -d cwd returns ALL processes; we filter by our PIDs afterward
|
|
223
|
+
const validPids = new Set(pidCommands.keys());
|
|
224
|
+
const cwdMap = new Map();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const lsofResult = spawnSync(
|
|
228
|
+
"lsof",
|
|
229
|
+
["-d", "cwd", "-Fn"],
|
|
230
|
+
{
|
|
231
|
+
encoding: "utf8",
|
|
232
|
+
stdio: "pipe",
|
|
233
|
+
timeout: 10000, // 10 second timeout
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (lsofResult.status === 0) {
|
|
238
|
+
// Parse lsof output: p<pid>\nfcwd\nn<path>\n format
|
|
239
|
+
// Filter to only the PIDs we care about
|
|
240
|
+
const lines = lsofResult.stdout.split("\n");
|
|
241
|
+
let currentPid = null;
|
|
242
|
+
let isCwdEntry = false;
|
|
243
|
+
|
|
244
|
+
for (const line of lines) {
|
|
245
|
+
if (line.startsWith("p")) {
|
|
246
|
+
currentPid = parseInt(line.slice(1), 10);
|
|
247
|
+
isCwdEntry = false;
|
|
248
|
+
} else if (line === "fcwd") {
|
|
249
|
+
isCwdEntry = true;
|
|
250
|
+
} else if (line.startsWith("n") && currentPid && isCwdEntry) {
|
|
251
|
+
// Only capture cwd for our target PIDs
|
|
252
|
+
if (validPids.has(currentPid)) {
|
|
253
|
+
const path = line.slice(1);
|
|
254
|
+
if (path.startsWith("/")) {
|
|
255
|
+
cwdMap.set(currentPid, path);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
isCwdEntry = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
log.debug(
|
|
263
|
+
{ targetPids: validPids.size, cwdsFound: cwdMap.size },
|
|
264
|
+
"Batch lsof complete",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
log.debug("lsof not available for cwd detection");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Build final process list
|
|
272
|
+
for (const [pid, command] of pidCommands) {
|
|
273
|
+
processes.push({
|
|
274
|
+
pid,
|
|
275
|
+
command,
|
|
276
|
+
cwd: cwdMap.get(pid) || null,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
log.debug({ status: pgrepResult.status }, "pgrep found no processes");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
detectionAvailable = false;
|
|
285
|
+
error = `Unix process detection failed: ${e.message}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { processes, detectionAvailable, error };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Heuristic check if a tmux session might be running Claude
|
|
294
|
+
* @param {string} sessionName - The session name
|
|
295
|
+
* @returns {boolean}
|
|
296
|
+
*/
|
|
297
|
+
function mightBeClaudeSession(sessionName) {
|
|
298
|
+
const claudePatterns = ["claude", "ai", "chat", "code"];
|
|
299
|
+
const lowerName = sessionName.toLowerCase();
|
|
300
|
+
|
|
301
|
+
for (const pattern of claudePatterns) {
|
|
302
|
+
if (lowerName.includes(pattern)) return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Try to peek at the session's current command
|
|
306
|
+
try {
|
|
307
|
+
const result = spawnSync(
|
|
308
|
+
"tmux",
|
|
309
|
+
["display-message", "-t", sessionName, "-p", "#{pane_current_command}"],
|
|
310
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
if (result.status === 0) {
|
|
314
|
+
const currentCommand = result.stdout.trim().toLowerCase();
|
|
315
|
+
if (currentCommand.includes("claude")) return true;
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Ignore
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Detect tmux sessions that might be running Claude
|
|
326
|
+
* @returns {Array<{ sessionName: string, windows: number, attached: boolean }>}
|
|
327
|
+
*/
|
|
328
|
+
function scanClaudeTmuxSessions() {
|
|
329
|
+
const sessions = [];
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const result = spawnSync(
|
|
333
|
+
"tmux",
|
|
334
|
+
[
|
|
335
|
+
"list-sessions",
|
|
336
|
+
"-F",
|
|
337
|
+
"#{session_name}:#{session_windows}:#{session_attached}",
|
|
338
|
+
],
|
|
339
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
if (result.status === 0) {
|
|
343
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
344
|
+
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
const [sessionName, windows, attached] = line.split(":");
|
|
347
|
+
|
|
348
|
+
// Skip our own sessions
|
|
349
|
+
if (sessionName.startsWith("claudeui-")) continue;
|
|
350
|
+
|
|
351
|
+
if (mightBeClaudeSession(sessionName)) {
|
|
352
|
+
sessions.push({
|
|
353
|
+
sessionName,
|
|
354
|
+
windows: parseInt(windows, 10),
|
|
355
|
+
attached: attached === "1",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// tmux not available or no server running
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return sessions;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Update the process cache with fresh data
|
|
369
|
+
*/
|
|
370
|
+
async function updateCache() {
|
|
371
|
+
if (processCache.isUpdating) {
|
|
372
|
+
log.debug("Cache update already in progress, skipping");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
processCache.isUpdating = true;
|
|
377
|
+
const startTime = Date.now();
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
log.debug("Starting cache update");
|
|
381
|
+
|
|
382
|
+
// Scan for processes
|
|
383
|
+
const processResult = scanClaudeProcesses();
|
|
384
|
+
processCache.processes = processResult.processes;
|
|
385
|
+
processCache.detectionAvailable = processResult.detectionAvailable;
|
|
386
|
+
processCache.detectionError = processResult.error;
|
|
387
|
+
|
|
388
|
+
// Scan for tmux sessions
|
|
389
|
+
processCache.tmuxSessions = scanClaudeTmuxSessions();
|
|
390
|
+
|
|
391
|
+
processCache.lastUpdated = Date.now();
|
|
392
|
+
|
|
393
|
+
const duration = Date.now() - startTime;
|
|
394
|
+
log.info(
|
|
395
|
+
{
|
|
396
|
+
processCount: processCache.processes.length,
|
|
397
|
+
tmuxCount: processCache.tmuxSessions.length,
|
|
398
|
+
durationMs: duration,
|
|
399
|
+
},
|
|
400
|
+
"Process cache updated",
|
|
401
|
+
);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
log.error({ error: e.message }, "Failed to update process cache");
|
|
404
|
+
processCache.detectionError = e.message;
|
|
405
|
+
} finally {
|
|
406
|
+
processCache.isUpdating = false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get the current update interval based on active mode
|
|
412
|
+
*/
|
|
413
|
+
function getCurrentInterval() {
|
|
414
|
+
return isActiveMode
|
|
415
|
+
? ACTIVE_CACHE_UPDATE_INTERVAL
|
|
416
|
+
: IDLE_CACHE_UPDATE_INTERVAL;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Start the background cache update loop
|
|
421
|
+
*/
|
|
422
|
+
function startCacheUpdater() {
|
|
423
|
+
// Perform initial update immediately
|
|
424
|
+
updateCache();
|
|
425
|
+
|
|
426
|
+
// Schedule periodic updates (start in idle mode)
|
|
427
|
+
updateInterval = setInterval(updateCache, getCurrentInterval());
|
|
428
|
+
|
|
429
|
+
log.info(
|
|
430
|
+
{ intervalMs: getCurrentInterval(), isActiveMode },
|
|
431
|
+
"Process cache updater started",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Set the process cache active mode
|
|
437
|
+
* When active (WebSocket clients connected), updates more frequently
|
|
438
|
+
* @param {boolean} active - Whether the cache should be in active mode
|
|
439
|
+
*/
|
|
440
|
+
function setProcessCacheActive(active) {
|
|
441
|
+
if (isActiveMode === active) return;
|
|
442
|
+
|
|
443
|
+
isActiveMode = active;
|
|
444
|
+
|
|
445
|
+
// Restart the interval with the new timing
|
|
446
|
+
if (updateInterval) {
|
|
447
|
+
clearInterval(updateInterval);
|
|
448
|
+
updateInterval = setInterval(updateCache, getCurrentInterval());
|
|
449
|
+
|
|
450
|
+
log.info(
|
|
451
|
+
{ isActiveMode, intervalMs: getCurrentInterval() },
|
|
452
|
+
"Process cache interval updated",
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// If becoming active, do an immediate update
|
|
456
|
+
if (active) {
|
|
457
|
+
updateCache();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Stop the background cache update loop
|
|
464
|
+
*/
|
|
465
|
+
function stopCacheUpdater() {
|
|
466
|
+
if (updateInterval) {
|
|
467
|
+
clearInterval(updateInterval);
|
|
468
|
+
updateInterval = null;
|
|
469
|
+
log.info("Process cache updater stopped");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Get cached process data
|
|
475
|
+
* @returns {{ processes: Array, tmuxSessions: Array, detectionAvailable: boolean, detectionError: string | null, lastUpdated: number | null }}
|
|
476
|
+
*/
|
|
477
|
+
function getCachedProcessData() {
|
|
478
|
+
return {
|
|
479
|
+
processes: processCache.processes,
|
|
480
|
+
tmuxSessions: processCache.tmuxSessions,
|
|
481
|
+
detectionAvailable: processCache.detectionAvailable,
|
|
482
|
+
detectionError: processCache.detectionError,
|
|
483
|
+
lastUpdated: processCache.lastUpdated,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Force an immediate cache update
|
|
489
|
+
*/
|
|
490
|
+
async function forceUpdate() {
|
|
491
|
+
await updateCache();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Get cache age in milliseconds
|
|
496
|
+
* @returns {number | null}
|
|
497
|
+
*/
|
|
498
|
+
function getCacheAge() {
|
|
499
|
+
if (!processCache.lastUpdated) return null;
|
|
500
|
+
return Date.now() - processCache.lastUpdated;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export {
|
|
504
|
+
startCacheUpdater,
|
|
505
|
+
stopCacheUpdater,
|
|
506
|
+
getCachedProcessData,
|
|
507
|
+
forceUpdate,
|
|
508
|
+
getCacheAge,
|
|
509
|
+
setProcessCacheActive,
|
|
510
|
+
CACHE_UPDATE_INTERVAL,
|
|
511
|
+
IDLE_CACHE_UPDATE_INTERVAL,
|
|
512
|
+
ACTIVE_CACHE_UPDATE_INTERVAL,
|
|
513
|
+
};
|
package/server/projects.js
CHANGED
|
@@ -618,10 +618,11 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|
|
618
618
|
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
|
619
619
|
|
|
620
620
|
const allSessions = new Map();
|
|
621
|
-
const allEntries = [];
|
|
622
621
|
const uuidToSessionMap = new Map();
|
|
622
|
+
const allFirstUserMessages = []; // Lightweight: only { uuid, sessionId }
|
|
623
623
|
|
|
624
|
-
// Collect all sessions and
|
|
624
|
+
// Collect all sessions and lightweight uuid index from all files
|
|
625
|
+
// Memory optimization: we no longer store full entries, only minimal metadata
|
|
625
626
|
for (const { file } of filesWithStats) {
|
|
626
627
|
const jsonlFile = path.join(projectDir, file);
|
|
627
628
|
const result = await parseJsonlSessions(jsonlFile);
|
|
@@ -632,60 +633,52 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|
|
632
633
|
}
|
|
633
634
|
});
|
|
634
635
|
|
|
635
|
-
|
|
636
|
+
// Merge uuid index (lightweight - only uuid -> sessionId mappings)
|
|
637
|
+
result.uuidIndex.uuidToSessionId.forEach((sessionId, uuid) => {
|
|
638
|
+
uuidToSessionMap.set(uuid, sessionId);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Collect first user messages for timeline grouping
|
|
642
|
+
allFirstUserMessages.push(...result.uuidIndex.firstUserMessages);
|
|
636
643
|
|
|
637
644
|
// Early exit optimization for large projects
|
|
638
645
|
if (
|
|
639
646
|
allSessions.size >= (limit + offset) * 2 &&
|
|
640
|
-
|
|
647
|
+
allFirstUserMessages.length >= Math.min(3, filesWithStats.length)
|
|
641
648
|
) {
|
|
642
649
|
break;
|
|
643
650
|
}
|
|
644
651
|
}
|
|
645
652
|
|
|
646
|
-
// Build UUID-to-session mapping for timeline detection
|
|
647
|
-
allEntries.forEach((entry) => {
|
|
648
|
-
if (entry.uuid && entry.sessionId) {
|
|
649
|
-
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
|
650
|
-
}
|
|
651
|
-
});
|
|
652
|
-
|
|
653
653
|
// Group sessions by first user message ID
|
|
654
|
+
// Note: uuidToSessionMap is already built from uuidIndex during file processing above
|
|
654
655
|
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
|
655
656
|
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
|
656
657
|
|
|
657
|
-
// Find the first user message for each session
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
entry.sessionId &&
|
|
661
|
-
entry.type === "user" &&
|
|
662
|
-
entry.parentUuid === null &&
|
|
663
|
-
entry.uuid
|
|
664
|
-
) {
|
|
665
|
-
// This is a first user message in a session (parentUuid is null)
|
|
666
|
-
const firstUserMsgId = entry.uuid;
|
|
667
|
-
|
|
668
|
-
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
|
|
669
|
-
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
|
|
670
|
-
|
|
671
|
-
const session = allSessions.get(entry.sessionId);
|
|
672
|
-
if (session) {
|
|
673
|
-
if (!sessionGroups.has(firstUserMsgId)) {
|
|
674
|
-
sessionGroups.set(firstUserMsgId, {
|
|
675
|
-
latestSession: session,
|
|
676
|
-
allSessions: [session],
|
|
677
|
-
});
|
|
678
|
-
} else {
|
|
679
|
-
const group = sessionGroups.get(firstUserMsgId);
|
|
680
|
-
group.allSessions.push(session);
|
|
658
|
+
// Find the first user message for each session using lightweight index
|
|
659
|
+
allFirstUserMessages.forEach(({ uuid, sessionId }) => {
|
|
660
|
+
const firstUserMsgId = uuid;
|
|
681
661
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
662
|
+
if (!sessionToFirstUserMsgId.has(sessionId)) {
|
|
663
|
+
sessionToFirstUserMsgId.set(sessionId, firstUserMsgId);
|
|
664
|
+
|
|
665
|
+
const session = allSessions.get(sessionId);
|
|
666
|
+
if (session) {
|
|
667
|
+
if (!sessionGroups.has(firstUserMsgId)) {
|
|
668
|
+
sessionGroups.set(firstUserMsgId, {
|
|
669
|
+
latestSession: session,
|
|
670
|
+
allSessions: [session],
|
|
671
|
+
});
|
|
672
|
+
} else {
|
|
673
|
+
const group = sessionGroups.get(firstUserMsgId);
|
|
674
|
+
group.allSessions.push(session);
|
|
675
|
+
|
|
676
|
+
// Update latest session if this one is more recent
|
|
677
|
+
if (
|
|
678
|
+
new Date(session.lastActivity) >
|
|
679
|
+
new Date(group.latestSession.lastActivity)
|
|
680
|
+
) {
|
|
681
|
+
group.latestSession = session;
|
|
689
682
|
}
|
|
690
683
|
}
|
|
691
684
|
}
|
|
@@ -736,7 +729,12 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|
|
736
729
|
|
|
737
730
|
async function parseJsonlSessions(filePath) {
|
|
738
731
|
const sessions = new Map();
|
|
739
|
-
|
|
732
|
+
// Lightweight index for timeline detection - only stores minimal metadata, not full entries
|
|
733
|
+
// This reduces memory from ~2-4GB to ~50MB for large projects
|
|
734
|
+
const uuidIndex = {
|
|
735
|
+
uuidToSessionId: new Map(), // uuid -> sessionId (for timeline detection)
|
|
736
|
+
firstUserMessages: [], // Array of { uuid, sessionId } for entries with parentUuid === null and type === "user"
|
|
737
|
+
};
|
|
740
738
|
const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
|
|
741
739
|
|
|
742
740
|
try {
|
|
@@ -750,7 +748,23 @@ async function parseJsonlSessions(filePath) {
|
|
|
750
748
|
if (line.trim()) {
|
|
751
749
|
try {
|
|
752
750
|
const entry = JSON.parse(line);
|
|
753
|
-
|
|
751
|
+
|
|
752
|
+
// Build lightweight uuid index for timeline detection (instead of storing full entry)
|
|
753
|
+
if (entry.uuid && entry.sessionId) {
|
|
754
|
+
uuidIndex.uuidToSessionId.set(entry.uuid, entry.sessionId);
|
|
755
|
+
}
|
|
756
|
+
// Track first user messages (parentUuid === null) for timeline grouping
|
|
757
|
+
if (
|
|
758
|
+
entry.type === "user" &&
|
|
759
|
+
entry.parentUuid === null &&
|
|
760
|
+
entry.uuid &&
|
|
761
|
+
entry.sessionId
|
|
762
|
+
) {
|
|
763
|
+
uuidIndex.firstUserMessages.push({
|
|
764
|
+
uuid: entry.uuid,
|
|
765
|
+
sessionId: entry.sessionId,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
754
768
|
|
|
755
769
|
// Handle summary entries that don't have sessionId yet
|
|
756
770
|
if (
|
|
@@ -881,7 +895,7 @@ async function parseJsonlSessions(filePath) {
|
|
|
881
895
|
// After processing all entries, set final summary based on last message if no summary exists
|
|
882
896
|
for (const session of sessions.values()) {
|
|
883
897
|
if (session.summary === "New Session") {
|
|
884
|
-
//
|
|
898
|
+
// Use last user message, then last assistant message as fallback
|
|
885
899
|
const lastMessage =
|
|
886
900
|
session.lastUserMessage || session.lastAssistantMessage;
|
|
887
901
|
if (lastMessage) {
|
|
@@ -908,11 +922,14 @@ async function parseJsonlSessions(filePath) {
|
|
|
908
922
|
|
|
909
923
|
return {
|
|
910
924
|
sessions: filteredSessions,
|
|
911
|
-
|
|
925
|
+
uuidIndex: uuidIndex,
|
|
912
926
|
};
|
|
913
927
|
} catch (error) {
|
|
914
928
|
console.error("Error reading JSONL file:", error);
|
|
915
|
-
return {
|
|
929
|
+
return {
|
|
930
|
+
sessions: [],
|
|
931
|
+
uuidIndex: { uuidToSessionId: new Map(), firstUserMessages: [] },
|
|
932
|
+
};
|
|
916
933
|
}
|
|
917
934
|
}
|
|
918
935
|
|