@epiphytic/claudecodeui 1.0.0 โ 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/orchestrator/client.js +37 -1
- 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-DfR9xEkp.css +0 -32
- package/dist/assets/index-DvlVn6Eb.js +0 -1231
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
"โป๏ธ
|
|
1529
|
+
"โป๏ธ Adding client to existing PTY session:",
|
|
1114
1530
|
ptySessionKey,
|
|
1115
1531
|
);
|
|
1116
1532
|
shellProcess = existingSession.pty;
|
|
1117
1533
|
|
|
1118
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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((
|
|
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(
|
|
1787
|
+
session.buffer.push(outputData);
|
|
1275
1788
|
} else {
|
|
1276
1789
|
session.buffer.shift();
|
|
1277
|
-
session.buffer.push(
|
|
1790
|
+
session.buffer.push(outputData);
|
|
1278
1791
|
}
|
|
1279
1792
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1406
|
-
ptySessionKey,
|
|
2079
|
+
`๐ Session ${ptySessionKey}: ${session.clients.size} client(s) remaining`,
|
|
1407
2080
|
);
|
|
1408
|
-
session.ws = null;
|
|
1409
2081
|
|
|
1410
|
-
|
|
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
|
-
"
|
|
2088
|
+
"โณ PTY session kept alive, will timeout in 30 minutes:",
|
|
1413
2089
|
ptySessionKey,
|
|
1414
2090
|
);
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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);
|