@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.
- package/dist/assets/index-DqxzEd_8.js +1245 -0
- package/dist/assets/index-r43D8sh4.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/database/db.js +222 -0
- package/server/database/init.sql +27 -1
- package/server/external-session-detector.js +403 -0
- package/server/index.js +885 -116
- package/server/orchestrator/client.js +361 -16
- package/server/orchestrator/index.js +83 -8
- package/server/orchestrator/protocol.js +67 -0
- package/server/projects-cache.js +196 -0
- package/server/projects.js +760 -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-COkp1acE.js +0 -1231
- package/dist/assets/index-DfR9xEkp.css +0 -32
package/server/database/db.js
CHANGED
|
@@ -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
|
};
|
package/server/database/init.sql
CHANGED
|
@@ -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
|
+
};
|