@epiphytic/claudecodeui 1.2.2 → 1.3.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
@@ -86,6 +86,12 @@ import {
86
86
  resizeSession as resizeTmuxSession,
87
87
  } from "./tmux-manager.js";
88
88
  import { detectExternalClaude } from "./external-session-detector.js";
89
+ import { createLogger } from "./logger.js";
90
+ import {
91
+ startCacheUpdater,
92
+ setProcessCacheActive,
93
+ CACHE_UPDATE_INTERVAL,
94
+ } from "./process-cache.js";
89
95
  import {
90
96
  spawnCursor,
91
97
  abortCursorSession,
@@ -97,7 +103,12 @@ import {
97
103
  abortCodexSession,
98
104
  isCodexSessionActive,
99
105
  getActiveCodexSessions,
106
+ cleanupCodexSessions,
100
107
  } from "./openai-codex.js";
108
+ import {
109
+ registerTask,
110
+ setActive as setMaintenanceActive,
111
+ } from "./maintenance-scheduler.js";
101
112
  import gitRoutes from "./routes/git.js";
102
113
  import authRoutes from "./routes/auth.js";
103
114
  import mcpRoutes from "./routes/mcp.js";
@@ -112,9 +123,27 @@ import cliAuthRoutes from "./routes/cli-auth.js";
112
123
  import userRoutes from "./routes/user.js";
113
124
  import codexRoutes from "./routes/codex.js";
114
125
  import sessionsRoutes from "./routes/sessions.js";
115
- import { updateSessionsCache } from "./sessions-cache.js";
126
+ import { updateSessionsCache, updateSessionTitle } from "./sessions-cache.js";
127
+ import {
128
+ getSessionTitleFromHistory,
129
+ invalidateCache as invalidateHistoryCache,
130
+ } from "./history-cache.js";
131
+ import {
132
+ getMessageList,
133
+ getMessageByNumber,
134
+ getMessagesByRange,
135
+ LIST_CACHE_TTL,
136
+ MESSAGE_CACHE_TTL,
137
+ } from "./messages-cache.js";
116
138
  import { updateProjectsCache } from "./projects-cache.js";
117
139
  import { initializeDatabase, tmuxSessionsDb } from "./database/db.js";
140
+ import {
141
+ getDatabase,
142
+ getProjectsFromDb,
143
+ getSessions as getSessionsFromDb,
144
+ getVersion,
145
+ } from "./database.js";
146
+ import { indexFile, indexAllProjects } from "./db-indexer.js";
118
147
  import {
119
148
  validateApiKey,
120
149
  authenticateToken,
@@ -122,6 +151,9 @@ import {
122
151
  } from "./middleware/auth.js";
123
152
  import { initializeOrchestrator, StatusValues } from "./orchestrator/index.js";
124
153
 
154
+ // Initialize logger for main server
155
+ const log = createLogger("server");
156
+
125
157
  // File system watcher for projects folder
126
158
  let projectsWatcher = null;
127
159
  const connectedClients = new Set();
@@ -185,10 +217,11 @@ async function setupProjectsWatcher() {
185
217
  persistent: true,
186
218
  ignoreInitial: true, // Don't fire events for existing files on startup
187
219
  followSymlinks: false,
188
- depth: 10, // Reasonable depth limit
220
+ usePolling: false,
221
+ depth: 2, // Reduced depth limit for performance
189
222
  awaitWriteFinish: {
190
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
191
- pollInterval: 50,
223
+ stabilityThreshold: 500, // Wait 500ms for file to stabilize
224
+ pollInterval: 200,
192
225
  },
193
226
  });
194
227
 
@@ -201,12 +234,18 @@ async function setupProjectsWatcher() {
201
234
  // Clear project directory cache when files change
202
235
  clearProjectDirectoryCache();
203
236
 
204
- // Get updated projects list
205
- const updatedProjects = await getProjects();
237
+ // Use SQLite incremental indexing instead of full re-parse
238
+ // This only processes new bytes in the changed file
239
+ const indexResult = await indexFile(filePath);
240
+ log.debug({ filePath, indexResult }, "File indexed");
241
+
242
+ // Get lightweight data from SQLite for client notification
243
+ // This is much cheaper than parsing all JSONL files
244
+ const projectsFromDb = getProjectsFromDb();
206
245
 
207
- // Update sessions and projects caches with new projects data
208
- updateSessionsCache(updatedProjects);
209
- updateProjectsCache(updatedProjects);
246
+ // Update in-memory caches from SQLite (for backward compatibility)
247
+ // TODO: Eventually remove these caches entirely
248
+ updateProjectsCache(projectsFromDb);
210
249
 
211
250
  // Clean up stale tmux session entries from database
212
251
  cleanupStaleTmuxSessions();
@@ -214,7 +253,7 @@ async function setupProjectsWatcher() {
214
253
  // Notify all connected clients about the project changes
215
254
  const updateMessage = JSON.stringify({
216
255
  type: "projects_updated",
217
- projects: updatedProjects,
256
+ projects: projectsFromDb,
218
257
  timestamp: new Date().toISOString(),
219
258
  changeType: eventType,
220
259
  changedFile: path.relative(claudeProjectsPath, filePath),
@@ -228,7 +267,7 @@ async function setupProjectsWatcher() {
228
267
  } catch (error) {
229
268
  console.error("[ERROR] Error handling project changes:", error);
230
269
  }
231
- }, 300); // 300ms debounce (slightly faster than before)
270
+ }, 2000); // 2 second debounce to reduce UI refresh frequency
232
271
  };
233
272
 
234
273
  // Set up event listeners
@@ -522,6 +561,64 @@ app.use("/api", validateApiKey);
522
561
  // Authentication routes (public)
523
562
  app.use("/api/auth", authRoutes);
524
563
 
564
+ // Environment info endpoint (public) - used by version checker
565
+ app.get("/api/environment", async (req, res) => {
566
+ try {
567
+ const { execSync } = await import("child_process");
568
+ const serverDir = __dirname;
569
+
570
+ let isGitRepo = false;
571
+ let gitBranch = null;
572
+ let shouldCheckForUpdates = true;
573
+
574
+ // Check if server directory is a git repo
575
+ try {
576
+ execSync("git rev-parse --is-inside-work-tree", {
577
+ cwd: serverDir,
578
+ stdio: "pipe",
579
+ });
580
+ isGitRepo = true;
581
+
582
+ // Get current branch
583
+ const branchOutput = execSync("git rev-parse --abbrev-ref HEAD", {
584
+ cwd: serverDir,
585
+ encoding: "utf8",
586
+ stdio: "pipe",
587
+ });
588
+ gitBranch = branchOutput.trim();
589
+
590
+ // Only check for updates if on main/master branch
591
+ shouldCheckForUpdates = gitBranch === "main" || gitBranch === "master";
592
+ } catch {
593
+ // Not a git repo or git not available
594
+ // Likely running from npm/npx - should check for updates
595
+ shouldCheckForUpdates = true;
596
+ }
597
+
598
+ // Detect if running from npx (temporary directory)
599
+ const isNpx =
600
+ process.argv[1]?.includes("_npx") ||
601
+ process.argv[1]?.includes("npx-") ||
602
+ process.env.npm_execpath?.includes("npx");
603
+
604
+ res.json({
605
+ isGitRepo,
606
+ gitBranch,
607
+ shouldCheckForUpdates,
608
+ isNpx: isNpx || false,
609
+ });
610
+ } catch (error) {
611
+ console.error("Environment check error:", error);
612
+ // Default to checking for updates on error
613
+ res.json({
614
+ isGitRepo: false,
615
+ gitBranch: null,
616
+ shouldCheckForUpdates: true,
617
+ isNpx: false,
618
+ });
619
+ }
620
+ });
621
+
525
622
  // Projects API Routes (protected)
526
623
  app.use("/api/projects", authenticateToken, projectsRoutes);
527
624
 
@@ -568,13 +665,9 @@ app.use(
568
665
  etag: true,
569
666
  lastModified: true,
570
667
  setHeaders: (res, filePath) => {
571
- // Cache icons and other static assets
668
+ // Cache icons and other static assets for 1 hour
572
669
  if (filePath.match(/\.(svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot)$/)) {
573
- // Cache for 1 week, allow revalidation
574
- res.setHeader(
575
- "Cache-Control",
576
- "public, max-age=604800, must-revalidate",
577
- );
670
+ res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
578
671
  } else if (filePath.endsWith(".json")) {
579
672
  // JSON files (like manifest.json) - short cache with revalidation
580
673
  res.setHeader("Cache-Control", "public, max-age=3600, must-revalidate");
@@ -685,12 +778,62 @@ app.post("/api/system/update", authenticateToken, async (req, res) => {
685
778
  app.get("/api/projects", authenticateToken, async (req, res) => {
686
779
  try {
687
780
  const projects = await getProjects();
781
+
782
+ // Set cache headers - 5 min cache with revalidation (public for Cloudflare)
783
+ const version = getVersion("projects");
784
+ res.set({
785
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
786
+ ETag: `"projects-${version.version}"`,
787
+ });
788
+
688
789
  res.json(projects);
689
790
  } catch (error) {
690
791
  res.status(500).json({ error: error.message });
691
792
  }
692
793
  });
693
794
 
795
+ // External Claude session detection endpoint
796
+ // Returns cached process data, refreshed every 60 seconds
797
+ // Cacheable by clients for up to 60 seconds
798
+ app.get("/api/external-sessions", authenticateToken, async (req, res) => {
799
+ try {
800
+ const { projectPath } = req.query;
801
+
802
+ // Set cache headers - clients can cache for up to 60 seconds
803
+ res.setHeader("Cache-Control", "private, max-age=60");
804
+
805
+ const result = detectExternalClaude(projectPath || null);
806
+
807
+ log.debug(
808
+ {
809
+ projectPath,
810
+ hasExternalSession: result.hasExternalSession,
811
+ processCount: result.processes.length,
812
+ cacheAge: result.cacheAge,
813
+ },
814
+ "External session check",
815
+ );
816
+
817
+ res.json({
818
+ hasExternalSession: result.hasExternalSession,
819
+ detectionAvailable: result.detectionAvailable,
820
+ detectionError: result.detectionError,
821
+ cacheAge: result.cacheAge,
822
+ details: result.hasExternalSession
823
+ ? {
824
+ processIds: result.processes.map((p) => p.pid),
825
+ commands: result.processes.map((p) => p.command),
826
+ tmuxSessions: result.tmuxSessions.map((s) => s.sessionName),
827
+ lockFile: result.lockFile.exists ? result.lockFile.lockFile : null,
828
+ }
829
+ : null,
830
+ });
831
+ } catch (error) {
832
+ log.error({ error: error.message }, "External session check failed");
833
+ res.status(500).json({ error: error.message });
834
+ }
835
+ });
836
+
694
837
  app.get(
695
838
  "/api/projects/:projectName/sessions",
696
839
  authenticateToken,
@@ -764,6 +907,130 @@ app.get(
764
907
  },
765
908
  );
766
909
 
910
+ // NEW: Get message list (IDs and numbers only) - cached for 60 seconds
911
+ // This is the efficient way to check for new messages
912
+ app.get(
913
+ "/api/projects/:projectName/sessions/:sessionId/messages/list",
914
+ authenticateToken,
915
+ async (req, res) => {
916
+ try {
917
+ const { projectName, sessionId } = req.params;
918
+
919
+ const result = await getMessageList(projectName, sessionId);
920
+
921
+ // Generate ETag based on total count and cache timestamp
922
+ const currentETag = `"list-${sessionId}-${result.total}-${result.cachedAt}"`;
923
+
924
+ // Check If-None-Match header
925
+ const clientETag = req.headers["if-none-match"];
926
+ if (clientETag && clientETag === currentETag) {
927
+ return res.status(304).end();
928
+ }
929
+
930
+ // Set caching headers - 60 second cache (public for Cloudflare)
931
+ res.set({
932
+ "Cache-Control": `public, max-age=${Math.floor(LIST_CACHE_TTL / 1000)}, stale-while-revalidate=30`,
933
+ ETag: currentETag,
934
+ });
935
+
936
+ res.json(result);
937
+ } catch (error) {
938
+ console.error("[MessagesListEndpoint] Error:", error.message);
939
+ res.status(500).json({ error: error.message });
940
+ }
941
+ },
942
+ );
943
+
944
+ // NEW: Get a single message by number (1-indexed) - cached for 30 minutes
945
+ // Messages are immutable, so they can be cached for a long time
946
+ app.get(
947
+ "/api/projects/:projectName/sessions/:sessionId/messages/number/:messageNumber",
948
+ authenticateToken,
949
+ async (req, res) => {
950
+ try {
951
+ const { projectName, sessionId, messageNumber } = req.params;
952
+ const num = parseInt(messageNumber, 10);
953
+
954
+ if (isNaN(num) || num < 1) {
955
+ return res.status(400).json({ error: "Invalid message number" });
956
+ }
957
+
958
+ const message = await getMessageByNumber(projectName, sessionId, num);
959
+
960
+ if (!message) {
961
+ return res.status(404).json({ error: "Message not found" });
962
+ }
963
+
964
+ // Generate ETag based on message ID and timestamp
965
+ const msgId = message.uuid || message.id || `msg_${num}`;
966
+ const currentETag = `"msg-${sessionId}-${num}-${msgId}"`;
967
+
968
+ // Check If-None-Match header
969
+ const clientETag = req.headers["if-none-match"];
970
+ if (clientETag && clientETag === currentETag) {
971
+ return res.status(304).end();
972
+ }
973
+
974
+ // Set caching headers - 30 minute cache (messages are immutable, public for Cloudflare)
975
+ res.set({
976
+ "Cache-Control": `public, max-age=${Math.floor(MESSAGE_CACHE_TTL / 1000)}, immutable`,
977
+ ETag: currentETag,
978
+ });
979
+
980
+ res.json({ number: num, message });
981
+ } catch (error) {
982
+ console.error("[MessageByNumberEndpoint] Error:", error.message);
983
+ res.status(500).json({ error: error.message });
984
+ }
985
+ },
986
+ );
987
+
988
+ // NEW: Get messages by number range - for batch fetching
989
+ app.get(
990
+ "/api/projects/:projectName/sessions/:sessionId/messages/range/:start/:end",
991
+ authenticateToken,
992
+ async (req, res) => {
993
+ try {
994
+ const { projectName, sessionId, start, end } = req.params;
995
+ const startNum = parseInt(start, 10);
996
+ const endNum = parseInt(end, 10);
997
+
998
+ if (
999
+ isNaN(startNum) ||
1000
+ isNaN(endNum) ||
1001
+ startNum < 1 ||
1002
+ endNum < startNum
1003
+ ) {
1004
+ return res.status(400).json({ error: "Invalid range" });
1005
+ }
1006
+
1007
+ // Limit range to prevent abuse
1008
+ if (endNum - startNum > 100) {
1009
+ return res
1010
+ .status(400)
1011
+ .json({ error: "Range too large (max 100 messages)" });
1012
+ }
1013
+
1014
+ const messages = await getMessagesByRange(
1015
+ projectName,
1016
+ sessionId,
1017
+ startNum,
1018
+ endNum,
1019
+ );
1020
+
1021
+ // Set cache - range queries for initial load (public for Cloudflare)
1022
+ res.set({
1023
+ "Cache-Control": "public, max-age=60, stale-while-revalidate=30",
1024
+ });
1025
+
1026
+ res.json({ messages, start: startNum, end: endNum });
1027
+ } catch (error) {
1028
+ console.error("[MessageRangeEndpoint] Error:", error.message);
1029
+ res.status(500).json({ error: error.message });
1030
+ }
1031
+ },
1032
+ );
1033
+
767
1034
  // Rename project endpoint
768
1035
  app.put(
769
1036
  "/api/projects/:projectName/rename",
@@ -779,6 +1046,42 @@ app.put(
779
1046
  },
780
1047
  );
781
1048
 
1049
+ // Avatar proxy endpoint - serves GitHub avatars with aggressive caching for Cloudflare
1050
+ app.get("/api/avatar/:githubId", authenticateToken, async (req, res) => {
1051
+ try {
1052
+ const { githubId } = req.params;
1053
+ const size = req.query.s || "80";
1054
+
1055
+ // Validate githubId (should be numeric)
1056
+ if (!/^\d+$/.test(githubId)) {
1057
+ return res.status(400).json({ error: "Invalid GitHub ID" });
1058
+ }
1059
+
1060
+ // Fetch from GitHub
1061
+ const githubUrl = `https://avatars.githubusercontent.com/u/${githubId}?s=${size}&v=4`;
1062
+ const response = await fetch(githubUrl);
1063
+
1064
+ if (!response.ok) {
1065
+ return res.status(response.status).json({ error: "Avatar not found" });
1066
+ }
1067
+
1068
+ // Set cache headers for aggressive caching (1 year, public for Cloudflare)
1069
+ res.set({
1070
+ "Content-Type": response.headers.get("content-type") || "image/png",
1071
+ "Cache-Control": "public, max-age=31536000, immutable",
1072
+ "CDN-Cache-Control": "public, max-age=31536000",
1073
+ "Cloudflare-CDN-Cache-Control": "public, max-age=31536000",
1074
+ });
1075
+
1076
+ // Pipe the avatar to the response
1077
+ const buffer = await response.arrayBuffer();
1078
+ res.send(Buffer.from(buffer));
1079
+ } catch (error) {
1080
+ console.error("[AvatarProxy] Error:", error.message);
1081
+ res.status(500).json({ error: "Failed to fetch avatar" });
1082
+ }
1083
+ });
1084
+
782
1085
  // Delete session endpoint
783
1086
  app.delete(
784
1087
  "/api/projects/:projectName/sessions/:sessionId",
@@ -1231,6 +1534,10 @@ function handleChatConnection(ws) {
1231
1534
 
1232
1535
  // Add to connected clients for project updates
1233
1536
  connectedClients.add(ws);
1537
+ // Activate process cache and maintenance scheduler when clients are connected
1538
+ const hasClients = connectedClients.size > 0;
1539
+ setProcessCacheActive(hasClients);
1540
+ setMaintenanceActive(hasClients);
1234
1541
 
1235
1542
  // Generate unique connection ID for orchestrator status tracking
1236
1543
  const connectionId = `ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -1252,6 +1559,10 @@ function handleChatConnection(ws) {
1252
1559
  console.log("🔌 Chat client disconnected");
1253
1560
  // Remove from connected clients
1254
1561
  connectedClients.delete(ws);
1562
+ // Update process cache and maintenance scheduler based on remaining clients
1563
+ const hasClients = connectedClients.size > 0;
1564
+ setProcessCacheActive(hasClients);
1565
+ setMaintenanceActive(hasClients);
1255
1566
  // Track disconnection for orchestrator status (if enabled)
1256
1567
  if (orchestratorStatusHooks) {
1257
1568
  orchestratorStatusHooks.onConnectionClose(connectionId);
@@ -1270,26 +1581,28 @@ async function handleChatMessage(ws, writer, messageData) {
1270
1581
  // Handle proactive external session check (before user submits a prompt)
1271
1582
  if (data.type === "check-external-session") {
1272
1583
  const projectPath = data.projectPath;
1273
- console.log(
1274
- "[ExternalSessionCheck] Checking for external sessions:",
1275
- projectPath,
1276
- );
1584
+ log.debug({ projectPath }, "External session check requested");
1277
1585
  if (projectPath) {
1278
1586
  const externalCheck = detectExternalClaude(projectPath);
1279
- console.log("[ExternalSessionCheck] Result:", {
1280
- hasExternalSession: externalCheck.hasExternalSession,
1281
- detectionAvailable: externalCheck.detectionAvailable,
1282
- detectionError: externalCheck.detectionError,
1283
- processCount: externalCheck.processes.length,
1284
- tmuxCount: externalCheck.tmuxSessions.length,
1285
- hasLockFile: externalCheck.lockFile.exists,
1286
- });
1587
+ log.debug(
1588
+ {
1589
+ projectPath,
1590
+ hasExternalSession: externalCheck.hasExternalSession,
1591
+ detectionAvailable: externalCheck.detectionAvailable,
1592
+ detectionError: externalCheck.detectionError,
1593
+ processCount: externalCheck.processes.length,
1594
+ tmuxCount: externalCheck.tmuxSessions.length,
1595
+ hasLockFile: externalCheck.lockFile.exists,
1596
+ },
1597
+ "External session check result",
1598
+ );
1287
1599
  writer.send({
1288
1600
  type: "external-session-check-result",
1289
1601
  projectPath,
1290
1602
  hasExternalSession: externalCheck.hasExternalSession,
1291
1603
  detectionAvailable: externalCheck.detectionAvailable,
1292
1604
  detectionError: externalCheck.detectionError,
1605
+ cacheAge: externalCheck.cacheAge,
1293
1606
  details: externalCheck.hasExternalSession
1294
1607
  ? {
1295
1608
  processIds: externalCheck.processes.map((p) => p.pid),
@@ -1304,15 +1617,22 @@ async function handleChatMessage(ws, writer, messageData) {
1304
1617
  : null,
1305
1618
  });
1306
1619
  } else {
1307
- console.log("[ExternalSessionCheck] No projectPath provided");
1620
+ log.debug("External session check: no projectPath provided");
1308
1621
  }
1309
1622
  return;
1310
1623
  }
1311
1624
 
1312
1625
  if (data.type === "claude-command") {
1313
- console.log("[DEBUG] User message:", data.command || "[Continue/Resume]");
1314
- console.log("📁 Project:", data.options?.projectPath || "Unknown");
1315
- console.log("🔄 Session:", data.options?.sessionId ? "Resume" : "New");
1626
+ log.debug(
1627
+ {
1628
+ command: data.command
1629
+ ? data.command.slice(0, 50)
1630
+ : "[Continue/Resume]",
1631
+ project: data.options?.projectPath || "Unknown",
1632
+ sessionMode: data.options?.sessionId ? "Resume" : "New",
1633
+ },
1634
+ "User message received",
1635
+ );
1316
1636
 
1317
1637
  const projectPath = data.options?.projectPath;
1318
1638
  const sessionId = data.options?.sessionId || sessionIdForTracking;
@@ -1341,10 +1661,19 @@ async function handleChatMessage(ws, writer, messageData) {
1341
1661
  if (projectPath) {
1342
1662
  const externalCheck = detectExternalClaude(projectPath);
1343
1663
  if (externalCheck.hasExternalSession) {
1664
+ log.info(
1665
+ {
1666
+ projectPath,
1667
+ processCount: externalCheck.processes.length,
1668
+ tmuxCount: externalCheck.tmuxSessions.length,
1669
+ },
1670
+ "External Claude session detected during chat",
1671
+ );
1344
1672
  writer.send({
1345
1673
  type: "external-session-detected",
1346
1674
  projectPath,
1347
1675
  detectionAvailable: externalCheck.detectionAvailable,
1676
+ cacheAge: externalCheck.cacheAge,
1348
1677
  details: {
1349
1678
  processIds: externalCheck.processes.map((p) => p.pid),
1350
1679
  tmuxSessions: externalCheck.tmuxSessions.map(
@@ -1413,6 +1742,27 @@ async function handleChatMessage(ws, writer, messageData) {
1413
1742
  if (orchestratorStatusHooks) {
1414
1743
  orchestratorStatusHooks.onQueryEnd(sessionIdForTracking);
1415
1744
  }
1745
+
1746
+ // Update session title from history.jsonl (last user prompt)
1747
+ try {
1748
+ // Invalidate history cache to get fresh data
1749
+ invalidateHistoryCache();
1750
+ const newTitle = await getSessionTitleFromHistory(sessionId);
1751
+ if (newTitle) {
1752
+ const updated = updateSessionTitle(sessionId, newTitle);
1753
+ if (updated) {
1754
+ log.debug(
1755
+ { sessionId, title: newTitle.slice(0, 50) },
1756
+ "Updated session title from history",
1757
+ );
1758
+ }
1759
+ }
1760
+ } catch (titleError) {
1761
+ log.debug(
1762
+ { sessionId, error: titleError.message },
1763
+ "Failed to update session title",
1764
+ );
1765
+ }
1416
1766
  }
1417
1767
  } else if (data.type === "cursor-command") {
1418
1768
  console.log(
@@ -2997,28 +3347,87 @@ async function startServer() {
2997
3347
  );
2998
3348
  console.log("");
2999
3349
 
3000
- // Start watching the projects folder for changes
3001
- await setupProjectsWatcher();
3002
-
3003
- // Initialize sessions and projects caches with initial project data
3350
+ // Initialize SQLite database and index all projects
3351
+ // NOTE: This must happen BEFORE starting the file watcher to avoid contention
3004
3352
  try {
3005
- const initialProjects = await getProjects();
3006
- updateSessionsCache(initialProjects);
3007
- updateProjectsCache(initialProjects);
3008
- console.log(
3009
- `${c.ok("[OK]")} Sessions cache initialized with ${initialProjects.length} projects`,
3010
- );
3353
+ console.log(`${c.info("[INFO]")} Initializing SQLite database...`);
3354
+
3355
+ // Initialize SQLite database connection
3356
+ getDatabase();
3357
+
3358
+ // Run full indexing of all projects
3359
+ // This is incremental - only processes changed/new files
3360
+ const indexResult = await indexAllProjects();
3361
+
3362
+ if (indexResult.success) {
3363
+ console.log(
3364
+ `${c.ok("[OK]")} SQLite database indexed: ${indexResult.stats.projects} projects, ${indexResult.stats.sessions} sessions`,
3365
+ );
3366
+ } else {
3367
+ console.warn(
3368
+ `${c.warn("[WARN]")} Indexing incomplete: ${indexResult.error}`,
3369
+ );
3370
+ }
3371
+
3372
+ // Populate in-memory caches from SQLite (for backward compatibility)
3373
+ // TODO: Eventually remove these caches and read directly from SQLite
3374
+ const projectsFromDb = getProjectsFromDb();
3375
+ updateProjectsCache(projectsFromDb);
3376
+
3011
3377
  console.log(
3012
- `${c.ok("[OK]")} Projects cache initialized with ${initialProjects.length} projects`,
3378
+ `${c.ok("[OK]")} Caches initialized from SQLite with ${projectsFromDb.length} projects`,
3013
3379
  );
3014
-
3015
- // Clean up stale tmux session entries on startup
3016
- cleanupStaleTmuxSessions();
3017
3380
  } catch (cacheError) {
3018
3381
  console.warn(
3019
- `${c.warn("[WARN]")} Failed to initialize caches: ${cacheError.message}`,
3382
+ `${c.warn("[WARN]")} Failed to initialize database: ${cacheError.message}`,
3020
3383
  );
3384
+ // Fall back to legacy getProjects if SQLite fails
3385
+ try {
3386
+ const initialProjects = await getProjects();
3387
+ updateSessionsCache(initialProjects);
3388
+ updateProjectsCache(initialProjects);
3389
+ console.log(
3390
+ `${c.ok("[OK]")} Caches initialized (legacy) with ${initialProjects.length} projects`,
3391
+ );
3392
+ } catch (fallbackError) {
3393
+ console.error(
3394
+ `${c.warn("[ERROR]")} Cache initialization failed completely`,
3395
+ );
3396
+ }
3021
3397
  }
3398
+
3399
+ // Start watching the projects folder for changes (after cache is initialized)
3400
+ await setupProjectsWatcher();
3401
+
3402
+ // Clean up stale tmux session entries on startup
3403
+ cleanupStaleTmuxSessions();
3404
+
3405
+ // Start the process cache updater for external session detection
3406
+ startCacheUpdater();
3407
+ console.log(
3408
+ `${c.ok("[OK]")} Process cache started (updates every ${CACHE_UPDATE_INTERVAL / 1000}s)`,
3409
+ );
3410
+
3411
+ // Register maintenance tasks with centralized scheduler
3412
+ // These tasks only run when WebSocket clients are connected
3413
+ registerTask(
3414
+ "session-lock-cleanup",
3415
+ () => {
3416
+ const cleaned = sessionLock.cleanupStaleLocks();
3417
+ if (cleaned > 0) {
3418
+ console.log(`[SessionLock] Cleaned up ${cleaned} stale locks`);
3419
+ }
3420
+ },
3421
+ 5 * 60 * 1000, // 5 minutes
3422
+ );
3423
+ registerTask(
3424
+ "codex-session-cleanup",
3425
+ cleanupCodexSessions,
3426
+ 5 * 60 * 1000,
3427
+ );
3428
+ console.log(
3429
+ `${c.ok("[OK]")} Maintenance scheduler initialized (idle detection enabled)`,
3430
+ );
3022
3431
  });
3023
3432
  } catch (error) {
3024
3433
  console.error("[ERROR] Failed to start server:", error);