@epiphytic/claudecodeui 1.0.1 → 1.2.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.
@@ -97,6 +97,50 @@ const runMigrations = () => {
97
97
  );
98
98
  }
99
99
 
100
+ // Check if tmux_sessions table exists
101
+ const tables = db
102
+ .prepare(
103
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tmux_sessions'",
104
+ )
105
+ .all();
106
+ if (tables.length === 0) {
107
+ console.log("Running migration: Creating tmux_sessions table");
108
+ db.exec(`
109
+ CREATE TABLE IF NOT EXISTS tmux_sessions (
110
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ project_path TEXT NOT NULL,
112
+ session_id TEXT,
113
+ tmux_session_name TEXT NOT NULL,
114
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
115
+ last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
116
+ UNIQUE(project_path, session_id)
117
+ );
118
+ CREATE INDEX IF NOT EXISTS idx_tmux_sessions_project ON tmux_sessions(project_path);
119
+ CREATE INDEX IF NOT EXISTS idx_tmux_sessions_name ON tmux_sessions(tmux_session_name);
120
+ `);
121
+ }
122
+
123
+ // Check if orchestrator_tokens table exists
124
+ const orchestratorTokensTable = db
125
+ .prepare(
126
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='orchestrator_tokens'",
127
+ )
128
+ .all();
129
+ if (orchestratorTokensTable.length === 0) {
130
+ console.log("Running migration: Creating orchestrator_tokens table");
131
+ db.exec(`
132
+ CREATE TABLE IF NOT EXISTS orchestrator_tokens (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ host TEXT NOT NULL UNIQUE,
135
+ token TEXT NOT NULL,
136
+ client_id TEXT,
137
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
138
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
139
+ );
140
+ CREATE INDEX IF NOT EXISTS idx_orchestrator_tokens_host ON orchestrator_tokens(host);
141
+ `);
142
+ }
143
+
100
144
  console.log("Database migrations completed successfully");
101
145
  } catch (error) {
102
146
  console.error("Error running migrations:", error.message);
@@ -519,6 +563,182 @@ const credentialsDb = {
519
563
  },
520
564
  };
521
565
 
566
+ // tmux sessions database operations (for persisting shell session mappings)
567
+ const tmuxSessionsDb = {
568
+ // Get tmux session name for a project+session combo
569
+ getTmuxSession: (projectPath, sessionId) => {
570
+ try {
571
+ const row = db
572
+ .prepare(
573
+ "SELECT tmux_session_name FROM tmux_sessions WHERE project_path = ? AND (session_id = ? OR (session_id IS NULL AND ? IS NULL))",
574
+ )
575
+ .get(projectPath, sessionId, sessionId);
576
+ return row?.tmux_session_name || null;
577
+ } catch (err) {
578
+ throw err;
579
+ }
580
+ },
581
+
582
+ // Save or update tmux session mapping
583
+ saveTmuxSession: (projectPath, sessionId, tmuxSessionName) => {
584
+ try {
585
+ const stmt = db.prepare(`
586
+ INSERT INTO tmux_sessions (project_path, session_id, tmux_session_name, last_used)
587
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
588
+ ON CONFLICT(project_path, session_id) DO UPDATE SET
589
+ tmux_session_name = excluded.tmux_session_name,
590
+ last_used = CURRENT_TIMESTAMP
591
+ `);
592
+ stmt.run(projectPath, sessionId, tmuxSessionName);
593
+ return true;
594
+ } catch (err) {
595
+ throw err;
596
+ }
597
+ },
598
+
599
+ // Update last_used timestamp
600
+ touchTmuxSession: (projectPath, sessionId) => {
601
+ try {
602
+ db.prepare(
603
+ "UPDATE tmux_sessions SET last_used = CURRENT_TIMESTAMP WHERE project_path = ? AND (session_id = ? OR (session_id IS NULL AND ? IS NULL))",
604
+ ).run(projectPath, sessionId, sessionId);
605
+ } catch (err) {
606
+ throw err;
607
+ }
608
+ },
609
+
610
+ // Delete a tmux session mapping
611
+ deleteTmuxSession: (projectPath, sessionId) => {
612
+ try {
613
+ const stmt = db.prepare(
614
+ "DELETE FROM tmux_sessions WHERE project_path = ? AND (session_id = ? OR (session_id IS NULL AND ? IS NULL))",
615
+ );
616
+ const result = stmt.run(projectPath, sessionId, sessionId);
617
+ return result.changes > 0;
618
+ } catch (err) {
619
+ throw err;
620
+ }
621
+ },
622
+
623
+ // Delete by tmux session name
624
+ deleteByTmuxName: (tmuxSessionName) => {
625
+ try {
626
+ const stmt = db.prepare(
627
+ "DELETE FROM tmux_sessions WHERE tmux_session_name = ?",
628
+ );
629
+ const result = stmt.run(tmuxSessionName);
630
+ return result.changes > 0;
631
+ } catch (err) {
632
+ throw err;
633
+ }
634
+ },
635
+
636
+ // Get all tmux sessions for cleanup
637
+ getAllTmuxSessions: () => {
638
+ try {
639
+ const rows = db
640
+ .prepare(
641
+ "SELECT id, project_path, session_id, tmux_session_name, created_at, last_used FROM tmux_sessions ORDER BY last_used DESC",
642
+ )
643
+ .all();
644
+ return rows;
645
+ } catch (err) {
646
+ throw err;
647
+ }
648
+ },
649
+
650
+ // Delete multiple sessions by their IDs
651
+ deleteByIds: (ids) => {
652
+ try {
653
+ if (!ids || ids.length === 0) return 0;
654
+ const placeholders = ids.map(() => "?").join(",");
655
+ const stmt = db.prepare(
656
+ `DELETE FROM tmux_sessions WHERE id IN (${placeholders})`,
657
+ );
658
+ const result = stmt.run(...ids);
659
+ return result.changes;
660
+ } catch (err) {
661
+ throw err;
662
+ }
663
+ },
664
+ };
665
+
666
+ // Orchestrator tokens database operations (for storing tokens received during pending mode)
667
+ const orchestratorTokensDb = {
668
+ /**
669
+ * Get stored orchestrator token for a specific host
670
+ * @param {string} host - The orchestrator host (e.g., "duratii.example.com")
671
+ * @returns {{token: string, client_id: string} | null}
672
+ */
673
+ getToken: (host) => {
674
+ try {
675
+ const row = db
676
+ .prepare(
677
+ "SELECT token, client_id FROM orchestrator_tokens WHERE host = ?",
678
+ )
679
+ .get(host);
680
+ return row || null;
681
+ } catch (err) {
682
+ throw err;
683
+ }
684
+ },
685
+
686
+ /**
687
+ * Save or update orchestrator token for a host
688
+ * @param {string} host - The orchestrator host
689
+ * @param {string} token - The full token string
690
+ * @param {string} clientId - The client ID from orchestrator
691
+ */
692
+ saveToken: (host, token, clientId) => {
693
+ try {
694
+ const stmt = db.prepare(`
695
+ INSERT INTO orchestrator_tokens (host, token, client_id, updated_at)
696
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
697
+ ON CONFLICT(host) DO UPDATE SET
698
+ token = excluded.token,
699
+ client_id = excluded.client_id,
700
+ updated_at = CURRENT_TIMESTAMP
701
+ `);
702
+ stmt.run(host, token, clientId);
703
+ return true;
704
+ } catch (err) {
705
+ throw err;
706
+ }
707
+ },
708
+
709
+ /**
710
+ * Delete orchestrator token for a host
711
+ * @param {string} host - The orchestrator host
712
+ * @returns {boolean} True if a token was deleted
713
+ */
714
+ deleteToken: (host) => {
715
+ try {
716
+ const stmt = db.prepare("DELETE FROM orchestrator_tokens WHERE host = ?");
717
+ const result = stmt.run(host);
718
+ return result.changes > 0;
719
+ } catch (err) {
720
+ throw err;
721
+ }
722
+ },
723
+
724
+ /**
725
+ * Get all stored orchestrator tokens
726
+ * @returns {Array<{id: number, host: string, client_id: string, created_at: string, updated_at: string}>}
727
+ */
728
+ getAllTokens: () => {
729
+ try {
730
+ const rows = db
731
+ .prepare(
732
+ "SELECT id, host, client_id, created_at, updated_at FROM orchestrator_tokens ORDER BY updated_at DESC",
733
+ )
734
+ .all();
735
+ return rows;
736
+ } catch (err) {
737
+ throw err;
738
+ }
739
+ },
740
+ };
741
+
522
742
  // Backward compatibility - keep old names pointing to new system
523
743
  const githubTokensDb = {
524
744
  createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -551,4 +771,6 @@ export {
551
771
  apiKeysDb,
552
772
  credentialsDb,
553
773
  githubTokensDb, // Backward compatibility
774
+ tmuxSessionsDb,
775
+ orchestratorTokensDb,
554
776
  };
@@ -51,4 +51,30 @@ CREATE TABLE IF NOT EXISTS user_credentials (
51
51
 
52
52
  CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
53
53
  CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
54
- CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
54
+ CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
55
+
56
+ -- tmux sessions table for persisting shell session mappings across server restarts
57
+ CREATE TABLE IF NOT EXISTS tmux_sessions (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ project_path TEXT NOT NULL,
60
+ session_id TEXT, -- null for plain shell sessions
61
+ tmux_session_name TEXT NOT NULL,
62
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
63
+ last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
64
+ UNIQUE(project_path, session_id)
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS idx_tmux_sessions_project ON tmux_sessions(project_path);
68
+ CREATE INDEX IF NOT EXISTS idx_tmux_sessions_name ON tmux_sessions(tmux_session_name);
69
+
70
+ -- Orchestrator tokens table for storing tokens received from orchestrator during pending mode
71
+ CREATE TABLE IF NOT EXISTS orchestrator_tokens (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ host TEXT NOT NULL UNIQUE, -- e.g., "duratii.example.com"
74
+ token TEXT NOT NULL, -- Full token string (ao_xxx_yyy)
75
+ client_id TEXT, -- Client ID assigned by orchestrator
76
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
77
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
78
+ );
79
+
80
+ CREATE INDEX IF NOT EXISTS idx_orchestrator_tokens_host ON orchestrator_tokens(host);
@@ -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
+ };