@epiphytic/claudecodeui 1.0.1 → 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/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-COkp1acE.js +0 -1231
- package/dist/assets/index-DfR9xEkp.css +0 -32
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux Session Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages tmux sessions for multi-client shell sharing.
|
|
5
|
+
* Each Claude Code UI session can be backed by a tmux session,
|
|
6
|
+
* allowing multiple clients to connect to the same shell.
|
|
7
|
+
*
|
|
8
|
+
* Session naming convention: claudeui-{projectHash}-{sessionId}
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "child_process";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import os from "os";
|
|
14
|
+
|
|
15
|
+
// tmux availability cache
|
|
16
|
+
let tmuxAvailable = null;
|
|
17
|
+
let tmuxVersion = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if tmux is installed and available
|
|
21
|
+
* @returns {{ available: boolean, version?: string, error?: string }}
|
|
22
|
+
*/
|
|
23
|
+
function checkTmuxAvailable() {
|
|
24
|
+
if (tmuxAvailable !== null) {
|
|
25
|
+
return { available: tmuxAvailable, version: tmuxVersion };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = spawnSync("tmux", ["-V"], { encoding: "utf8" });
|
|
30
|
+
if (result.status === 0) {
|
|
31
|
+
tmuxAvailable = true;
|
|
32
|
+
tmuxVersion = result.stdout.trim();
|
|
33
|
+
return { available: true, version: tmuxVersion };
|
|
34
|
+
}
|
|
35
|
+
throw new Error("tmux command failed");
|
|
36
|
+
} catch {
|
|
37
|
+
tmuxAvailable = false;
|
|
38
|
+
const installHint =
|
|
39
|
+
os.platform() === "darwin"
|
|
40
|
+
? "brew install tmux"
|
|
41
|
+
: "apt install tmux or yum install tmux";
|
|
42
|
+
return {
|
|
43
|
+
available: false,
|
|
44
|
+
error: `tmux not found. Install with: ${installHint}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generate a short hash from a project path for session naming
|
|
51
|
+
* @param {string} projectPath - The project directory path
|
|
52
|
+
* @returns {string} - A short hash (first 8 chars)
|
|
53
|
+
*/
|
|
54
|
+
function hashProjectPath(projectPath) {
|
|
55
|
+
return crypto.createHash("md5").update(projectPath).digest("hex").slice(0, 8);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a tmux session name from project and session info
|
|
60
|
+
* @param {string} projectPath - The project directory path
|
|
61
|
+
* @param {string} sessionId - The Claude/Cursor session ID
|
|
62
|
+
* @returns {string} - tmux session name
|
|
63
|
+
*/
|
|
64
|
+
function generateTmuxSessionName(projectPath, sessionId) {
|
|
65
|
+
const projectHash = hashProjectPath(projectPath);
|
|
66
|
+
// tmux session names can't contain periods or colons
|
|
67
|
+
const cleanSessionId = sessionId.replace(/[.:]/g, "-");
|
|
68
|
+
return `claudeui-${projectHash}-${cleanSessionId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a tmux session exists
|
|
73
|
+
* @param {string} tmuxSessionName - The tmux session name
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
function sessionExists(tmuxSessionName) {
|
|
77
|
+
try {
|
|
78
|
+
const result = spawnSync("tmux", ["has-session", "-t", tmuxSessionName], {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
stdio: "pipe",
|
|
81
|
+
});
|
|
82
|
+
return result.status === 0;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a new tmux session
|
|
90
|
+
* @param {string} projectPath - Working directory for the session
|
|
91
|
+
* @param {string} sessionId - The Claude/Cursor session ID
|
|
92
|
+
* @param {object} options - Additional options
|
|
93
|
+
* @param {number} options.cols - Terminal columns
|
|
94
|
+
* @param {number} options.rows - Terminal rows
|
|
95
|
+
* @param {string} options.shell - Shell to use (default: user's shell or bash)
|
|
96
|
+
* @returns {{ success: boolean, tmuxSessionName?: string, error?: string }}
|
|
97
|
+
*/
|
|
98
|
+
function createTmuxSession(projectPath, sessionId, options = {}) {
|
|
99
|
+
const tmuxCheck = checkTmuxAvailable();
|
|
100
|
+
if (!tmuxCheck.available) {
|
|
101
|
+
return { success: false, error: tmuxCheck.error };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tmuxSessionName = generateTmuxSessionName(projectPath, sessionId);
|
|
105
|
+
|
|
106
|
+
// Check if session already exists
|
|
107
|
+
if (sessionExists(tmuxSessionName)) {
|
|
108
|
+
return { success: true, tmuxSessionName, existed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { cols = 80, rows = 24 } = options;
|
|
112
|
+
const shell = options.shell || process.env.SHELL || "/bin/bash";
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Create detached tmux session with specified dimensions
|
|
116
|
+
// -d: detached, -s: session name, -c: start directory, -x/-y: dimensions
|
|
117
|
+
const result = spawnSync(
|
|
118
|
+
"tmux",
|
|
119
|
+
[
|
|
120
|
+
"new-session",
|
|
121
|
+
"-d",
|
|
122
|
+
"-s",
|
|
123
|
+
tmuxSessionName,
|
|
124
|
+
"-c",
|
|
125
|
+
projectPath,
|
|
126
|
+
"-x",
|
|
127
|
+
String(cols),
|
|
128
|
+
"-y",
|
|
129
|
+
String(rows),
|
|
130
|
+
],
|
|
131
|
+
{
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
stdio: "pipe",
|
|
134
|
+
env: {
|
|
135
|
+
...process.env,
|
|
136
|
+
SHELL: shell,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (result.status !== 0) {
|
|
142
|
+
throw new Error(result.stderr || "Failed to create tmux session");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`[TmuxManager] Created session: ${tmuxSessionName}`);
|
|
146
|
+
return { success: true, tmuxSessionName, existed: false };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`[TmuxManager] Failed to create session:`, err.message);
|
|
149
|
+
return { success: false, error: err.message };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Attach to an existing tmux session using a PTY
|
|
155
|
+
* @param {string} tmuxSessionName - The tmux session name
|
|
156
|
+
* @param {object} pty - node-pty module reference
|
|
157
|
+
* @param {object} options - PTY options
|
|
158
|
+
* @returns {{ pty: IPty, attached: boolean } | null}
|
|
159
|
+
*/
|
|
160
|
+
function attachToTmuxSession(tmuxSessionName, pty, options = {}) {
|
|
161
|
+
const tmuxCheck = checkTmuxAvailable();
|
|
162
|
+
if (!tmuxCheck.available) {
|
|
163
|
+
console.error("[TmuxManager] tmux not available");
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!sessionExists(tmuxSessionName)) {
|
|
168
|
+
console.error(`[TmuxManager] Session does not exist: ${tmuxSessionName}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { cols = 80, rows = 24 } = options;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// Create PTY that attaches to tmux session
|
|
176
|
+
const ptyProcess = pty.spawn(
|
|
177
|
+
"tmux",
|
|
178
|
+
["attach-session", "-t", tmuxSessionName],
|
|
179
|
+
{
|
|
180
|
+
name: "xterm-256color",
|
|
181
|
+
cols,
|
|
182
|
+
rows,
|
|
183
|
+
cwd: process.env.HOME,
|
|
184
|
+
env: {
|
|
185
|
+
...process.env,
|
|
186
|
+
TERM: "xterm-256color",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
console.log(`[TmuxManager] Attached to session: ${tmuxSessionName}`);
|
|
192
|
+
return { pty: ptyProcess, attached: true };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(`[TmuxManager] Failed to attach:`, err.message);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* List all Claude Code UI tmux sessions
|
|
201
|
+
* @returns {Array<{ name: string, projectHash: string, sessionId: string, windows: number, attached: boolean, created: Date }>}
|
|
202
|
+
*/
|
|
203
|
+
function listClaudeSessions() {
|
|
204
|
+
const tmuxCheck = checkTmuxAvailable();
|
|
205
|
+
if (!tmuxCheck.available) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Get all sessions with format: name:windows:attached:created
|
|
211
|
+
const result = spawnSync(
|
|
212
|
+
"tmux",
|
|
213
|
+
[
|
|
214
|
+
"list-sessions",
|
|
215
|
+
"-F",
|
|
216
|
+
"#{session_name}:#{session_windows}:#{session_attached}:#{session_created}",
|
|
217
|
+
],
|
|
218
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (result.status !== 0) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sessions = [];
|
|
226
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
227
|
+
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
const [name, windows, attached, created] = line.split(":");
|
|
230
|
+
|
|
231
|
+
// Only include claudeui sessions
|
|
232
|
+
if (!name.startsWith("claudeui-")) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parse session name: claudeui-{projectHash}-{sessionId}
|
|
237
|
+
const parts = name.replace("claudeui-", "").split("-");
|
|
238
|
+
const projectHash = parts[0];
|
|
239
|
+
const sessionId = parts.slice(1).join("-");
|
|
240
|
+
|
|
241
|
+
sessions.push({
|
|
242
|
+
name,
|
|
243
|
+
projectHash,
|
|
244
|
+
sessionId,
|
|
245
|
+
windows: parseInt(windows, 10),
|
|
246
|
+
attached: attached === "1",
|
|
247
|
+
created: new Date(parseInt(created, 10) * 1000),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return sessions;
|
|
252
|
+
} catch {
|
|
253
|
+
// No sessions or tmux server not running
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Kill a tmux session
|
|
260
|
+
* @param {string} tmuxSessionName - The session name to kill
|
|
261
|
+
* @returns {boolean} - True if session was killed
|
|
262
|
+
*/
|
|
263
|
+
function killSession(tmuxSessionName) {
|
|
264
|
+
try {
|
|
265
|
+
const result = spawnSync("tmux", ["kill-session", "-t", tmuxSessionName], {
|
|
266
|
+
encoding: "utf8",
|
|
267
|
+
stdio: "pipe",
|
|
268
|
+
});
|
|
269
|
+
if (result.status === 0) {
|
|
270
|
+
console.log(`[TmuxManager] Killed session: ${tmuxSessionName}`);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
} catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Send keys to a tmux session (for input broadcasting)
|
|
281
|
+
* @param {string} tmuxSessionName - The session name
|
|
282
|
+
* @param {string} keys - The keys to send
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
function sendKeysToSession(tmuxSessionName, keys) {
|
|
286
|
+
try {
|
|
287
|
+
// Use send-keys with literal flag (-l)
|
|
288
|
+
const result = spawnSync(
|
|
289
|
+
"tmux",
|
|
290
|
+
["send-keys", "-t", tmuxSessionName, "-l", keys],
|
|
291
|
+
{
|
|
292
|
+
encoding: "utf8",
|
|
293
|
+
stdio: "pipe",
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
return result.status === 0;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resize a tmux session window
|
|
304
|
+
* @param {string} tmuxSessionName - The session name
|
|
305
|
+
* @param {number} cols - New columns
|
|
306
|
+
* @param {number} rows - New rows
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
function resizeSession(tmuxSessionName, cols, rows) {
|
|
310
|
+
try {
|
|
311
|
+
const result = spawnSync(
|
|
312
|
+
"tmux",
|
|
313
|
+
[
|
|
314
|
+
"resize-window",
|
|
315
|
+
"-t",
|
|
316
|
+
tmuxSessionName,
|
|
317
|
+
"-x",
|
|
318
|
+
String(cols),
|
|
319
|
+
"-y",
|
|
320
|
+
String(rows),
|
|
321
|
+
],
|
|
322
|
+
{
|
|
323
|
+
encoding: "utf8",
|
|
324
|
+
stdio: "pipe",
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
return result.status === 0;
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get session info
|
|
335
|
+
* @param {string} tmuxSessionName - The session name
|
|
336
|
+
* @returns {{ windows: number, attached: number, created: Date } | null}
|
|
337
|
+
*/
|
|
338
|
+
function getSessionInfo(tmuxSessionName) {
|
|
339
|
+
try {
|
|
340
|
+
const result = spawnSync(
|
|
341
|
+
"tmux",
|
|
342
|
+
[
|
|
343
|
+
"display-message",
|
|
344
|
+
"-t",
|
|
345
|
+
tmuxSessionName,
|
|
346
|
+
"-p",
|
|
347
|
+
"#{session_windows}:#{session_attached}:#{session_created}",
|
|
348
|
+
],
|
|
349
|
+
{ encoding: "utf8", stdio: "pipe" },
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (result.status !== 0) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const [windows, attached, created] = result.stdout.trim().split(":");
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
windows: parseInt(windows, 10),
|
|
360
|
+
attached: parseInt(attached, 10),
|
|
361
|
+
created: new Date(parseInt(created, 10) * 1000),
|
|
362
|
+
};
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Clean up old/stale tmux sessions
|
|
370
|
+
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 24 hours)
|
|
371
|
+
* @returns {number} - Number of sessions cleaned up
|
|
372
|
+
*/
|
|
373
|
+
function cleanupStaleSessions(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
374
|
+
const sessions = listClaudeSessions();
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
let cleaned = 0;
|
|
377
|
+
|
|
378
|
+
for (const session of sessions) {
|
|
379
|
+
// Only clean up sessions that are not attached and are old
|
|
380
|
+
if (!session.attached && now - session.created.getTime() > maxAgeMs) {
|
|
381
|
+
if (killSession(session.name)) {
|
|
382
|
+
cleaned++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return cleaned;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export {
|
|
391
|
+
checkTmuxAvailable,
|
|
392
|
+
hashProjectPath,
|
|
393
|
+
generateTmuxSessionName,
|
|
394
|
+
sessionExists,
|
|
395
|
+
createTmuxSession,
|
|
396
|
+
attachToTmuxSession,
|
|
397
|
+
listClaudeSessions,
|
|
398
|
+
killSession,
|
|
399
|
+
sendKeysToSession,
|
|
400
|
+
resizeSession,
|
|
401
|
+
getSessionInfo,
|
|
402
|
+
cleanupStaleSessions,
|
|
403
|
+
};
|