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