@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/server/index.js CHANGED
@@ -75,6 +75,17 @@ import {
75
75
  getActiveClaudeSDKSessions,
76
76
  resolveToolApproval,
77
77
  } from "./claude-sdk.js";
78
+ import { sessionLock } from "./session-lock.js";
79
+ import {
80
+ checkTmuxAvailable,
81
+ createTmuxSession,
82
+ attachToTmuxSession,
83
+ killSession as killTmuxSession,
84
+ generateTmuxSessionName,
85
+ sessionExists as tmuxSessionExists,
86
+ resizeSession as resizeTmuxSession,
87
+ } from "./tmux-manager.js";
88
+ import { detectExternalClaude } from "./external-session-detector.js";
78
89
  import {
79
90
  spawnCursor,
80
91
  abortCursorSession,
@@ -100,7 +111,10 @@ import projectsRoutes from "./routes/projects.js";
100
111
  import cliAuthRoutes from "./routes/cli-auth.js";
101
112
  import userRoutes from "./routes/user.js";
102
113
  import codexRoutes from "./routes/codex.js";
103
- import { initializeDatabase } from "./database/db.js";
114
+ import sessionsRoutes from "./routes/sessions.js";
115
+ import { updateSessionsCache } from "./sessions-cache.js";
116
+ import { updateProjectsCache } from "./projects-cache.js";
117
+ import { initializeDatabase, tmuxSessionsDb } from "./database/db.js";
104
118
  import {
105
119
  validateApiKey,
106
120
  authenticateToken,
@@ -116,6 +130,37 @@ const connectedClients = new Set();
116
130
  let orchestrator = null;
117
131
  let orchestratorStatusHooks = null;
118
132
 
133
+ /**
134
+ * Clean up stale tmux session entries from the database
135
+ * Called when projects are rescanned to remove entries for tmux sessions that no longer exist
136
+ */
137
+ function cleanupStaleTmuxSessions() {
138
+ try {
139
+ const allSessions = tmuxSessionsDb.getAllTmuxSessions();
140
+ const staleIds = [];
141
+
142
+ for (const session of allSessions) {
143
+ // Check if the tmux session still exists
144
+ if (!tmuxSessionExists(session.tmux_session_name)) {
145
+ console.log(
146
+ `๐Ÿงน Cleaning up stale tmux entry: ${session.tmux_session_name}`,
147
+ );
148
+ staleIds.push(session.id);
149
+ }
150
+ }
151
+
152
+ if (staleIds.length > 0) {
153
+ const deleted = tmuxSessionsDb.deleteByIds(staleIds);
154
+ console.log(`๐Ÿงน Cleaned up ${deleted} stale tmux session entries`);
155
+ }
156
+ } catch (error) {
157
+ console.error(
158
+ "[ERROR] Error cleaning up stale tmux sessions:",
159
+ error.message,
160
+ );
161
+ }
162
+ }
163
+
119
164
  // Setup file system watcher for Claude projects folder using chokidar
120
165
  async function setupProjectsWatcher() {
121
166
  const chokidar = (await import("chokidar")).default;
@@ -159,6 +204,13 @@ async function setupProjectsWatcher() {
159
204
  // Get updated projects list
160
205
  const updatedProjects = await getProjects();
161
206
 
207
+ // Update sessions and projects caches with new projects data
208
+ updateSessionsCache(updatedProjects);
209
+ updateProjectsCache(updatedProjects);
210
+
211
+ // Clean up stale tmux session entries from database
212
+ cleanupStaleTmuxSessions();
213
+
162
214
  // Notify all connected clients about the project changes
163
215
  const updateMessage = JSON.stringify({
164
216
  type: "projects_updated",
@@ -198,9 +250,89 @@ async function setupProjectsWatcher() {
198
250
  const app = express();
199
251
  const server = http.createServer(app);
200
252
 
253
+ /**
254
+ * PTY Sessions Map - Enhanced for multi-client support
255
+ *
256
+ * Each entry: {
257
+ * pty: PTYProcess, // node-pty or tmux attached process
258
+ * clients: Set<WebSocket>, // Connected WebSocket clients
259
+ * buffer: string[], // Output buffer (max 5000 items)
260
+ * mode: 'shell' | 'chat', // Current session mode
261
+ * lockedBy: string | null, // Chat lock holder (clientId)
262
+ * tmuxSessionName: string|null, // tmux session name if using tmux
263
+ * projectPath: string,
264
+ * sessionId: string,
265
+ * timeoutId: NodeJS.Timeout|null,
266
+ * createdAt: number,
267
+ * lastActivity: number,
268
+ * }
269
+ */
201
270
  const ptySessionsMap = new Map();
202
271
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
203
272
 
273
+ // Check if tmux is available on startup
274
+ const tmuxStatus = checkTmuxAvailable();
275
+ if (tmuxStatus.available) {
276
+ console.log(`[INFO] tmux available: ${tmuxStatus.version}`);
277
+ } else {
278
+ console.log(`[WARN] tmux not available: ${tmuxStatus.error}`);
279
+ console.log("[INFO] Multi-client shell sharing will be limited");
280
+ }
281
+
282
+ /**
283
+ * Broadcast a message to all connected clients for a session
284
+ * @param {string} sessionKey - The session key
285
+ * @param {object} message - The message object to send
286
+ */
287
+ function broadcastToSession(sessionKey, message) {
288
+ const session = ptySessionsMap.get(sessionKey);
289
+ if (!session || !session.clients) return;
290
+
291
+ const messageStr =
292
+ typeof message === "string" ? message : JSON.stringify(message);
293
+
294
+ for (const client of session.clients) {
295
+ if (client.readyState === WebSocket.OPEN) {
296
+ try {
297
+ client.send(messageStr);
298
+ } catch (err) {
299
+ console.error("[WARN] Failed to send to client:", err.message);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Broadcast session state update to all clients
307
+ * @param {string} sessionKey - The session key
308
+ */
309
+ function broadcastSessionState(sessionKey) {
310
+ const session = ptySessionsMap.get(sessionKey);
311
+ if (!session) return;
312
+
313
+ broadcastToSession(sessionKey, {
314
+ type: "session-state-update",
315
+ sessionKey,
316
+ state: {
317
+ mode: session.mode || "shell",
318
+ clientCount: session.clients ? session.clients.size : 0,
319
+ lockedBy: session.lockedBy,
320
+ tmuxSessionName: session.tmuxSessionName,
321
+ },
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Get the session key for a project/session combination
327
+ * @param {string} projectPath
328
+ * @param {string} sessionId
329
+ * @param {string} commandSuffix - Optional command suffix for unique commands
330
+ * @returns {string}
331
+ */
332
+ function getPtySessionKey(projectPath, sessionId, commandSuffix = "") {
333
+ return `${projectPath}_${sessionId || "default"}${commandSuffix}`;
334
+ }
335
+
204
336
  // Single WebSocket server that handles both paths
205
337
  const wss = new WebSocketServer({
206
338
  server,
@@ -309,16 +441,42 @@ app.use("/api/user", authenticateToken, userRoutes);
309
441
  // Codex API Routes (protected)
310
442
  app.use("/api/codex", authenticateToken, codexRoutes);
311
443
 
444
+ // Sessions API Routes (protected)
445
+ app.use("/api/sessions", authenticateToken, sessionsRoutes);
446
+
312
447
  // Agent API Routes (uses API key authentication)
313
448
  app.use("/api/agent", agentRoutes);
314
449
 
315
- // Serve public files (like api-docs.html)
316
- app.use(express.static(path.join(__dirname, "../public")));
450
+ // Serve public files (like api-docs.html, icons)
451
+ // Enable ETag generation for conditional requests (304 support)
452
+ app.use(
453
+ express.static(path.join(__dirname, "../public"), {
454
+ etag: true,
455
+ lastModified: true,
456
+ setHeaders: (res, filePath) => {
457
+ // Cache icons and other static assets
458
+ if (filePath.match(/\.(svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot)$/)) {
459
+ // Cache for 1 week, allow revalidation
460
+ res.setHeader(
461
+ "Cache-Control",
462
+ "public, max-age=604800, must-revalidate",
463
+ );
464
+ } else if (filePath.endsWith(".html")) {
465
+ // HTML files should not be cached
466
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
467
+ res.setHeader("Pragma", "no-cache");
468
+ res.setHeader("Expires", "0");
469
+ }
470
+ },
471
+ }),
472
+ );
317
473
 
318
474
  // Static files served after API routes
319
475
  // Add cache control: HTML files should not be cached, but assets can be cached
320
476
  app.use(
321
477
  express.static(path.join(__dirname, "../dist"), {
478
+ etag: true,
479
+ lastModified: true,
322
480
  setHeaders: (res, filePath) => {
323
481
  if (filePath.endsWith(".html")) {
324
482
  // Prevent HTML caching to avoid service worker issues after builds
@@ -518,6 +676,75 @@ app.delete(
518
676
  },
519
677
  );
520
678
 
679
+ // Terminate shell session endpoint (for conflict resolution from chat interface)
680
+ app.post("/api/shell/terminate", authenticateToken, async (req, res) => {
681
+ try {
682
+ const { projectPath, sessionId } = req.body;
683
+
684
+ if (!projectPath) {
685
+ return res.status(400).json({ error: "projectPath is required" });
686
+ }
687
+
688
+ // Build session key (same format as in handleShellConnection)
689
+ const sessionKey = sessionId
690
+ ? `${projectPath}:${sessionId}`
691
+ : `${projectPath}:plain-shell`;
692
+
693
+ console.log("๐Ÿ”ช API: Force closing shell session:", sessionKey);
694
+
695
+ const session = ptySessionsMap.get(sessionKey);
696
+
697
+ if (!session) {
698
+ // Session not found - might already be closed
699
+ return res.json({
700
+ success: true,
701
+ message: "Session not found or already closed",
702
+ });
703
+ }
704
+
705
+ // Notify all shell clients
706
+ const closeMsg = JSON.stringify({
707
+ type: "output",
708
+ data: `\r\n\x1b[31m[Session terminated by another client]\x1b[0m\r\n`,
709
+ });
710
+
711
+ for (const client of session.clients) {
712
+ if (client.readyState === WebSocket.OPEN) {
713
+ try {
714
+ client.send(closeMsg);
715
+ // Also send a session-closed message so clients know to disconnect
716
+ client.send(JSON.stringify({ type: "session-closed" }));
717
+ } catch {
718
+ // Ignore send errors
719
+ }
720
+ }
721
+ }
722
+
723
+ // Kill the PTY process
724
+ if (session.pty && session.pty.kill) {
725
+ session.pty.kill();
726
+ }
727
+
728
+ // Kill tmux session if exists
729
+ if (session.tmuxSessionName) {
730
+ killTmuxSession(session.tmuxSessionName);
731
+ }
732
+
733
+ // Clear any pending timeout
734
+ if (session.timeoutId) {
735
+ clearTimeout(session.timeoutId);
736
+ }
737
+
738
+ ptySessionsMap.delete(sessionKey);
739
+
740
+ console.log("โœ… Shell session terminated:", sessionKey);
741
+ res.json({ success: true, sessionKey });
742
+ } catch (error) {
743
+ console.error("[API] Error terminating shell session:", error);
744
+ res.status(500).json({ error: error.message });
745
+ }
746
+ });
747
+
521
748
  // Create project endpoint
522
749
  app.post("/api/projects/create", authenticateToken, async (req, res) => {
523
750
  try {
@@ -904,6 +1131,81 @@ async function handleChatMessage(ws, writer, messageData) {
904
1131
  console.log("๐Ÿ“ Project:", data.options?.projectPath || "Unknown");
905
1132
  console.log("๐Ÿ”„ Session:", data.options?.sessionId ? "Resume" : "New");
906
1133
 
1134
+ const projectPath = data.options?.projectPath;
1135
+ const sessionId = data.options?.sessionId || sessionIdForTracking;
1136
+ const ptySessionKey = getPtySessionKey(projectPath, sessionId);
1137
+
1138
+ // Check for active shell session with clients
1139
+ const shellSession = ptySessionsMap.get(ptySessionKey);
1140
+ if (
1141
+ shellSession &&
1142
+ shellSession.clients &&
1143
+ shellSession.clients.size > 0
1144
+ ) {
1145
+ // Shell is active with connected clients
1146
+ writer.send({
1147
+ type: "session-conflict",
1148
+ sessionKey: ptySessionKey,
1149
+ conflictType: "shell-active",
1150
+ message: "A shell session is active with connected clients",
1151
+ clientCount: shellSession.clients.size,
1152
+ options: ["close-shell", "fork-session", "cancel"],
1153
+ });
1154
+ return;
1155
+ }
1156
+
1157
+ // Check for external Claude sessions
1158
+ if (projectPath) {
1159
+ const externalCheck = detectExternalClaude(projectPath);
1160
+ if (externalCheck.hasExternalSession) {
1161
+ writer.send({
1162
+ type: "external-session-detected",
1163
+ projectPath,
1164
+ details: {
1165
+ processIds: externalCheck.processes.map((p) => p.pid),
1166
+ tmuxSessions: externalCheck.tmuxSessions.map(
1167
+ (s) => s.sessionName,
1168
+ ),
1169
+ lockFile: externalCheck.lockFile.exists
1170
+ ? externalCheck.lockFile.lockFile
1171
+ : null,
1172
+ },
1173
+ });
1174
+ // Continue with warning - don't block the chat
1175
+ }
1176
+ }
1177
+
1178
+ // Acquire chat lock
1179
+ const clientId = `chat-${Date.now()}`;
1180
+ const lockResult = sessionLock.acquireLock(
1181
+ ptySessionKey,
1182
+ clientId,
1183
+ "chat",
1184
+ {
1185
+ sessionId,
1186
+ startedAt: Date.now(),
1187
+ },
1188
+ );
1189
+
1190
+ if (!lockResult.success) {
1191
+ writer.send({
1192
+ type: "session-conflict",
1193
+ sessionKey: ptySessionKey,
1194
+ conflictType: "chat-locked",
1195
+ message: lockResult.reason,
1196
+ holder: lockResult.holder,
1197
+ options: ["wait", "cancel"],
1198
+ });
1199
+ return;
1200
+ }
1201
+
1202
+ // Update shell session mode if it exists
1203
+ if (shellSession) {
1204
+ shellSession.mode = "chat";
1205
+ shellSession.lockedBy = clientId;
1206
+ broadcastSessionState(ptySessionKey);
1207
+ }
1208
+
907
1209
  // Track busy status for orchestrator
908
1210
  if (orchestratorStatusHooks) {
909
1211
  orchestratorStatusHooks.onQueryStart(sessionIdForTracking);
@@ -913,6 +1215,16 @@ async function handleChatMessage(ws, writer, messageData) {
913
1215
  // Use Claude Agents SDK
914
1216
  await queryClaudeSDK(data.command, data.options, writer);
915
1217
  } finally {
1218
+ // Release chat lock
1219
+ sessionLock.releaseLock(ptySessionKey, clientId);
1220
+
1221
+ // Reset shell session mode
1222
+ if (shellSession) {
1223
+ shellSession.mode = "shell";
1224
+ shellSession.lockedBy = null;
1225
+ broadcastSessionState(ptySessionKey);
1226
+ }
1227
+
916
1228
  // Mark as no longer busy
917
1229
  if (orchestratorStatusHooks) {
918
1230
  orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
@@ -1054,12 +1366,14 @@ async function handleChatMessage(ws, writer, messageData) {
1054
1366
  }
1055
1367
  }
1056
1368
 
1057
- // Handle shell WebSocket connections
1369
+ // Handle shell WebSocket connections with multi-client support
1058
1370
  function handleShellConnection(ws) {
1059
1371
  console.log("๐Ÿš Shell client connected");
1372
+
1373
+ // Generate unique client ID for this connection
1374
+ const clientId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1060
1375
  let shellProcess = null;
1061
1376
  let ptySessionKey = null;
1062
- let outputBuffer = [];
1063
1377
 
1064
1378
  ws.on("message", async (message) => {
1065
1379
  try {
@@ -1089,7 +1403,7 @@ function handleShellConnection(ws) {
1089
1403
  isPlainShell && initialCommand
1090
1404
  ? `_cmd_${Buffer.from(initialCommand).toString("base64").slice(0, 16)}`
1091
1405
  : "";
1092
- ptySessionKey = `${projectPath}_${sessionId || "default"}${commandSuffix}`;
1406
+ ptySessionKey = getPtySessionKey(projectPath, sessionId, commandSuffix);
1093
1407
 
1094
1408
  // Kill any existing login session before starting fresh
1095
1409
  if (isLoginCommand) {
@@ -1101,6 +1415,10 @@ function handleShellConnection(ws) {
1101
1415
  );
1102
1416
  if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
1103
1417
  if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
1418
+ // Kill tmux session if exists
1419
+ if (oldSession.tmuxSessionName) {
1420
+ killTmuxSession(oldSession.tmuxSessionName);
1421
+ }
1104
1422
  ptySessionsMap.delete(ptySessionKey);
1105
1423
  }
1106
1424
  }
@@ -1108,25 +1426,133 @@ function handleShellConnection(ws) {
1108
1426
  const existingSession = isLoginCommand
1109
1427
  ? null
1110
1428
  : ptySessionsMap.get(ptySessionKey);
1429
+
1430
+ // Check for saved tmux session in database (for server restarts)
1431
+ if (!existingSession && !isLoginCommand && !isPlainShell) {
1432
+ const savedTmuxName = tmuxSessionsDb.getTmuxSession(
1433
+ projectPath,
1434
+ sessionId,
1435
+ );
1436
+ if (savedTmuxName && tmuxSessionExists(savedTmuxName)) {
1437
+ console.log(
1438
+ "โ™ป๏ธ Found existing tmux session from database:",
1439
+ savedTmuxName,
1440
+ );
1441
+
1442
+ // Reconnect to the existing tmux session
1443
+ const termCols = data.cols || 80;
1444
+ const termRows = data.rows || 24;
1445
+
1446
+ const attachResult = attachToTmuxSession(savedTmuxName, pty, {
1447
+ cols: termCols,
1448
+ rows: termRows,
1449
+ });
1450
+
1451
+ if (attachResult && attachResult.pty) {
1452
+ shellProcess = attachResult.pty;
1453
+
1454
+ // Update last used timestamp
1455
+ tmuxSessionsDb.touchTmuxSession(projectPath, sessionId);
1456
+
1457
+ // Create session with multi-client support
1458
+ const clients = new Set([ws]);
1459
+ ptySessionsMap.set(ptySessionKey, {
1460
+ pty: shellProcess,
1461
+ clients,
1462
+ buffer: [],
1463
+ mode: "shell",
1464
+ lockedBy: null,
1465
+ tmuxSessionName: savedTmuxName,
1466
+ timeoutId: null,
1467
+ projectPath,
1468
+ sessionId,
1469
+ createdAt: Date.now(),
1470
+ lastActivity: Date.now(),
1471
+ });
1472
+
1473
+ // Handle data output - broadcast to all clients
1474
+ shellProcess.onData((outputData) => {
1475
+ const session = ptySessionsMap.get(ptySessionKey);
1476
+ if (!session) return;
1477
+ session.lastActivity = Date.now();
1478
+
1479
+ // Add to buffer
1480
+ if (session.buffer.length < 5000) {
1481
+ session.buffer.push(outputData);
1482
+ } else {
1483
+ session.buffer.shift();
1484
+ session.buffer.push(outputData);
1485
+ }
1486
+
1487
+ // Broadcast to all clients
1488
+ broadcastToSession(ptySessionKey, {
1489
+ type: "output",
1490
+ data: outputData,
1491
+ });
1492
+ });
1493
+
1494
+ shellProcess.onExit(({ exitCode }) => {
1495
+ console.log(`๐Ÿ”ด Tmux process exited with code ${exitCode}`);
1496
+ const session = ptySessionsMap.get(ptySessionKey);
1497
+ if (session) {
1498
+ broadcastToSession(ptySessionKey, {
1499
+ type: "output",
1500
+ data: `\r\n\x1b[33mSession ended with exit code ${exitCode}\x1b[0m\r\n`,
1501
+ });
1502
+ ptySessionsMap.delete(ptySessionKey);
1503
+ }
1504
+ });
1505
+
1506
+ ws.send(
1507
+ JSON.stringify({
1508
+ type: "output",
1509
+ data: `\x1b[36m[Reconnected to existing tmux session]\x1b[0m\r\n`,
1510
+ }),
1511
+ );
1512
+
1513
+ broadcastSessionState(ptySessionKey);
1514
+ return;
1515
+ } else {
1516
+ // Failed to attach, will create new session below
1517
+ console.log(
1518
+ "โš ๏ธ Failed to attach to saved tmux session, creating new one",
1519
+ );
1520
+ // Clean up stale database entry
1521
+ tmuxSessionsDb.deleteTmuxSession(projectPath, sessionId);
1522
+ }
1523
+ }
1524
+ }
1525
+
1111
1526
  if (existingSession) {
1527
+ // Multi-client: Add this client to the existing session
1112
1528
  console.log(
1113
- "โ™ป๏ธ Reconnecting to existing PTY session:",
1529
+ "โ™ป๏ธ Adding client to existing PTY session:",
1114
1530
  ptySessionKey,
1115
1531
  );
1116
1532
  shellProcess = existingSession.pty;
1117
1533
 
1118
- clearTimeout(existingSession.timeoutId);
1534
+ // Clear timeout since we have an active client
1535
+ if (existingSession.timeoutId) {
1536
+ clearTimeout(existingSession.timeoutId);
1537
+ existingSession.timeoutId = null;
1538
+ }
1539
+
1540
+ // Add client to the clients Set
1541
+ existingSession.clients.add(ws);
1542
+ existingSession.lastActivity = Date.now();
1119
1543
 
1544
+ // Send reconnect message to this client only
1120
1545
  ws.send(
1121
1546
  JSON.stringify({
1122
1547
  type: "output",
1123
- data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`,
1548
+ data: `\x1b[36m[Connected to session - ${existingSession.clients.size} client(s) connected]\x1b[0m\r\n`,
1124
1549
  }),
1125
1550
  );
1126
1551
 
1552
+ // Send buffered output to this new client
1127
1553
  if (existingSession.buffer && existingSession.buffer.length > 0) {
1128
1554
  console.log(
1129
- `๐Ÿ“œ Sending ${existingSession.buffer.length} buffered messages`,
1555
+ `๐Ÿ“œ Sending ${existingSession.buffer.length} buffered messages to new client`,
1130
1556
  );
1131
1557
  existingSession.buffer.forEach((bufferedData) => {
1132
1558
  ws.send(
@@ -1138,7 +1564,22 @@ function handleShellConnection(ws) {
1138
1564
  });
1139
1565
  }
1140
1566
 
1141
- existingSession.ws = ws;
1567
+ // Send session state to this client
1568
+ ws.send(
1569
+ JSON.stringify({
1570
+ type: "session-state-update",
1571
+ sessionKey: ptySessionKey,
1572
+ state: {
1573
+ mode: existingSession.mode || "shell",
1574
+ clientCount: existingSession.clients.size,
1575
+ lockedBy: existingSession.lockedBy,
1576
+ tmuxSessionName: existingSession.tmuxSessionName,
1577
+ },
1578
+ }),
1579
+ );
1580
+
1581
+ // Broadcast updated client count to all clients
1582
+ broadcastSessionState(ptySessionKey);
1142
1583
 
1143
1584
  return;
1144
1585
  }
@@ -1221,114 +1662,201 @@ function handleShellConnection(ws) {
1221
1662
 
1222
1663
  console.log("๐Ÿ”ง Executing shell command:", shellCommand);
1223
1664
 
1224
- // Use appropriate shell based on platform
1225
- const shell = os.platform() === "win32" ? "powershell.exe" : "bash";
1226
- const shellArgs =
1227
- os.platform() === "win32"
1228
- ? ["-Command", shellCommand]
1229
- : ["-c", shellCommand];
1230
-
1231
1665
  // Use terminal dimensions from client if provided, otherwise use defaults
1232
1666
  const termCols = data.cols || 80;
1233
1667
  const termRows = data.rows || 24;
1234
1668
  console.log("๐Ÿ“ Using terminal dimensions:", termCols, "x", termRows);
1235
1669
 
1236
- shellProcess = pty.spawn(shell, shellArgs, {
1237
- name: "xterm-256color",
1238
- cols: termCols,
1239
- rows: termRows,
1240
- cwd: os.homedir(),
1241
- env: {
1242
- ...process.env,
1243
- TERM: "xterm-256color",
1244
- COLORTERM: "truecolor",
1245
- FORCE_COLOR: "3",
1246
- // Override browser opening commands to echo URL for detection
1247
- BROWSER:
1248
- os.platform() === "win32"
1249
- ? 'echo "OPEN_URL:"'
1250
- : 'echo "OPEN_URL:"',
1251
- },
1252
- });
1670
+ // Track tmux session name if we use tmux
1671
+ let tmuxSessionName = null;
1672
+
1673
+ // For non-plain-shell sessions on Unix, use tmux for session persistence
1674
+ const useTmux =
1675
+ !isPlainShell &&
1676
+ os.platform() !== "win32" &&
1677
+ checkTmuxAvailable().available;
1678
+
1679
+ if (useTmux) {
1680
+ // Create tmux session
1681
+ const tmuxResult = createTmuxSession(
1682
+ projectPath,
1683
+ sessionId || "shell",
1684
+ {
1685
+ cols: termCols,
1686
+ rows: termRows,
1687
+ },
1688
+ );
1689
+
1690
+ if (tmuxResult.success) {
1691
+ tmuxSessionName = tmuxResult.tmuxSessionName;
1692
+ console.log("๐Ÿ“บ Created/found tmux session:", tmuxSessionName);
1693
+
1694
+ // Save to database for persistence across server restarts
1695
+ tmuxSessionsDb.saveTmuxSession(
1696
+ projectPath,
1697
+ sessionId,
1698
+ tmuxSessionName,
1699
+ );
1700
+
1701
+ // Send the shell command to tmux
1702
+ const { spawnSync } = await import("child_process");
1703
+ spawnSync(
1704
+ "tmux",
1705
+ ["send-keys", "-t", tmuxSessionName, shellCommand, "Enter"],
1706
+ {
1707
+ encoding: "utf8",
1708
+ },
1709
+ );
1710
+
1711
+ // Attach to tmux session
1712
+ const attachResult = attachToTmuxSession(tmuxSessionName, pty, {
1713
+ cols: termCols,
1714
+ rows: termRows,
1715
+ });
1716
+
1717
+ if (attachResult && attachResult.pty) {
1718
+ shellProcess = attachResult.pty;
1719
+ } else {
1720
+ // Fallback to direct PTY if tmux attach fails
1721
+ console.log(
1722
+ "โš ๏ธ Tmux attach failed, falling back to direct PTY",
1723
+ );
1724
+ tmuxSessionName = null;
1725
+ }
1726
+ }
1727
+ }
1728
+
1729
+ // Fallback to direct PTY spawn (for plain shell, Windows, or tmux failure)
1730
+ if (!shellProcess) {
1731
+ const shell = os.platform() === "win32" ? "powershell.exe" : "bash";
1732
+ const shellArgs =
1733
+ os.platform() === "win32"
1734
+ ? ["-Command", shellCommand]
1735
+ : ["-c", shellCommand];
1736
+
1737
+ shellProcess = pty.spawn(shell, shellArgs, {
1738
+ name: "xterm-256color",
1739
+ cols: termCols,
1740
+ rows: termRows,
1741
+ cwd: os.homedir(),
1742
+ env: {
1743
+ ...process.env,
1744
+ TERM: "xterm-256color",
1745
+ COLORTERM: "truecolor",
1746
+ FORCE_COLOR: "3",
1747
+ // Override browser opening commands to echo URL for detection
1748
+ BROWSER:
1749
+ os.platform() === "win32"
1750
+ ? 'echo "OPEN_URL:"'
1751
+ : 'echo "OPEN_URL:"',
1752
+ },
1753
+ });
1754
+ }
1253
1755
 
1254
1756
  console.log(
1255
1757
  "๐ŸŸข Shell process started with PTY, PID:",
1256
1758
  shellProcess.pid,
1759
+ tmuxSessionName ? `(tmux: ${tmuxSessionName})` : "(direct)",
1257
1760
  );
1258
1761
 
1762
+ // Create session with multi-client support
1763
+ const clients = new Set([ws]);
1259
1764
  ptySessionsMap.set(ptySessionKey, {
1260
1765
  pty: shellProcess,
1261
- ws: ws,
1766
+ clients,
1262
1767
  buffer: [],
1768
+ mode: "shell",
1769
+ lockedBy: null,
1770
+ tmuxSessionName,
1263
1771
  timeoutId: null,
1264
1772
  projectPath,
1265
1773
  sessionId,
1774
+ createdAt: Date.now(),
1775
+ lastActivity: Date.now(),
1266
1776
  });
1267
1777
 
1268
- // Handle data output
1269
- shellProcess.onData((data) => {
1778
+ // Handle data output - broadcast to all clients
1779
+ shellProcess.onData((outputData) => {
1270
1780
  const session = ptySessionsMap.get(ptySessionKey);
1271
1781
  if (!session) return;
1272
1782
 
1783
+ session.lastActivity = Date.now();
1784
+
1785
+ // Add to buffer (circular buffer, max 5000 items)
1273
1786
  if (session.buffer.length < 5000) {
1274
- session.buffer.push(data);
1787
+ session.buffer.push(outputData);
1275
1788
  } else {
1276
1789
  session.buffer.shift();
1277
- session.buffer.push(data);
1790
+ session.buffer.push(outputData);
1278
1791
  }
1279
1792
 
1280
- if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1281
- let outputData = data;
1282
-
1283
- // Check for various URL opening patterns
1284
- const patterns = [
1285
- // Direct browser opening commands
1286
- /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
1287
- // BROWSER environment variable override
1288
- /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1289
- // Git and other tools opening URLs
1290
- /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
1291
- // General URL patterns that might be opened
1292
- /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1293
- /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1294
- /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1295
- ];
1296
-
1297
- patterns.forEach((pattern) => {
1298
- let match;
1299
- while ((match = pattern.exec(data)) !== null) {
1300
- const url = match[1];
1301
- console.log("[DEBUG] Detected URL for opening:", url);
1302
-
1303
- // Send URL opening message to client
1304
- session.ws.send(
1305
- JSON.stringify({
1306
- type: "url_open",
1307
- url: url,
1308
- }),
1793
+ // Process output for URL detection
1794
+ let processedData = outputData;
1795
+
1796
+ // Check for various URL opening patterns
1797
+ const patterns = [
1798
+ // Direct browser opening commands
1799
+ /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
1800
+ // BROWSER environment variable override
1801
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1802
+ // Git and other tools opening URLs
1803
+ /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
1804
+ // General URL patterns that might be opened
1805
+ /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1806
+ /View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1807
+ /Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1808
+ ];
1809
+
1810
+ const detectedUrls = [];
1811
+ patterns.forEach((pattern) => {
1812
+ let match;
1813
+ while ((match = pattern.exec(outputData)) !== null) {
1814
+ const url = match[1];
1815
+ console.log("[DEBUG] Detected URL for opening:", url);
1816
+ detectedUrls.push(url);
1817
+
1818
+ // Replace the OPEN_URL pattern with a user-friendly message
1819
+ if (pattern.source.includes("OPEN_URL")) {
1820
+ processedData = processedData.replace(
1821
+ match[0],
1822
+ `[INFO] Opening in browser: ${url}`,
1309
1823
  );
1824
+ }
1825
+ }
1826
+ });
1310
1827
 
1311
- // Replace the OPEN_URL pattern with a user-friendly message
1312
- if (pattern.source.includes("OPEN_URL")) {
1313
- outputData = outputData.replace(
1314
- match[0],
1315
- `[INFO] Opening in browser: ${url}`,
1828
+ // Broadcast output to all clients
1829
+ for (const client of session.clients) {
1830
+ if (client.readyState === WebSocket.OPEN) {
1831
+ try {
1832
+ // Send URL opening message
1833
+ for (const url of detectedUrls) {
1834
+ client.send(
1835
+ JSON.stringify({
1836
+ type: "url_open",
1837
+ url,
1838
+ }),
1316
1839
  );
1317
1840
  }
1318
- }
1319
- });
1320
1841
 
1321
- // Send regular output
1322
- session.ws.send(
1323
- JSON.stringify({
1324
- type: "output",
1325
- data: outputData,
1326
- }),
1327
- );
1842
+ // Send regular output
1843
+ client.send(
1844
+ JSON.stringify({
1845
+ type: "output",
1846
+ data: processedData,
1847
+ }),
1848
+ );
1849
+ } catch (err) {
1850
+ console.error(
1851
+ "[WARN] Failed to send to client:",
1852
+ err.message,
1853
+ );
1854
+ }
1855
+ }
1328
1856
  }
1329
1857
  });
1330
1858
 
1331
- // Handle process exit
1859
+ // Handle process exit - broadcast to all clients
1332
1860
  shellProcess.onExit((exitCode) => {
1333
1861
  console.log(
1334
1862
  "๐Ÿ”š Shell process exited with code:",
@@ -1337,21 +1865,34 @@ function handleShellConnection(ws) {
1337
1865
  exitCode.signal,
1338
1866
  );
1339
1867
  const session = ptySessionsMap.get(ptySessionKey);
1340
- if (
1341
- session &&
1342
- session.ws &&
1343
- session.ws.readyState === WebSocket.OPEN
1344
- ) {
1345
- session.ws.send(
1346
- JSON.stringify({
1347
- type: "output",
1348
- data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ""}\x1b[0m\r\n`,
1349
- }),
1350
- );
1351
- }
1352
- if (session && session.timeoutId) {
1353
- clearTimeout(session.timeoutId);
1868
+
1869
+ if (session) {
1870
+ // Broadcast exit to all clients
1871
+ const exitMsg = JSON.stringify({
1872
+ type: "output",
1873
+ data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ""}\x1b[0m\r\n`,
1874
+ });
1875
+
1876
+ for (const client of session.clients) {
1877
+ if (client.readyState === WebSocket.OPEN) {
1878
+ try {
1879
+ client.send(exitMsg);
1880
+ } catch {
1881
+ // Ignore send errors during exit
1882
+ }
1883
+ }
1884
+ }
1885
+
1886
+ if (session.timeoutId) {
1887
+ clearTimeout(session.timeoutId);
1888
+ }
1889
+
1890
+ // Kill tmux session if it exists
1891
+ if (session.tmuxSessionName) {
1892
+ killTmuxSession(session.tmuxSessionName);
1893
+ }
1354
1894
  }
1895
+
1355
1896
  ptySessionsMap.delete(ptySessionKey);
1356
1897
  shellProcess = null;
1357
1898
  });
@@ -1366,9 +1907,25 @@ function handleShellConnection(ws) {
1366
1907
  }
1367
1908
  } else if (data.type === "input") {
1368
1909
  // Send input to shell process
1910
+ const session = ptySessionsMap.get(ptySessionKey);
1911
+
1912
+ // Check if session is locked by chat
1913
+ if (session && session.mode === "chat") {
1914
+ ws.send(
1915
+ JSON.stringify({
1916
+ type: "output",
1917
+ data: `\r\n\x1b[33m[Chat in progress - shell input disabled]\x1b[0m\r\n`,
1918
+ }),
1919
+ );
1920
+ return;
1921
+ }
1922
+
1369
1923
  if (shellProcess && shellProcess.write) {
1370
1924
  try {
1371
1925
  shellProcess.write(data.data);
1926
+ if (session) {
1927
+ session.lastActivity = Date.now();
1928
+ }
1372
1929
  } catch (error) {
1373
1930
  console.error("Error writing to shell:", error);
1374
1931
  }
@@ -1380,7 +1937,121 @@ function handleShellConnection(ws) {
1380
1937
  if (shellProcess && shellProcess.resize) {
1381
1938
  console.log("Terminal resize requested:", data.cols, "x", data.rows);
1382
1939
  shellProcess.resize(data.cols, data.rows);
1940
+
1941
+ // Also resize tmux session if using tmux
1942
+ const session = ptySessionsMap.get(ptySessionKey);
1943
+ if (session && session.tmuxSessionName) {
1944
+ resizeTmuxSession(session.tmuxSessionName, data.cols, data.rows);
1945
+ }
1946
+ }
1947
+ } else if (data.type === "check-session-mode") {
1948
+ // Return current session mode
1949
+ const session = ptySessionsMap.get(data.sessionKey || ptySessionKey);
1950
+ ws.send(
1951
+ JSON.stringify({
1952
+ type: "session-state-update",
1953
+ sessionKey: data.sessionKey || ptySessionKey,
1954
+ state: session
1955
+ ? {
1956
+ mode: session.mode || "shell",
1957
+ clientCount: session.clients ? session.clients.size : 0,
1958
+ lockedBy: session.lockedBy,
1959
+ tmuxSessionName: session.tmuxSessionName,
1960
+ }
1961
+ : null,
1962
+ }),
1963
+ );
1964
+ } else if (data.type === "terminate") {
1965
+ // Handle client request to terminate their session
1966
+ const session = ptySessionsMap.get(ptySessionKey);
1967
+ if (session) {
1968
+ console.log(
1969
+ "๐Ÿ”ช Client requested session termination:",
1970
+ ptySessionKey,
1971
+ );
1972
+
1973
+ // Notify all shell clients
1974
+ const closeMsg = JSON.stringify({
1975
+ type: "output",
1976
+ data: `\r\n\x1b[33m[Session terminated]\x1b[0m\r\n`,
1977
+ });
1978
+
1979
+ for (const client of session.clients) {
1980
+ if (client.readyState === WebSocket.OPEN) {
1981
+ try {
1982
+ client.send(closeMsg);
1983
+ client.send(JSON.stringify({ type: "session-closed" }));
1984
+ } catch {
1985
+ // Ignore
1986
+ }
1987
+ }
1988
+ }
1989
+
1990
+ // Kill the PTY process
1991
+ if (session.pty && session.pty.kill) {
1992
+ session.pty.kill();
1993
+ }
1994
+
1995
+ // Kill tmux session if exists
1996
+ if (session.tmuxSessionName) {
1997
+ killTmuxSession(session.tmuxSessionName);
1998
+ }
1999
+
2000
+ // Clear any pending timeout
2001
+ if (session.timeoutId) {
2002
+ clearTimeout(session.timeoutId);
2003
+ }
2004
+
2005
+ ptySessionsMap.delete(ptySessionKey);
2006
+ console.log("โœ… Shell session terminated by client:", ptySessionKey);
2007
+ }
2008
+ } else if (data.type === "resolve-conflict") {
2009
+ // Handle conflict resolution
2010
+ const targetSessionKey = data.sessionKey || ptySessionKey;
2011
+ const session = ptySessionsMap.get(targetSessionKey);
2012
+
2013
+ if (data.action === "close-shell" && session) {
2014
+ // Kill the shell session
2015
+ console.log("๐Ÿ”ช Force closing shell session:", targetSessionKey);
2016
+
2017
+ // Notify all shell clients
2018
+ const closeMsg = JSON.stringify({
2019
+ type: "output",
2020
+ data: `\r\n\x1b[31m[Session closed by another client]\x1b[0m\r\n`,
2021
+ });
2022
+
2023
+ for (const client of session.clients) {
2024
+ if (client.readyState === WebSocket.OPEN) {
2025
+ try {
2026
+ client.send(closeMsg);
2027
+ } catch {
2028
+ // Ignore
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ // Kill the PTY process
2034
+ if (session.pty && session.pty.kill) {
2035
+ session.pty.kill();
2036
+ }
2037
+
2038
+ // Kill tmux session if exists
2039
+ if (session.tmuxSessionName) {
2040
+ killTmuxSession(session.tmuxSessionName);
2041
+ }
2042
+
2043
+ ptySessionsMap.delete(targetSessionKey);
2044
+
2045
+ // Confirm to the requesting client
2046
+ ws.send(
2047
+ JSON.stringify({
2048
+ type: "conflict-resolved",
2049
+ action: "close-shell",
2050
+ sessionKey: targetSessionKey,
2051
+ }),
2052
+ );
1383
2053
  }
2054
+ // Note: 'fork-session' is handled by the frontend creating a new session
1384
2055
  }
1385
2056
  } catch (error) {
1386
2057
  console.error("[ERROR] Shell WebSocket error:", error.message);
@@ -1396,27 +2067,42 @@ function handleShellConnection(ws) {
1396
2067
  });
1397
2068
 
1398
2069
  ws.on("close", () => {
1399
- console.log("๐Ÿ”Œ Shell client disconnected");
2070
+ console.log("๐Ÿ”Œ Shell client disconnected:", clientId);
1400
2071
 
1401
2072
  if (ptySessionKey) {
1402
2073
  const session = ptySessionsMap.get(ptySessionKey);
1403
- if (session) {
2074
+ if (session && session.clients) {
2075
+ // Remove this client from the session
2076
+ session.clients.delete(ws);
2077
+
1404
2078
  console.log(
1405
- "โณ PTY session kept alive, will timeout in 30 minutes:",
1406
- ptySessionKey,
2079
+ `๐Ÿ“Š Session ${ptySessionKey}: ${session.clients.size} client(s) remaining`,
1407
2080
  );
1408
- session.ws = null;
1409
2081
 
1410
- session.timeoutId = setTimeout(() => {
2082
+ // Broadcast updated client count to remaining clients
2083
+ if (session.clients.size > 0) {
2084
+ broadcastSessionState(ptySessionKey);
2085
+ } else {
2086
+ // No clients left, start timeout
1411
2087
  console.log(
1412
- "โฐ PTY session timeout, killing process:",
2088
+ "โณ PTY session kept alive, will timeout in 30 minutes:",
1413
2089
  ptySessionKey,
1414
2090
  );
1415
- if (session.pty && session.pty.kill) {
1416
- session.pty.kill();
1417
- }
1418
- ptySessionsMap.delete(ptySessionKey);
1419
- }, PTY_SESSION_TIMEOUT);
2091
+
2092
+ session.timeoutId = setTimeout(() => {
2093
+ console.log(
2094
+ "โฐ PTY session timeout, killing process:",
2095
+ ptySessionKey,
2096
+ );
2097
+ if (session.pty && session.pty.kill) {
2098
+ session.pty.kill();
2099
+ }
2100
+ if (session.tmuxSessionName) {
2101
+ killTmuxSession(session.tmuxSessionName);
2102
+ }
2103
+ ptySessionsMap.delete(ptySessionKey);
2104
+ }, PTY_SESSION_TIMEOUT);
2105
+ }
1420
2106
  }
1421
2107
  }
1422
2108
  });
@@ -2110,6 +2796,26 @@ async function startServer() {
2110
2796
 
2111
2797
  // Start watching the projects folder for changes
2112
2798
  await setupProjectsWatcher();
2799
+
2800
+ // Initialize sessions and projects caches with initial project data
2801
+ try {
2802
+ const initialProjects = await getProjects();
2803
+ updateSessionsCache(initialProjects);
2804
+ updateProjectsCache(initialProjects);
2805
+ console.log(
2806
+ `${c.ok("[OK]")} Sessions cache initialized with ${initialProjects.length} projects`,
2807
+ );
2808
+ console.log(
2809
+ `${c.ok("[OK]")} Projects cache initialized with ${initialProjects.length} projects`,
2810
+ );
2811
+
2812
+ // Clean up stale tmux session entries on startup
2813
+ cleanupStaleTmuxSessions();
2814
+ } catch (cacheError) {
2815
+ console.warn(
2816
+ `${c.warn("[WARN]")} Failed to initialize caches: ${cacheError.message}`,
2817
+ );
2818
+ }
2113
2819
  });
2114
2820
  } catch (error) {
2115
2821
  console.error("[ERROR] Failed to start server:", error);