@epiphytic/claudecodeui 1.0.1 โ 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DqxzEd_8.js +1245 -0
- package/dist/assets/index-r43D8sh4.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/database/db.js +222 -0
- package/server/database/init.sql +27 -1
- package/server/external-session-detector.js +403 -0
- package/server/index.js +885 -116
- package/server/orchestrator/client.js +361 -16
- package/server/orchestrator/index.js +83 -8
- package/server/orchestrator/protocol.js +67 -0
- package/server/projects-cache.js +196 -0
- package/server/projects.js +760 -464
- package/server/routes/projects.js +248 -92
- package/server/routes/sessions.js +106 -0
- package/server/session-lock.js +253 -0
- package/server/sessions-cache.js +183 -0
- package/server/tmux-manager.js +403 -0
- package/dist/assets/index-COkp1acE.js +0 -1231
- package/dist/assets/index-DfR9xEkp.css +0 -32
package/server/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
|
|
@@ -431,7 +589,7 @@ app.get(
|
|
|
431
589
|
},
|
|
432
590
|
);
|
|
433
591
|
|
|
434
|
-
// Get messages for a specific session
|
|
592
|
+
// Get messages for a specific session with ETag caching support
|
|
435
593
|
app.get(
|
|
436
594
|
"/api/projects/:projectName/sessions/:sessionId/messages",
|
|
437
595
|
authenticateToken,
|
|
@@ -451,6 +609,27 @@ app.get(
|
|
|
451
609
|
parsedOffset,
|
|
452
610
|
);
|
|
453
611
|
|
|
612
|
+
// Generate ETag based on message count and last timestamp
|
|
613
|
+
const messages = Array.isArray(result) ? result : result.messages || [];
|
|
614
|
+
const total = Array.isArray(result) ? messages.length : result.total || 0;
|
|
615
|
+
const lastTimestamp =
|
|
616
|
+
messages.length > 0
|
|
617
|
+
? messages[messages.length - 1]?.timestamp || ""
|
|
618
|
+
: "";
|
|
619
|
+
const currentETag = `"${sessionId}-${total}-${Buffer.from(lastTimestamp).toString("base64").slice(0, 16)}"`;
|
|
620
|
+
|
|
621
|
+
// Check If-None-Match header for conditional request
|
|
622
|
+
const clientETag = req.headers["if-none-match"];
|
|
623
|
+
if (clientETag && clientETag === currentETag) {
|
|
624
|
+
return res.status(304).end();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Set caching headers
|
|
628
|
+
res.set({
|
|
629
|
+
"Cache-Control": "private, max-age=5",
|
|
630
|
+
ETag: currentETag,
|
|
631
|
+
});
|
|
632
|
+
|
|
454
633
|
// Handle both old and new response formats
|
|
455
634
|
if (Array.isArray(result)) {
|
|
456
635
|
// Backward compatibility: no pagination parameters were provided
|
|
@@ -518,6 +697,75 @@ app.delete(
|
|
|
518
697
|
},
|
|
519
698
|
);
|
|
520
699
|
|
|
700
|
+
// Terminate shell session endpoint (for conflict resolution from chat interface)
|
|
701
|
+
app.post("/api/shell/terminate", authenticateToken, async (req, res) => {
|
|
702
|
+
try {
|
|
703
|
+
const { projectPath, sessionId } = req.body;
|
|
704
|
+
|
|
705
|
+
if (!projectPath) {
|
|
706
|
+
return res.status(400).json({ error: "projectPath is required" });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Build session key (same format as in handleShellConnection)
|
|
710
|
+
const sessionKey = sessionId
|
|
711
|
+
? `${projectPath}:${sessionId}`
|
|
712
|
+
: `${projectPath}:plain-shell`;
|
|
713
|
+
|
|
714
|
+
console.log("๐ช API: Force closing shell session:", sessionKey);
|
|
715
|
+
|
|
716
|
+
const session = ptySessionsMap.get(sessionKey);
|
|
717
|
+
|
|
718
|
+
if (!session) {
|
|
719
|
+
// Session not found - might already be closed
|
|
720
|
+
return res.json({
|
|
721
|
+
success: true,
|
|
722
|
+
message: "Session not found or already closed",
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Notify all shell clients
|
|
727
|
+
const closeMsg = JSON.stringify({
|
|
728
|
+
type: "output",
|
|
729
|
+
data: `\r\n\x1b[31m[Session terminated by another client]\x1b[0m\r\n`,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
for (const client of session.clients) {
|
|
733
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
734
|
+
try {
|
|
735
|
+
client.send(closeMsg);
|
|
736
|
+
// Also send a session-closed message so clients know to disconnect
|
|
737
|
+
client.send(JSON.stringify({ type: "session-closed" }));
|
|
738
|
+
} catch {
|
|
739
|
+
// Ignore send errors
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Kill the PTY process
|
|
745
|
+
if (session.pty && session.pty.kill) {
|
|
746
|
+
session.pty.kill();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Kill tmux session if exists
|
|
750
|
+
if (session.tmuxSessionName) {
|
|
751
|
+
killTmuxSession(session.tmuxSessionName);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Clear any pending timeout
|
|
755
|
+
if (session.timeoutId) {
|
|
756
|
+
clearTimeout(session.timeoutId);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
ptySessionsMap.delete(sessionKey);
|
|
760
|
+
|
|
761
|
+
console.log("โ
Shell session terminated:", sessionKey);
|
|
762
|
+
res.json({ success: true, sessionKey });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
console.error("[API] Error terminating shell session:", error);
|
|
765
|
+
res.status(500).json({ error: error.message });
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
521
769
|
// Create project endpoint
|
|
522
770
|
app.post("/api/projects/create", authenticateToken, async (req, res) => {
|
|
523
771
|
try {
|
|
@@ -899,11 +1147,112 @@ async function handleChatMessage(ws, writer, messageData) {
|
|
|
899
1147
|
const sessionIdForTracking =
|
|
900
1148
|
data.options?.sessionId || data.sessionId || `session-${Date.now()}`;
|
|
901
1149
|
|
|
1150
|
+
// Handle proactive external session check (before user submits a prompt)
|
|
1151
|
+
if (data.type === "check-external-session") {
|
|
1152
|
+
const projectPath = data.projectPath;
|
|
1153
|
+
if (projectPath) {
|
|
1154
|
+
const externalCheck = detectExternalClaude(projectPath);
|
|
1155
|
+
writer.send({
|
|
1156
|
+
type: "external-session-check-result",
|
|
1157
|
+
projectPath,
|
|
1158
|
+
hasExternalSession: externalCheck.hasExternalSession,
|
|
1159
|
+
details: externalCheck.hasExternalSession
|
|
1160
|
+
? {
|
|
1161
|
+
processIds: externalCheck.processes.map((p) => p.pid),
|
|
1162
|
+
commands: externalCheck.processes.map((p) => p.command),
|
|
1163
|
+
tmuxSessions: externalCheck.tmuxSessions.map(
|
|
1164
|
+
(s) => s.sessionName,
|
|
1165
|
+
),
|
|
1166
|
+
lockFile: externalCheck.lockFile.exists
|
|
1167
|
+
? externalCheck.lockFile.lockFile
|
|
1168
|
+
: null,
|
|
1169
|
+
}
|
|
1170
|
+
: null,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
902
1176
|
if (data.type === "claude-command") {
|
|
903
1177
|
console.log("[DEBUG] User message:", data.command || "[Continue/Resume]");
|
|
904
1178
|
console.log("๐ Project:", data.options?.projectPath || "Unknown");
|
|
905
1179
|
console.log("๐ Session:", data.options?.sessionId ? "Resume" : "New");
|
|
906
1180
|
|
|
1181
|
+
const projectPath = data.options?.projectPath;
|
|
1182
|
+
const sessionId = data.options?.sessionId || sessionIdForTracking;
|
|
1183
|
+
const ptySessionKey = getPtySessionKey(projectPath, sessionId);
|
|
1184
|
+
|
|
1185
|
+
// Check for active shell session with clients
|
|
1186
|
+
const shellSession = ptySessionsMap.get(ptySessionKey);
|
|
1187
|
+
if (
|
|
1188
|
+
shellSession &&
|
|
1189
|
+
shellSession.clients &&
|
|
1190
|
+
shellSession.clients.size > 0
|
|
1191
|
+
) {
|
|
1192
|
+
// Shell is active with connected clients
|
|
1193
|
+
writer.send({
|
|
1194
|
+
type: "session-conflict",
|
|
1195
|
+
sessionKey: ptySessionKey,
|
|
1196
|
+
conflictType: "shell-active",
|
|
1197
|
+
message: "A shell session is active with connected clients",
|
|
1198
|
+
clientCount: shellSession.clients.size,
|
|
1199
|
+
options: ["close-shell", "fork-session", "cancel"],
|
|
1200
|
+
});
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Check for external Claude sessions
|
|
1205
|
+
if (projectPath) {
|
|
1206
|
+
const externalCheck = detectExternalClaude(projectPath);
|
|
1207
|
+
if (externalCheck.hasExternalSession) {
|
|
1208
|
+
writer.send({
|
|
1209
|
+
type: "external-session-detected",
|
|
1210
|
+
projectPath,
|
|
1211
|
+
details: {
|
|
1212
|
+
processIds: externalCheck.processes.map((p) => p.pid),
|
|
1213
|
+
tmuxSessions: externalCheck.tmuxSessions.map(
|
|
1214
|
+
(s) => s.sessionName,
|
|
1215
|
+
),
|
|
1216
|
+
lockFile: externalCheck.lockFile.exists
|
|
1217
|
+
? externalCheck.lockFile.lockFile
|
|
1218
|
+
: null,
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
// Continue with warning - don't block the chat
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Acquire chat lock
|
|
1226
|
+
const clientId = `chat-${Date.now()}`;
|
|
1227
|
+
const lockResult = sessionLock.acquireLock(
|
|
1228
|
+
ptySessionKey,
|
|
1229
|
+
clientId,
|
|
1230
|
+
"chat",
|
|
1231
|
+
{
|
|
1232
|
+
sessionId,
|
|
1233
|
+
startedAt: Date.now(),
|
|
1234
|
+
},
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
if (!lockResult.success) {
|
|
1238
|
+
writer.send({
|
|
1239
|
+
type: "session-conflict",
|
|
1240
|
+
sessionKey: ptySessionKey,
|
|
1241
|
+
conflictType: "chat-locked",
|
|
1242
|
+
message: lockResult.reason,
|
|
1243
|
+
holder: lockResult.holder,
|
|
1244
|
+
options: ["wait", "cancel"],
|
|
1245
|
+
});
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Update shell session mode if it exists
|
|
1250
|
+
if (shellSession) {
|
|
1251
|
+
shellSession.mode = "chat";
|
|
1252
|
+
shellSession.lockedBy = clientId;
|
|
1253
|
+
broadcastSessionState(ptySessionKey);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
907
1256
|
// Track busy status for orchestrator
|
|
908
1257
|
if (orchestratorStatusHooks) {
|
|
909
1258
|
orchestratorStatusHooks.onQueryStart(sessionIdForTracking);
|
|
@@ -913,6 +1262,16 @@ async function handleChatMessage(ws, writer, messageData) {
|
|
|
913
1262
|
// Use Claude Agents SDK
|
|
914
1263
|
await queryClaudeSDK(data.command, data.options, writer);
|
|
915
1264
|
} finally {
|
|
1265
|
+
// Release chat lock
|
|
1266
|
+
sessionLock.releaseLock(ptySessionKey, clientId);
|
|
1267
|
+
|
|
1268
|
+
// Reset shell session mode
|
|
1269
|
+
if (shellSession) {
|
|
1270
|
+
shellSession.mode = "shell";
|
|
1271
|
+
shellSession.lockedBy = null;
|
|
1272
|
+
broadcastSessionState(ptySessionKey);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
916
1275
|
// Mark as no longer busy
|
|
917
1276
|
if (orchestratorStatusHooks) {
|
|
918
1277
|
orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
|
|
@@ -1054,12 +1413,14 @@ async function handleChatMessage(ws, writer, messageData) {
|
|
|
1054
1413
|
}
|
|
1055
1414
|
}
|
|
1056
1415
|
|
|
1057
|
-
// Handle shell WebSocket connections
|
|
1416
|
+
// Handle shell WebSocket connections with multi-client support
|
|
1058
1417
|
function handleShellConnection(ws) {
|
|
1059
1418
|
console.log("๐ Shell client connected");
|
|
1419
|
+
|
|
1420
|
+
// Generate unique client ID for this connection
|
|
1421
|
+
const clientId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
1060
1422
|
let shellProcess = null;
|
|
1061
1423
|
let ptySessionKey = null;
|
|
1062
|
-
let outputBuffer = [];
|
|
1063
1424
|
|
|
1064
1425
|
ws.on("message", async (message) => {
|
|
1065
1426
|
try {
|
|
@@ -1089,7 +1450,7 @@ function handleShellConnection(ws) {
|
|
|
1089
1450
|
isPlainShell && initialCommand
|
|
1090
1451
|
? `_cmd_${Buffer.from(initialCommand).toString("base64").slice(0, 16)}`
|
|
1091
1452
|
: "";
|
|
1092
|
-
ptySessionKey =
|
|
1453
|
+
ptySessionKey = getPtySessionKey(projectPath, sessionId, commandSuffix);
|
|
1093
1454
|
|
|
1094
1455
|
// Kill any existing login session before starting fresh
|
|
1095
1456
|
if (isLoginCommand) {
|
|
@@ -1101,6 +1462,10 @@ function handleShellConnection(ws) {
|
|
|
1101
1462
|
);
|
|
1102
1463
|
if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
|
|
1103
1464
|
if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
|
|
1465
|
+
// Kill tmux session if exists
|
|
1466
|
+
if (oldSession.tmuxSessionName) {
|
|
1467
|
+
killTmuxSession(oldSession.tmuxSessionName);
|
|
1468
|
+
}
|
|
1104
1469
|
ptySessionsMap.delete(ptySessionKey);
|
|
1105
1470
|
}
|
|
1106
1471
|
}
|
|
@@ -1108,25 +1473,133 @@ function handleShellConnection(ws) {
|
|
|
1108
1473
|
const existingSession = isLoginCommand
|
|
1109
1474
|
? null
|
|
1110
1475
|
: ptySessionsMap.get(ptySessionKey);
|
|
1476
|
+
|
|
1477
|
+
// Check for saved tmux session in database (for server restarts)
|
|
1478
|
+
if (!existingSession && !isLoginCommand && !isPlainShell) {
|
|
1479
|
+
const savedTmuxName = tmuxSessionsDb.getTmuxSession(
|
|
1480
|
+
projectPath,
|
|
1481
|
+
sessionId,
|
|
1482
|
+
);
|
|
1483
|
+
if (savedTmuxName && tmuxSessionExists(savedTmuxName)) {
|
|
1484
|
+
console.log(
|
|
1485
|
+
"โป๏ธ Found existing tmux session from database:",
|
|
1486
|
+
savedTmuxName,
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// Reconnect to the existing tmux session
|
|
1490
|
+
const termCols = data.cols || 80;
|
|
1491
|
+
const termRows = data.rows || 24;
|
|
1492
|
+
|
|
1493
|
+
const attachResult = attachToTmuxSession(savedTmuxName, pty, {
|
|
1494
|
+
cols: termCols,
|
|
1495
|
+
rows: termRows,
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
if (attachResult && attachResult.pty) {
|
|
1499
|
+
shellProcess = attachResult.pty;
|
|
1500
|
+
|
|
1501
|
+
// Update last used timestamp
|
|
1502
|
+
tmuxSessionsDb.touchTmuxSession(projectPath, sessionId);
|
|
1503
|
+
|
|
1504
|
+
// Create session with multi-client support
|
|
1505
|
+
const clients = new Set([ws]);
|
|
1506
|
+
ptySessionsMap.set(ptySessionKey, {
|
|
1507
|
+
pty: shellProcess,
|
|
1508
|
+
clients,
|
|
1509
|
+
buffer: [],
|
|
1510
|
+
mode: "shell",
|
|
1511
|
+
lockedBy: null,
|
|
1512
|
+
tmuxSessionName: savedTmuxName,
|
|
1513
|
+
timeoutId: null,
|
|
1514
|
+
projectPath,
|
|
1515
|
+
sessionId,
|
|
1516
|
+
createdAt: Date.now(),
|
|
1517
|
+
lastActivity: Date.now(),
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
// Handle data output - broadcast to all clients
|
|
1521
|
+
shellProcess.onData((outputData) => {
|
|
1522
|
+
const session = ptySessionsMap.get(ptySessionKey);
|
|
1523
|
+
if (!session) return;
|
|
1524
|
+
session.lastActivity = Date.now();
|
|
1525
|
+
|
|
1526
|
+
// Add to buffer
|
|
1527
|
+
if (session.buffer.length < 5000) {
|
|
1528
|
+
session.buffer.push(outputData);
|
|
1529
|
+
} else {
|
|
1530
|
+
session.buffer.shift();
|
|
1531
|
+
session.buffer.push(outputData);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Broadcast to all clients
|
|
1535
|
+
broadcastToSession(ptySessionKey, {
|
|
1536
|
+
type: "output",
|
|
1537
|
+
data: outputData,
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
shellProcess.onExit(({ exitCode }) => {
|
|
1542
|
+
console.log(`๐ด Tmux process exited with code ${exitCode}`);
|
|
1543
|
+
const session = ptySessionsMap.get(ptySessionKey);
|
|
1544
|
+
if (session) {
|
|
1545
|
+
broadcastToSession(ptySessionKey, {
|
|
1546
|
+
type: "output",
|
|
1547
|
+
data: `\r\n\x1b[33mSession ended with exit code ${exitCode}\x1b[0m\r\n`,
|
|
1548
|
+
});
|
|
1549
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
ws.send(
|
|
1554
|
+
JSON.stringify({
|
|
1555
|
+
type: "output",
|
|
1556
|
+
data: `\x1b[36m[Reconnected to existing tmux session]\x1b[0m\r\n`,
|
|
1557
|
+
}),
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
broadcastSessionState(ptySessionKey);
|
|
1561
|
+
return;
|
|
1562
|
+
} else {
|
|
1563
|
+
// Failed to attach, will create new session below
|
|
1564
|
+
console.log(
|
|
1565
|
+
"โ ๏ธ Failed to attach to saved tmux session, creating new one",
|
|
1566
|
+
);
|
|
1567
|
+
// Clean up stale database entry
|
|
1568
|
+
tmuxSessionsDb.deleteTmuxSession(projectPath, sessionId);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1111
1573
|
if (existingSession) {
|
|
1574
|
+
// Multi-client: Add this client to the existing session
|
|
1112
1575
|
console.log(
|
|
1113
|
-
"โป๏ธ
|
|
1576
|
+
"โป๏ธ Adding client to existing PTY session:",
|
|
1114
1577
|
ptySessionKey,
|
|
1115
1578
|
);
|
|
1116
1579
|
shellProcess = existingSession.pty;
|
|
1117
1580
|
|
|
1118
|
-
|
|
1581
|
+
// Clear timeout since we have an active client
|
|
1582
|
+
if (existingSession.timeoutId) {
|
|
1583
|
+
clearTimeout(existingSession.timeoutId);
|
|
1584
|
+
existingSession.timeoutId = null;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Add client to the clients Set
|
|
1588
|
+
existingSession.clients.add(ws);
|
|
1589
|
+
existingSession.lastActivity = Date.now();
|
|
1119
1590
|
|
|
1591
|
+
// Send reconnect message to this client only
|
|
1120
1592
|
ws.send(
|
|
1121
1593
|
JSON.stringify({
|
|
1122
1594
|
type: "output",
|
|
1123
|
-
data: `\x1b[36m[
|
|
1595
|
+
data: `\x1b[36m[Connected to session - ${existingSession.clients.size} client(s) connected]\x1b[0m\r\n`,
|
|
1124
1596
|
}),
|
|
1125
1597
|
);
|
|
1126
1598
|
|
|
1599
|
+
// Send buffered output to this new client
|
|
1127
1600
|
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
1128
1601
|
console.log(
|
|
1129
|
-
`๐ Sending ${existingSession.buffer.length} buffered messages`,
|
|
1602
|
+
`๐ Sending ${existingSession.buffer.length} buffered messages to new client`,
|
|
1130
1603
|
);
|
|
1131
1604
|
existingSession.buffer.forEach((bufferedData) => {
|
|
1132
1605
|
ws.send(
|
|
@@ -1138,7 +1611,22 @@ function handleShellConnection(ws) {
|
|
|
1138
1611
|
});
|
|
1139
1612
|
}
|
|
1140
1613
|
|
|
1141
|
-
|
|
1614
|
+
// Send session state to this client
|
|
1615
|
+
ws.send(
|
|
1616
|
+
JSON.stringify({
|
|
1617
|
+
type: "session-state-update",
|
|
1618
|
+
sessionKey: ptySessionKey,
|
|
1619
|
+
state: {
|
|
1620
|
+
mode: existingSession.mode || "shell",
|
|
1621
|
+
clientCount: existingSession.clients.size,
|
|
1622
|
+
lockedBy: existingSession.lockedBy,
|
|
1623
|
+
tmuxSessionName: existingSession.tmuxSessionName,
|
|
1624
|
+
},
|
|
1625
|
+
}),
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
// Broadcast updated client count to all clients
|
|
1629
|
+
broadcastSessionState(ptySessionKey);
|
|
1142
1630
|
|
|
1143
1631
|
return;
|
|
1144
1632
|
}
|
|
@@ -1221,114 +1709,201 @@ function handleShellConnection(ws) {
|
|
|
1221
1709
|
|
|
1222
1710
|
console.log("๐ง Executing shell command:", shellCommand);
|
|
1223
1711
|
|
|
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
1712
|
// Use terminal dimensions from client if provided, otherwise use defaults
|
|
1232
1713
|
const termCols = data.cols || 80;
|
|
1233
1714
|
const termRows = data.rows || 24;
|
|
1234
1715
|
console.log("๐ Using terminal dimensions:", termCols, "x", termRows);
|
|
1235
1716
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1717
|
+
// Track tmux session name if we use tmux
|
|
1718
|
+
let tmuxSessionName = null;
|
|
1719
|
+
|
|
1720
|
+
// For non-plain-shell sessions on Unix, use tmux for session persistence
|
|
1721
|
+
const useTmux =
|
|
1722
|
+
!isPlainShell &&
|
|
1723
|
+
os.platform() !== "win32" &&
|
|
1724
|
+
checkTmuxAvailable().available;
|
|
1725
|
+
|
|
1726
|
+
if (useTmux) {
|
|
1727
|
+
// Create tmux session
|
|
1728
|
+
const tmuxResult = createTmuxSession(
|
|
1729
|
+
projectPath,
|
|
1730
|
+
sessionId || "shell",
|
|
1731
|
+
{
|
|
1732
|
+
cols: termCols,
|
|
1733
|
+
rows: termRows,
|
|
1734
|
+
},
|
|
1735
|
+
);
|
|
1736
|
+
|
|
1737
|
+
if (tmuxResult.success) {
|
|
1738
|
+
tmuxSessionName = tmuxResult.tmuxSessionName;
|
|
1739
|
+
console.log("๐บ Created/found tmux session:", tmuxSessionName);
|
|
1740
|
+
|
|
1741
|
+
// Save to database for persistence across server restarts
|
|
1742
|
+
tmuxSessionsDb.saveTmuxSession(
|
|
1743
|
+
projectPath,
|
|
1744
|
+
sessionId,
|
|
1745
|
+
tmuxSessionName,
|
|
1746
|
+
);
|
|
1747
|
+
|
|
1748
|
+
// Send the shell command to tmux
|
|
1749
|
+
const { spawnSync } = await import("child_process");
|
|
1750
|
+
spawnSync(
|
|
1751
|
+
"tmux",
|
|
1752
|
+
["send-keys", "-t", tmuxSessionName, shellCommand, "Enter"],
|
|
1753
|
+
{
|
|
1754
|
+
encoding: "utf8",
|
|
1755
|
+
},
|
|
1756
|
+
);
|
|
1757
|
+
|
|
1758
|
+
// Attach to tmux session
|
|
1759
|
+
const attachResult = attachToTmuxSession(tmuxSessionName, pty, {
|
|
1760
|
+
cols: termCols,
|
|
1761
|
+
rows: termRows,
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
if (attachResult && attachResult.pty) {
|
|
1765
|
+
shellProcess = attachResult.pty;
|
|
1766
|
+
} else {
|
|
1767
|
+
// Fallback to direct PTY if tmux attach fails
|
|
1768
|
+
console.log(
|
|
1769
|
+
"โ ๏ธ Tmux attach failed, falling back to direct PTY",
|
|
1770
|
+
);
|
|
1771
|
+
tmuxSessionName = null;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Fallback to direct PTY spawn (for plain shell, Windows, or tmux failure)
|
|
1777
|
+
if (!shellProcess) {
|
|
1778
|
+
const shell = os.platform() === "win32" ? "powershell.exe" : "bash";
|
|
1779
|
+
const shellArgs =
|
|
1780
|
+
os.platform() === "win32"
|
|
1781
|
+
? ["-Command", shellCommand]
|
|
1782
|
+
: ["-c", shellCommand];
|
|
1783
|
+
|
|
1784
|
+
shellProcess = pty.spawn(shell, shellArgs, {
|
|
1785
|
+
name: "xterm-256color",
|
|
1786
|
+
cols: termCols,
|
|
1787
|
+
rows: termRows,
|
|
1788
|
+
cwd: os.homedir(),
|
|
1789
|
+
env: {
|
|
1790
|
+
...process.env,
|
|
1791
|
+
TERM: "xterm-256color",
|
|
1792
|
+
COLORTERM: "truecolor",
|
|
1793
|
+
FORCE_COLOR: "3",
|
|
1794
|
+
// Override browser opening commands to echo URL for detection
|
|
1795
|
+
BROWSER:
|
|
1796
|
+
os.platform() === "win32"
|
|
1797
|
+
? 'echo "OPEN_URL:"'
|
|
1798
|
+
: 'echo "OPEN_URL:"',
|
|
1799
|
+
},
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1253
1802
|
|
|
1254
1803
|
console.log(
|
|
1255
1804
|
"๐ข Shell process started with PTY, PID:",
|
|
1256
1805
|
shellProcess.pid,
|
|
1806
|
+
tmuxSessionName ? `(tmux: ${tmuxSessionName})` : "(direct)",
|
|
1257
1807
|
);
|
|
1258
1808
|
|
|
1809
|
+
// Create session with multi-client support
|
|
1810
|
+
const clients = new Set([ws]);
|
|
1259
1811
|
ptySessionsMap.set(ptySessionKey, {
|
|
1260
1812
|
pty: shellProcess,
|
|
1261
|
-
|
|
1813
|
+
clients,
|
|
1262
1814
|
buffer: [],
|
|
1815
|
+
mode: "shell",
|
|
1816
|
+
lockedBy: null,
|
|
1817
|
+
tmuxSessionName,
|
|
1263
1818
|
timeoutId: null,
|
|
1264
1819
|
projectPath,
|
|
1265
1820
|
sessionId,
|
|
1821
|
+
createdAt: Date.now(),
|
|
1822
|
+
lastActivity: Date.now(),
|
|
1266
1823
|
});
|
|
1267
1824
|
|
|
1268
|
-
// Handle data output
|
|
1269
|
-
shellProcess.onData((
|
|
1825
|
+
// Handle data output - broadcast to all clients
|
|
1826
|
+
shellProcess.onData((outputData) => {
|
|
1270
1827
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
1271
1828
|
if (!session) return;
|
|
1272
1829
|
|
|
1830
|
+
session.lastActivity = Date.now();
|
|
1831
|
+
|
|
1832
|
+
// Add to buffer (circular buffer, max 5000 items)
|
|
1273
1833
|
if (session.buffer.length < 5000) {
|
|
1274
|
-
session.buffer.push(
|
|
1834
|
+
session.buffer.push(outputData);
|
|
1275
1835
|
} else {
|
|
1276
1836
|
session.buffer.shift();
|
|
1277
|
-
session.buffer.push(
|
|
1837
|
+
session.buffer.push(outputData);
|
|
1278
1838
|
}
|
|
1279
1839
|
|
|
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
|
-
|
|
1840
|
+
// Process output for URL detection
|
|
1841
|
+
let processedData = outputData;
|
|
1842
|
+
|
|
1843
|
+
// Check for various URL opening patterns
|
|
1844
|
+
const patterns = [
|
|
1845
|
+
// Direct browser opening commands
|
|
1846
|
+
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
1847
|
+
// BROWSER environment variable override
|
|
1848
|
+
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
1849
|
+
// Git and other tools opening URLs
|
|
1850
|
+
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1851
|
+
// General URL patterns that might be opened
|
|
1852
|
+
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1853
|
+
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1854
|
+
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
|
1855
|
+
];
|
|
1856
|
+
|
|
1857
|
+
const detectedUrls = [];
|
|
1858
|
+
patterns.forEach((pattern) => {
|
|
1859
|
+
let match;
|
|
1860
|
+
while ((match = pattern.exec(outputData)) !== null) {
|
|
1861
|
+
const url = match[1];
|
|
1862
|
+
console.log("[DEBUG] Detected URL for opening:", url);
|
|
1863
|
+
detectedUrls.push(url);
|
|
1864
|
+
|
|
1865
|
+
// Replace the OPEN_URL pattern with a user-friendly message
|
|
1866
|
+
if (pattern.source.includes("OPEN_URL")) {
|
|
1867
|
+
processedData = processedData.replace(
|
|
1868
|
+
match[0],
|
|
1869
|
+
`[INFO] Opening in browser: ${url}`,
|
|
1309
1870
|
);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1310
1874
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1875
|
+
// Broadcast output to all clients
|
|
1876
|
+
for (const client of session.clients) {
|
|
1877
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1878
|
+
try {
|
|
1879
|
+
// Send URL opening message
|
|
1880
|
+
for (const url of detectedUrls) {
|
|
1881
|
+
client.send(
|
|
1882
|
+
JSON.stringify({
|
|
1883
|
+
type: "url_open",
|
|
1884
|
+
url,
|
|
1885
|
+
}),
|
|
1316
1886
|
);
|
|
1317
1887
|
}
|
|
1318
|
-
}
|
|
1319
|
-
});
|
|
1320
1888
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1889
|
+
// Send regular output
|
|
1890
|
+
client.send(
|
|
1891
|
+
JSON.stringify({
|
|
1892
|
+
type: "output",
|
|
1893
|
+
data: processedData,
|
|
1894
|
+
}),
|
|
1895
|
+
);
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error(
|
|
1898
|
+
"[WARN] Failed to send to client:",
|
|
1899
|
+
err.message,
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1328
1903
|
}
|
|
1329
1904
|
});
|
|
1330
1905
|
|
|
1331
|
-
// Handle process exit
|
|
1906
|
+
// Handle process exit - broadcast to all clients
|
|
1332
1907
|
shellProcess.onExit((exitCode) => {
|
|
1333
1908
|
console.log(
|
|
1334
1909
|
"๐ Shell process exited with code:",
|
|
@@ -1337,21 +1912,34 @@ function handleShellConnection(ws) {
|
|
|
1337
1912
|
exitCode.signal,
|
|
1338
1913
|
);
|
|
1339
1914
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1915
|
+
|
|
1916
|
+
if (session) {
|
|
1917
|
+
// Broadcast exit to all clients
|
|
1918
|
+
const exitMsg = JSON.stringify({
|
|
1919
|
+
type: "output",
|
|
1920
|
+
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ""}\x1b[0m\r\n`,
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
for (const client of session.clients) {
|
|
1924
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1925
|
+
try {
|
|
1926
|
+
client.send(exitMsg);
|
|
1927
|
+
} catch {
|
|
1928
|
+
// Ignore send errors during exit
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
if (session.timeoutId) {
|
|
1934
|
+
clearTimeout(session.timeoutId);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Kill tmux session if it exists
|
|
1938
|
+
if (session.tmuxSessionName) {
|
|
1939
|
+
killTmuxSession(session.tmuxSessionName);
|
|
1940
|
+
}
|
|
1354
1941
|
}
|
|
1942
|
+
|
|
1355
1943
|
ptySessionsMap.delete(ptySessionKey);
|
|
1356
1944
|
shellProcess = null;
|
|
1357
1945
|
});
|
|
@@ -1366,9 +1954,25 @@ function handleShellConnection(ws) {
|
|
|
1366
1954
|
}
|
|
1367
1955
|
} else if (data.type === "input") {
|
|
1368
1956
|
// Send input to shell process
|
|
1957
|
+
const session = ptySessionsMap.get(ptySessionKey);
|
|
1958
|
+
|
|
1959
|
+
// Check if session is locked by chat
|
|
1960
|
+
if (session && session.mode === "chat") {
|
|
1961
|
+
ws.send(
|
|
1962
|
+
JSON.stringify({
|
|
1963
|
+
type: "output",
|
|
1964
|
+
data: `\r\n\x1b[33m[Chat in progress - shell input disabled]\x1b[0m\r\n`,
|
|
1965
|
+
}),
|
|
1966
|
+
);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1369
1970
|
if (shellProcess && shellProcess.write) {
|
|
1370
1971
|
try {
|
|
1371
1972
|
shellProcess.write(data.data);
|
|
1973
|
+
if (session) {
|
|
1974
|
+
session.lastActivity = Date.now();
|
|
1975
|
+
}
|
|
1372
1976
|
} catch (error) {
|
|
1373
1977
|
console.error("Error writing to shell:", error);
|
|
1374
1978
|
}
|
|
@@ -1380,7 +1984,121 @@ function handleShellConnection(ws) {
|
|
|
1380
1984
|
if (shellProcess && shellProcess.resize) {
|
|
1381
1985
|
console.log("Terminal resize requested:", data.cols, "x", data.rows);
|
|
1382
1986
|
shellProcess.resize(data.cols, data.rows);
|
|
1987
|
+
|
|
1988
|
+
// Also resize tmux session if using tmux
|
|
1989
|
+
const session = ptySessionsMap.get(ptySessionKey);
|
|
1990
|
+
if (session && session.tmuxSessionName) {
|
|
1991
|
+
resizeTmuxSession(session.tmuxSessionName, data.cols, data.rows);
|
|
1992
|
+
}
|
|
1383
1993
|
}
|
|
1994
|
+
} else if (data.type === "check-session-mode") {
|
|
1995
|
+
// Return current session mode
|
|
1996
|
+
const session = ptySessionsMap.get(data.sessionKey || ptySessionKey);
|
|
1997
|
+
ws.send(
|
|
1998
|
+
JSON.stringify({
|
|
1999
|
+
type: "session-state-update",
|
|
2000
|
+
sessionKey: data.sessionKey || ptySessionKey,
|
|
2001
|
+
state: session
|
|
2002
|
+
? {
|
|
2003
|
+
mode: session.mode || "shell",
|
|
2004
|
+
clientCount: session.clients ? session.clients.size : 0,
|
|
2005
|
+
lockedBy: session.lockedBy,
|
|
2006
|
+
tmuxSessionName: session.tmuxSessionName,
|
|
2007
|
+
}
|
|
2008
|
+
: null,
|
|
2009
|
+
}),
|
|
2010
|
+
);
|
|
2011
|
+
} else if (data.type === "terminate") {
|
|
2012
|
+
// Handle client request to terminate their session
|
|
2013
|
+
const session = ptySessionsMap.get(ptySessionKey);
|
|
2014
|
+
if (session) {
|
|
2015
|
+
console.log(
|
|
2016
|
+
"๐ช Client requested session termination:",
|
|
2017
|
+
ptySessionKey,
|
|
2018
|
+
);
|
|
2019
|
+
|
|
2020
|
+
// Notify all shell clients
|
|
2021
|
+
const closeMsg = JSON.stringify({
|
|
2022
|
+
type: "output",
|
|
2023
|
+
data: `\r\n\x1b[33m[Session terminated]\x1b[0m\r\n`,
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
for (const client of session.clients) {
|
|
2027
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2028
|
+
try {
|
|
2029
|
+
client.send(closeMsg);
|
|
2030
|
+
client.send(JSON.stringify({ type: "session-closed" }));
|
|
2031
|
+
} catch {
|
|
2032
|
+
// Ignore
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Kill the PTY process
|
|
2038
|
+
if (session.pty && session.pty.kill) {
|
|
2039
|
+
session.pty.kill();
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Kill tmux session if exists
|
|
2043
|
+
if (session.tmuxSessionName) {
|
|
2044
|
+
killTmuxSession(session.tmuxSessionName);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// Clear any pending timeout
|
|
2048
|
+
if (session.timeoutId) {
|
|
2049
|
+
clearTimeout(session.timeoutId);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
2053
|
+
console.log("โ
Shell session terminated by client:", ptySessionKey);
|
|
2054
|
+
}
|
|
2055
|
+
} else if (data.type === "resolve-conflict") {
|
|
2056
|
+
// Handle conflict resolution
|
|
2057
|
+
const targetSessionKey = data.sessionKey || ptySessionKey;
|
|
2058
|
+
const session = ptySessionsMap.get(targetSessionKey);
|
|
2059
|
+
|
|
2060
|
+
if (data.action === "close-shell" && session) {
|
|
2061
|
+
// Kill the shell session
|
|
2062
|
+
console.log("๐ช Force closing shell session:", targetSessionKey);
|
|
2063
|
+
|
|
2064
|
+
// Notify all shell clients
|
|
2065
|
+
const closeMsg = JSON.stringify({
|
|
2066
|
+
type: "output",
|
|
2067
|
+
data: `\r\n\x1b[31m[Session closed by another client]\x1b[0m\r\n`,
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
for (const client of session.clients) {
|
|
2071
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2072
|
+
try {
|
|
2073
|
+
client.send(closeMsg);
|
|
2074
|
+
} catch {
|
|
2075
|
+
// Ignore
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Kill the PTY process
|
|
2081
|
+
if (session.pty && session.pty.kill) {
|
|
2082
|
+
session.pty.kill();
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// Kill tmux session if exists
|
|
2086
|
+
if (session.tmuxSessionName) {
|
|
2087
|
+
killTmuxSession(session.tmuxSessionName);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
ptySessionsMap.delete(targetSessionKey);
|
|
2091
|
+
|
|
2092
|
+
// Confirm to the requesting client
|
|
2093
|
+
ws.send(
|
|
2094
|
+
JSON.stringify({
|
|
2095
|
+
type: "conflict-resolved",
|
|
2096
|
+
action: "close-shell",
|
|
2097
|
+
sessionKey: targetSessionKey,
|
|
2098
|
+
}),
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
// Note: 'fork-session' is handled by the frontend creating a new session
|
|
1384
2102
|
}
|
|
1385
2103
|
} catch (error) {
|
|
1386
2104
|
console.error("[ERROR] Shell WebSocket error:", error.message);
|
|
@@ -1396,27 +2114,42 @@ function handleShellConnection(ws) {
|
|
|
1396
2114
|
});
|
|
1397
2115
|
|
|
1398
2116
|
ws.on("close", () => {
|
|
1399
|
-
console.log("๐ Shell client disconnected");
|
|
2117
|
+
console.log("๐ Shell client disconnected:", clientId);
|
|
1400
2118
|
|
|
1401
2119
|
if (ptySessionKey) {
|
|
1402
2120
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
1403
|
-
if (session) {
|
|
2121
|
+
if (session && session.clients) {
|
|
2122
|
+
// Remove this client from the session
|
|
2123
|
+
session.clients.delete(ws);
|
|
2124
|
+
|
|
1404
2125
|
console.log(
|
|
1405
|
-
|
|
1406
|
-
ptySessionKey,
|
|
2126
|
+
`๐ Session ${ptySessionKey}: ${session.clients.size} client(s) remaining`,
|
|
1407
2127
|
);
|
|
1408
|
-
session.ws = null;
|
|
1409
2128
|
|
|
1410
|
-
|
|
2129
|
+
// Broadcast updated client count to remaining clients
|
|
2130
|
+
if (session.clients.size > 0) {
|
|
2131
|
+
broadcastSessionState(ptySessionKey);
|
|
2132
|
+
} else {
|
|
2133
|
+
// No clients left, start timeout
|
|
1411
2134
|
console.log(
|
|
1412
|
-
"
|
|
2135
|
+
"โณ PTY session kept alive, will timeout in 30 minutes:",
|
|
1413
2136
|
ptySessionKey,
|
|
1414
2137
|
);
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
2138
|
+
|
|
2139
|
+
session.timeoutId = setTimeout(() => {
|
|
2140
|
+
console.log(
|
|
2141
|
+
"โฐ PTY session timeout, killing process:",
|
|
2142
|
+
ptySessionKey,
|
|
2143
|
+
);
|
|
2144
|
+
if (session.pty && session.pty.kill) {
|
|
2145
|
+
session.pty.kill();
|
|
2146
|
+
}
|
|
2147
|
+
if (session.tmuxSessionName) {
|
|
2148
|
+
killTmuxSession(session.tmuxSessionName);
|
|
2149
|
+
}
|
|
2150
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
2151
|
+
}, PTY_SESSION_TIMEOUT);
|
|
2152
|
+
}
|
|
1420
2153
|
}
|
|
1421
2154
|
}
|
|
1422
2155
|
});
|
|
@@ -1703,6 +2436,9 @@ app.get(
|
|
|
1703
2436
|
try {
|
|
1704
2437
|
const { projectName, sessionId } = req.params;
|
|
1705
2438
|
const { provider = "claude" } = req.query;
|
|
2439
|
+
console.log(
|
|
2440
|
+
`[TOKEN-USAGE] Request for project: ${projectName}, session: ${sessionId}, provider: ${provider}`,
|
|
2441
|
+
);
|
|
1706
2442
|
const homeDir = os.homedir();
|
|
1707
2443
|
|
|
1708
2444
|
// Allow only safe characters in sessionId
|
|
@@ -1821,8 +2557,8 @@ app.get(
|
|
|
1821
2557
|
|
|
1822
2558
|
// Construct the JSONL file path
|
|
1823
2559
|
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
1824
|
-
// The encoding replaces /, spaces, ~, and
|
|
1825
|
-
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, "-");
|
|
2560
|
+
// The encoding replaces /, spaces, ~, _, and . with -
|
|
2561
|
+
const encodedPath = projectPath.replace(/[\\/:\s~_.]/g, "-");
|
|
1826
2562
|
const projectDir = path.join(homeDir, ".claude", "projects", encodedPath);
|
|
1827
2563
|
|
|
1828
2564
|
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
@@ -1842,9 +2578,22 @@ app.get(
|
|
|
1842
2578
|
fileContent = await fsPromises.readFile(jsonlPath, "utf8");
|
|
1843
2579
|
} catch (error) {
|
|
1844
2580
|
if (error.code === "ENOENT") {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
2581
|
+
// Session file doesn't exist yet (new session with no messages)
|
|
2582
|
+
// Return zero token usage instead of 404
|
|
2583
|
+
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
2584
|
+
const contextWindow = Number.isFinite(parsedContextWindow)
|
|
2585
|
+
? parsedContextWindow
|
|
2586
|
+
: 160000;
|
|
2587
|
+
return res.json({
|
|
2588
|
+
used: 0,
|
|
2589
|
+
total: contextWindow,
|
|
2590
|
+
breakdown: {
|
|
2591
|
+
input: 0,
|
|
2592
|
+
cacheCreation: 0,
|
|
2593
|
+
cacheRead: 0,
|
|
2594
|
+
},
|
|
2595
|
+
newSession: true,
|
|
2596
|
+
});
|
|
1848
2597
|
}
|
|
1849
2598
|
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
1850
2599
|
}
|
|
@@ -2110,6 +2859,26 @@ async function startServer() {
|
|
|
2110
2859
|
|
|
2111
2860
|
// Start watching the projects folder for changes
|
|
2112
2861
|
await setupProjectsWatcher();
|
|
2862
|
+
|
|
2863
|
+
// Initialize sessions and projects caches with initial project data
|
|
2864
|
+
try {
|
|
2865
|
+
const initialProjects = await getProjects();
|
|
2866
|
+
updateSessionsCache(initialProjects);
|
|
2867
|
+
updateProjectsCache(initialProjects);
|
|
2868
|
+
console.log(
|
|
2869
|
+
`${c.ok("[OK]")} Sessions cache initialized with ${initialProjects.length} projects`,
|
|
2870
|
+
);
|
|
2871
|
+
console.log(
|
|
2872
|
+
`${c.ok("[OK]")} Projects cache initialized with ${initialProjects.length} projects`,
|
|
2873
|
+
);
|
|
2874
|
+
|
|
2875
|
+
// Clean up stale tmux session entries on startup
|
|
2876
|
+
cleanupStaleTmuxSessions();
|
|
2877
|
+
} catch (cacheError) {
|
|
2878
|
+
console.warn(
|
|
2879
|
+
`${c.warn("[WARN]")} Failed to initialize caches: ${cacheError.message}`,
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2113
2882
|
});
|
|
2114
2883
|
} catch (error) {
|
|
2115
2884
|
console.error("[ERROR] Failed to start server:", error);
|