@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,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
+ };