@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,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External Claude Session Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects Claude CLI sessions running outside of this application.
|
|
5
|
+
* This helps prevent conflicts when users have both the UI and CLI
|
|
6
|
+
* running simultaneously on the same project.
|
|
7
|
+
*
|
|
8
|
+
* Detection methods:
|
|
9
|
+
* 1. Process detection via pgrep/ps
|
|
10
|
+
* 2. tmux session scanning
|
|
11
|
+
* 3. Session lock file detection (.claude/session.lock)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "child_process";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import os from "os";
|
|
18
|
+
|
|
19
|
+
// Cache to avoid repeated process scans
|
|
20
|
+
const detectionCache = new Map();
|
|
21
|
+
const CACHE_TTL = 5000; // 5 seconds
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the current process ID (for exclusion)
|
|
25
|
+
*/
|
|
26
|
+
const currentPid = process.pid;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect external Claude processes
|
|
30
|
+
* @returns {Array<{ pid: number, command: string, cwd: string | null }>}
|
|
31
|
+
*/
|
|
32
|
+
function detectClaudeProcesses() {
|
|
33
|
+
const processes = [];
|
|
34
|
+
|
|
35
|
+
if (os.platform() === "win32") {
|
|
36
|
+
// Windows: use wmic or tasklist
|
|
37
|
+
try {
|
|
38
|
+
const result = spawnSync(
|
|
39
|
+
"wmic",
|
|
40
|
+
[
|
|
41
|
+
"process",
|
|
42
|
+
"where",
|
|
43
|
+
"name like '%claude%'",
|
|
44
|
+
"get",
|
|
45
|
+
"processid,commandline",
|
|
46
|
+
],
|
|
47
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (result.status === 0) {
|
|
51
|
+
const lines = result.stdout.trim().split("\n").slice(1); // Skip header
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const match = line.match(/(\d+)\s*$/);
|
|
54
|
+
if (match) {
|
|
55
|
+
const pid = parseInt(match[1], 10);
|
|
56
|
+
if (pid !== currentPid) {
|
|
57
|
+
processes.push({ pid, command: line.trim(), cwd: null });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Ignore errors on Windows
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Unix: use pgrep and ps
|
|
67
|
+
try {
|
|
68
|
+
// First, get PIDs of claude processes
|
|
69
|
+
const pgrepResult = spawnSync("pgrep", ["-f", "claude"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
stdio: "pipe",
|
|
72
|
+
});
|
|
73
|
+
|
|
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="], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: "pipe",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
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];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// lsof may not be available
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
processes.push({ pid, command, cwd });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Ignore errors
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return processes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a command is an external Claude process (not spawned by us)
|
|
131
|
+
* @param {string} command - The process command line
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function isExternalClaudeProcess(command) {
|
|
135
|
+
// Skip node processes (SDK internals)
|
|
136
|
+
if (command.startsWith("node ")) return false;
|
|
137
|
+
|
|
138
|
+
// Skip our own server
|
|
139
|
+
if (command.includes("claudecodeui/server")) return false;
|
|
140
|
+
|
|
141
|
+
// Look for actual claude CLI invocations
|
|
142
|
+
return (
|
|
143
|
+
command.includes("claude ") ||
|
|
144
|
+
command.includes("claude-code") ||
|
|
145
|
+
command.match(/\/claude\s/) ||
|
|
146
|
+
command.endsWith("/claude")
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Detect tmux sessions that might be running Claude
|
|
152
|
+
* @returns {Array<{ sessionName: string, windows: number, attached: boolean }>}
|
|
153
|
+
*/
|
|
154
|
+
function detectClaudeTmuxSessions() {
|
|
155
|
+
const sessions = [];
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = spawnSync(
|
|
159
|
+
"tmux",
|
|
160
|
+
[
|
|
161
|
+
"list-sessions",
|
|
162
|
+
"-F",
|
|
163
|
+
"#{session_name}:#{session_windows}:#{session_attached}",
|
|
164
|
+
],
|
|
165
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (result.status === 0) {
|
|
169
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
170
|
+
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
const [sessionName, windows, attached] = line.split(":");
|
|
173
|
+
|
|
174
|
+
// Skip our own sessions
|
|
175
|
+
if (sessionName.startsWith("claudeui-")) continue;
|
|
176
|
+
|
|
177
|
+
// Check if session might be running Claude
|
|
178
|
+
// We can peek at the pane content or just check window titles
|
|
179
|
+
if (mightBeClaudeSession(sessionName)) {
|
|
180
|
+
sessions.push({
|
|
181
|
+
sessionName,
|
|
182
|
+
windows: parseInt(windows, 10),
|
|
183
|
+
attached: attached === "1",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// tmux not available or no server running
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return sessions;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Heuristic check if a tmux session might be running Claude
|
|
197
|
+
* @param {string} sessionName - The session name
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
function mightBeClaudeSession(sessionName) {
|
|
201
|
+
// Check common patterns
|
|
202
|
+
const claudePatterns = ["claude", "ai", "chat", "code"];
|
|
203
|
+
const lowerName = sessionName.toLowerCase();
|
|
204
|
+
|
|
205
|
+
for (const pattern of claudePatterns) {
|
|
206
|
+
if (lowerName.includes(pattern)) return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Try to peek at the session's current command
|
|
210
|
+
try {
|
|
211
|
+
const result = spawnSync(
|
|
212
|
+
"tmux",
|
|
213
|
+
["display-message", "-t", sessionName, "-p", "#{pane_current_command}"],
|
|
214
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (result.status === 0) {
|
|
218
|
+
const currentCommand = result.stdout.trim().toLowerCase();
|
|
219
|
+
if (currentCommand.includes("claude")) return true;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Ignore
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check for session lock files in a project directory
|
|
230
|
+
* @param {string} projectPath - The project directory to check
|
|
231
|
+
* @returns {{ exists: boolean, lockFile: string | null, content: object | null }}
|
|
232
|
+
*/
|
|
233
|
+
function checkSessionLockFile(projectPath) {
|
|
234
|
+
const lockFile = path.join(projectPath, ".claude", "session.lock");
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (fs.existsSync(lockFile)) {
|
|
238
|
+
const content = fs.readFileSync(lockFile, "utf8");
|
|
239
|
+
try {
|
|
240
|
+
const lockData = JSON.parse(content);
|
|
241
|
+
|
|
242
|
+
// Check if the lock is stale (process no longer exists)
|
|
243
|
+
if (lockData.pid) {
|
|
244
|
+
const isAlive = processExists(lockData.pid);
|
|
245
|
+
if (!isAlive) {
|
|
246
|
+
// Stale lock file, clean it up
|
|
247
|
+
try {
|
|
248
|
+
fs.unlinkSync(lockFile);
|
|
249
|
+
} catch {
|
|
250
|
+
// Ignore cleanup errors
|
|
251
|
+
}
|
|
252
|
+
return { exists: false, lockFile: null, content: null };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { exists: true, lockFile, content: lockData };
|
|
257
|
+
} catch {
|
|
258
|
+
// Invalid JSON, treat as text lock
|
|
259
|
+
return { exists: true, lockFile, content: { raw: content } };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// Cannot read lock file
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { exists: false, lockFile: null, content: null };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Check if a process exists
|
|
271
|
+
* @param {number} pid - Process ID
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
function processExists(pid) {
|
|
275
|
+
try {
|
|
276
|
+
// Sending signal 0 checks if process exists without actually sending a signal
|
|
277
|
+
process.kill(pid, 0);
|
|
278
|
+
return true;
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Main detection function - detect all external Claude sessions
|
|
286
|
+
* @param {string} projectPath - The project directory to check
|
|
287
|
+
* @returns {{ hasExternalSession: boolean, processes: Array, tmuxSessions: Array, lockFile: object }}
|
|
288
|
+
*/
|
|
289
|
+
function detectExternalClaude(projectPath) {
|
|
290
|
+
// Check cache
|
|
291
|
+
const cacheKey = projectPath || "__global__";
|
|
292
|
+
const cached = detectionCache.get(cacheKey);
|
|
293
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
294
|
+
return cached.result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const result = {
|
|
298
|
+
hasExternalSession: false,
|
|
299
|
+
processes: [],
|
|
300
|
+
tmuxSessions: [],
|
|
301
|
+
lockFile: { exists: false, lockFile: null, content: null },
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Detect processes
|
|
305
|
+
result.processes = detectClaudeProcesses();
|
|
306
|
+
if (projectPath) {
|
|
307
|
+
// Filter to processes in this project
|
|
308
|
+
result.processes = result.processes.filter(
|
|
309
|
+
(p) => !p.cwd || p.cwd.startsWith(projectPath),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Detect tmux sessions
|
|
314
|
+
result.tmuxSessions = detectClaudeTmuxSessions();
|
|
315
|
+
|
|
316
|
+
// Check lock file
|
|
317
|
+
if (projectPath) {
|
|
318
|
+
result.lockFile = checkSessionLockFile(projectPath);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Determine if there's an external session
|
|
322
|
+
result.hasExternalSession =
|
|
323
|
+
result.processes.length > 0 ||
|
|
324
|
+
result.tmuxSessions.length > 0 ||
|
|
325
|
+
result.lockFile.exists;
|
|
326
|
+
|
|
327
|
+
// Cache the result
|
|
328
|
+
detectionCache.set(cacheKey, {
|
|
329
|
+
timestamp: Date.now(),
|
|
330
|
+
result,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Clear the detection cache
|
|
338
|
+
*/
|
|
339
|
+
function clearCache() {
|
|
340
|
+
detectionCache.clear();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create a session lock file for this application
|
|
345
|
+
* @param {string} projectPath - The project directory
|
|
346
|
+
* @param {string} sessionId - The session ID
|
|
347
|
+
* @returns {boolean}
|
|
348
|
+
*/
|
|
349
|
+
function createSessionLock(projectPath, sessionId) {
|
|
350
|
+
const claudeDir = path.join(projectPath, ".claude");
|
|
351
|
+
const lockFile = path.join(claudeDir, "session.lock");
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
// Ensure .claude directory exists
|
|
355
|
+
if (!fs.existsSync(claudeDir)) {
|
|
356
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const lockData = {
|
|
360
|
+
pid: process.pid,
|
|
361
|
+
sessionId,
|
|
362
|
+
createdAt: new Date().toISOString(),
|
|
363
|
+
app: "claudecodeui",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
fs.writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
|
|
367
|
+
return true;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(
|
|
370
|
+
"[ExternalSessionDetector] Failed to create lock file:",
|
|
371
|
+
err.message,
|
|
372
|
+
);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Remove a session lock file
|
|
379
|
+
* @param {string} projectPath - The project directory
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
function removeSessionLock(projectPath) {
|
|
383
|
+
const lockFile = path.join(projectPath, ".claude", "session.lock");
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
if (fs.existsSync(lockFile)) {
|
|
387
|
+
fs.unlinkSync(lockFile);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export {
|
|
396
|
+
detectExternalClaude,
|
|
397
|
+
detectClaudeProcesses,
|
|
398
|
+
detectClaudeTmuxSessions,
|
|
399
|
+
checkSessionLockFile,
|
|
400
|
+
createSessionLock,
|
|
401
|
+
removeSessionLock,
|
|
402
|
+
clearCache,
|
|
403
|
+
};
|