@siteboon/claude-code-ui 1.22.0 → 1.23.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.
@@ -56,4 +56,4 @@ Error generating stack: `+u.message+`
56
56
  * LICENSE.md file in the root directory of this source tree.
57
57
  *
58
58
  * @license MIT
59
- */const up="6";try{window.__reactRouterVersion=up}catch{}const ip="startTransition",Ha=od[ip];function dp(o){let{basename:f,children:a,future:y,window:g}=o,C=j.useRef();C.current==null&&(C.current=pd({window:g,v5Compat:!0}));let _=C.current,[T,P]=j.useState({action:_.action,location:_.location}),{v7_startTransition:U}=y||{},$=j.useCallback(N=>{U&&Ha?Ha(()=>P(N)):P(N)},[P,U]);return j.useLayoutEffect(()=>_.listen($),[_,$]),j.useEffect(()=>np(y),[y]),j.createElement(lp,{basename:f,children:a,location:T.location,navigationType:T.action,navigator:_,future:y})}var Qa;(function(o){o.UseScrollRestoration="useScrollRestoration",o.UseSubmit="useSubmit",o.UseSubmitFetcher="useSubmitFetcher",o.UseFetcher="useFetcher",o.useViewTransitionState="useViewTransitionState"})(Qa||(Qa={}));var Ka;(function(o){o.UseFetcher="useFetcher",o.UseFetchers="useFetchers",o.UseScrollRestoration="useScrollRestoration"})(Ka||(Ka={}));export{dp as B,id as R,j as a,fd as b,sp as c,op as d,cp as e,fp as f,Ya as g,rp as h,Xa as r,ap as u};
59
+ */const up="6";try{window.__reactRouterVersion=up}catch{}const ip="startTransition",Ha=od[ip];function dp(o){let{basename:f,children:a,future:y,window:g}=o,C=j.useRef();C.current==null&&(C.current=pd({window:g,v5Compat:!0}));let _=C.current,[T,P]=j.useState({action:_.action,location:_.location}),{v7_startTransition:U}=y||{},$=j.useCallback(N=>{U&&Ha?Ha(()=>P(N)):P(N)},[P,U]);return j.useLayoutEffect(()=>_.listen($),[_,$]),j.useEffect(()=>np(y),[y]),j.createElement(lp,{basename:f,children:a,location:T.location,navigationType:T.action,navigator:_,future:y})}var Qa;(function(o){o.UseScrollRestoration="useScrollRestoration",o.UseSubmit="useSubmit",o.UseSubmitFetcher="useSubmitFetcher",o.UseFetcher="useFetcher",o.useViewTransitionState="useViewTransitionState"})(Qa||(Qa={}));var Ka;(function(o){o.UseFetcher="useFetcher",o.UseFetchers="useFetchers",o.UseScrollRestoration="useScrollRestoration"})(Ka||(Ka={}));export{dp as B,sp as R,j as a,fd as b,id as c,op as d,cp as e,fp as f,Ya as g,rp as h,Xa as r,ap as u};
package/dist/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <title>CloudCLI UI</title>
9
9
 
10
10
  <!-- PWA Manifest -->
11
- <link rel="manifest" href="/manifest.json" />
11
+ <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
12
12
 
13
13
  <!-- iOS Safari PWA Meta Tags -->
14
14
  <meta name="mobile-web-app-capable" content="yes" />
@@ -25,11 +25,11 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-Br2fwqOq.js"></script>
29
- <link rel="modulepreload" crossorigin href="/assets/vendor-react-DIN4KjD2.js">
30
- <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-BMLq5tLB.js">
28
+ <script type="module" crossorigin src="/assets/index-7_J3n3lH.js"></script>
29
+ <link rel="modulepreload" crossorigin href="/assets/vendor-react-CdSTmIF1.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-C8f1vU1x.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CJZjLICi.js">
32
- <link rel="stylesheet" crossorigin href="/assets/index-B6iL1dXV.css">
32
+ <link rel="stylesheet" crossorigin href="/assets/index-BFyod1Qa.css">
33
33
  </head>
34
34
  <body>
35
35
  <div id="root"></div>
@@ -49,4 +49,4 @@
49
49
  }
50
50
  </script>
51
51
  </body>
52
- </html>
52
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteboon/claude-code-ui",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "A web-based UI for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -30,10 +30,13 @@
30
30
  "build": "vite build",
31
31
  "preview": "vite preview",
32
32
  "typecheck": "tsc --noEmit -p tsconfig.json",
33
+ "lint": "eslint src/",
34
+ "lint:fix": "eslint src/ --fix",
33
35
  "start": "npm run build && npm run server",
34
36
  "release": "./release.sh",
35
37
  "prepublishOnly": "npm run build",
36
- "postinstall": "node scripts/fix-node-pty.js"
38
+ "postinstall": "node scripts/fix-node-pty.js",
39
+ "prepare": "husky"
37
40
  },
38
41
  "keywords": [
39
42
  "claude code",
@@ -103,6 +106,9 @@
103
106
  "ws": "^8.14.2"
104
107
  },
105
108
  "devDependencies": {
109
+ "@commitlint/cli": "^20.4.3",
110
+ "@commitlint/config-conventional": "^20.4.3",
111
+ "@eslint/js": "^9.39.3",
106
112
  "@release-it/conventional-changelog": "^10.0.5",
107
113
  "@types/node": "^22.19.7",
108
114
  "@types/react": "^18.2.43",
@@ -111,12 +117,26 @@
111
117
  "auto-changelog": "^2.5.0",
112
118
  "autoprefixer": "^10.4.16",
113
119
  "concurrently": "^8.2.2",
120
+ "eslint": "^9.39.3",
121
+ "eslint-plugin-import-x": "^4.16.1",
122
+ "eslint-plugin-react": "^7.37.5",
123
+ "eslint-plugin-react-hooks": "^7.0.1",
124
+ "eslint-plugin-react-refresh": "^0.5.2",
125
+ "eslint-plugin-tailwindcss": "^3.18.2",
126
+ "eslint-plugin-unused-imports": "^4.4.1",
127
+ "globals": "^17.4.0",
128
+ "husky": "^9.1.7",
129
+ "lint-staged": "^16.3.2",
114
130
  "node-gyp": "^10.0.0",
115
131
  "postcss": "^8.4.32",
116
132
  "release-it": "^19.0.5",
117
133
  "sharp": "^0.34.2",
118
134
  "tailwindcss": "^3.4.0",
119
135
  "typescript": "^5.9.3",
136
+ "typescript-eslint": "^8.56.1",
120
137
  "vite": "^7.0.4"
138
+ },
139
+ "lint-staged": {
140
+ "src/**/*.{ts,tsx,js,jsx}": "eslint"
121
141
  }
122
142
  }
@@ -34,7 +34,7 @@ function createRequestId() {
34
34
  }
35
35
 
36
36
  function waitForToolApproval(requestId, options = {}) {
37
- const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
37
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
38
38
 
39
39
  return new Promise(resolve => {
40
40
  let settled = false;
@@ -78,9 +78,14 @@ function waitForToolApproval(requestId, options = {}) {
78
78
  signal.addEventListener('abort', abortHandler, { once: true });
79
79
  }
80
80
 
81
- pendingToolApprovals.set(requestId, (decision) => {
81
+ const resolver = (decision) => {
82
82
  finalize(decision);
83
- });
83
+ };
84
+ // Attach metadata for getPendingApprovalsForSession lookup
85
+ if (metadata) {
86
+ Object.assign(resolver, metadata);
87
+ }
88
+ pendingToolApprovals.set(requestId, resolver);
84
89
  });
85
90
  }
86
91
 
@@ -209,13 +214,14 @@ function mapCliOptionsToSDK(options = {}) {
209
214
  * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
210
215
  * @param {string} tempDir - Temp directory for cleanup
211
216
  */
212
- function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
217
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
213
218
  activeSessions.set(sessionId, {
214
219
  instance: queryInstance,
215
220
  startTime: Date.now(),
216
221
  status: 'active',
217
222
  tempImagePaths,
218
- tempDir
223
+ tempDir,
224
+ writer
219
225
  });
220
226
  }
221
227
 
@@ -512,6 +518,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
512
518
  const decision = await waitForToolApproval(requestId, {
513
519
  timeoutMs: requiresInteraction ? 0 : undefined,
514
520
  signal: context?.signal,
521
+ metadata: {
522
+ _sessionId: capturedSessionId || sessionId || null,
523
+ _toolName: toolName,
524
+ _input: input,
525
+ _receivedAt: new Date(),
526
+ },
515
527
  onCancel: (reason) => {
516
528
  ws.send({
517
529
  type: 'claude-permission-cancelled',
@@ -562,7 +574,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
562
574
 
563
575
  // Track the query instance for abort capability
564
576
  if (capturedSessionId) {
565
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
577
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
566
578
  }
567
579
 
568
580
  // Process streaming messages
@@ -572,7 +584,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
572
584
  if (message.session_id && !capturedSessionId) {
573
585
 
574
586
  capturedSessionId = message.session_id;
575
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
587
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
576
588
 
577
589
  // Set session ID on writer
578
590
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -712,11 +724,50 @@ function getActiveClaudeSDKSessions() {
712
724
  return getAllSessions();
713
725
  }
714
726
 
727
+ /**
728
+ * Get pending tool approvals for a specific session.
729
+ * @param {string} sessionId - The session ID
730
+ * @returns {Array} Array of pending permission request objects
731
+ */
732
+ function getPendingApprovalsForSession(sessionId) {
733
+ const pending = [];
734
+ for (const [requestId, resolver] of pendingToolApprovals.entries()) {
735
+ if (resolver._sessionId === sessionId) {
736
+ pending.push({
737
+ requestId,
738
+ toolName: resolver._toolName || 'UnknownTool',
739
+ input: resolver._input,
740
+ context: resolver._context,
741
+ sessionId,
742
+ receivedAt: resolver._receivedAt || new Date(),
743
+ });
744
+ }
745
+ }
746
+ return pending;
747
+ }
748
+
749
+ /**
750
+ * Reconnect a session's WebSocketWriter to a new raw WebSocket.
751
+ * Called when client reconnects (e.g. page refresh) while SDK is still running.
752
+ * @param {string} sessionId - The session ID
753
+ * @param {Object} newRawWs - The new raw WebSocket connection
754
+ * @returns {boolean} True if writer was successfully reconnected
755
+ */
756
+ function reconnectSessionWriter(sessionId, newRawWs) {
757
+ const session = getSession(sessionId);
758
+ if (!session?.writer?.updateWebSocket) return false;
759
+ session.writer.updateWebSocket(newRawWs);
760
+ console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
761
+ return true;
762
+ }
763
+
715
764
  // Export public API
716
765
  export {
717
766
  queryClaudeSDK,
718
767
  abortClaudeSDKSession,
719
768
  isClaudeSDKSessionActive,
720
769
  getActiveClaudeSDKSessions,
721
- resolveToolApproval
770
+ resolveToolApproval,
771
+ getPendingApprovalsForSession,
772
+ reconnectSessionWriter
722
773
  };
@@ -91,6 +91,18 @@ const runMigrations = () => {
91
91
  db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
92
92
  }
93
93
 
94
+ // Create session_names table if it doesn't exist (for existing installations)
95
+ db.exec(`CREATE TABLE IF NOT EXISTS session_names (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ session_id TEXT NOT NULL,
98
+ provider TEXT NOT NULL DEFAULT 'claude',
99
+ custom_name TEXT NOT NULL,
100
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
101
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
102
+ UNIQUE(session_id, provider)
103
+ )`);
104
+ db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
105
+
94
106
  console.log('Database migrations completed successfully');
95
107
  } catch (error) {
96
108
  console.error('Error running migrations:', error.message);
@@ -348,6 +360,60 @@ const credentialsDb = {
348
360
  }
349
361
  };
350
362
 
363
+ // Session custom names database operations
364
+ const sessionNamesDb = {
365
+ // Set (insert or update) a custom session name
366
+ setName: (sessionId, provider, customName) => {
367
+ db.prepare(`
368
+ INSERT INTO session_names (session_id, provider, custom_name)
369
+ VALUES (?, ?, ?)
370
+ ON CONFLICT(session_id, provider)
371
+ DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
372
+ `).run(sessionId, provider, customName);
373
+ },
374
+
375
+ // Get a single custom session name
376
+ getName: (sessionId, provider) => {
377
+ const row = db.prepare(
378
+ 'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
379
+ ).get(sessionId, provider);
380
+ return row?.custom_name || null;
381
+ },
382
+
383
+ // Batch lookup — returns Map<sessionId, customName>
384
+ getNames: (sessionIds, provider) => {
385
+ if (!sessionIds.length) return new Map();
386
+ const placeholders = sessionIds.map(() => '?').join(',');
387
+ const rows = db.prepare(
388
+ `SELECT session_id, custom_name FROM session_names
389
+ WHERE session_id IN (${placeholders}) AND provider = ?`
390
+ ).all(...sessionIds, provider);
391
+ return new Map(rows.map(r => [r.session_id, r.custom_name]));
392
+ },
393
+
394
+ // Delete a custom session name
395
+ deleteName: (sessionId, provider) => {
396
+ return db.prepare(
397
+ 'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
398
+ ).run(sessionId, provider).changes > 0;
399
+ },
400
+ };
401
+
402
+ // Apply custom session names from the database (overrides CLI-generated summaries)
403
+ function applyCustomSessionNames(sessions, provider) {
404
+ if (!sessions?.length) return;
405
+ try {
406
+ const ids = sessions.map(s => s.id);
407
+ const customNames = sessionNamesDb.getNames(ids, provider);
408
+ for (const session of sessions) {
409
+ const custom = customNames.get(session.id);
410
+ if (custom) session.summary = custom;
411
+ }
412
+ } catch (error) {
413
+ console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
414
+ }
415
+ }
416
+
351
417
  // Backward compatibility - keep old names pointing to new system
352
418
  const githubTokensDb = {
353
419
  createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -373,5 +439,7 @@ export {
373
439
  userDb,
374
440
  apiKeysDb,
375
441
  credentialsDb,
442
+ sessionNamesDb,
443
+ applyCustomSessionNames,
376
444
  githubTokensDb // Backward compatibility
377
445
  };
@@ -49,4 +49,17 @@ CREATE TABLE IF NOT EXISTS user_credentials (
49
49
 
50
50
  CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
51
51
  CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
52
- CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
52
+ CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
53
+
54
+ -- Session custom names (provider-agnostic display name overrides)
55
+ CREATE TABLE IF NOT EXISTS session_names (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ session_id TEXT NOT NULL,
58
+ provider TEXT NOT NULL DEFAULT 'claude',
59
+ custom_name TEXT NOT NULL,
60
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
61
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
62
+ UNIQUE(session_id, provider)
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
package/server/index.js CHANGED
@@ -45,7 +45,7 @@ import fetch from 'node-fetch';
45
45
  import mime from 'mime-types';
46
46
 
47
47
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
48
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
48
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
49
49
  import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
50
50
  import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
51
51
  import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
@@ -64,10 +64,12 @@ import cliAuthRoutes from './routes/cli-auth.js';
64
64
  import userRoutes from './routes/user.js';
65
65
  import codexRoutes from './routes/codex.js';
66
66
  import geminiRoutes from './routes/gemini.js';
67
- import { initializeDatabase } from './database/db.js';
67
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
68
68
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
69
69
  import { IS_PLATFORM } from './constants/config.js';
70
70
 
71
+ const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
72
+
71
73
  // File system watchers for provider project/session folders
72
74
  const PROVIDER_WATCH_PATHS = [
73
75
  { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
@@ -493,6 +495,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
493
495
  try {
494
496
  const { limit = 5, offset = 0 } = req.query;
495
497
  const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
498
+ applyCustomSessionNames(result.sessions, 'claude');
496
499
  res.json(result);
497
500
  } catch (error) {
498
501
  res.status(500).json({ error: error.message });
@@ -541,6 +544,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
541
544
  const { projectName, sessionId } = req.params;
542
545
  console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
543
546
  await deleteSession(projectName, sessionId);
547
+ sessionNamesDb.deleteName(sessionId, 'claude');
544
548
  console.log(`[API] Session ${sessionId} deleted successfully`);
545
549
  res.json({ success: true });
546
550
  } catch (error) {
@@ -549,6 +553,32 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
549
553
  }
550
554
  });
551
555
 
556
+ // Rename session endpoint
557
+ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
558
+ try {
559
+ const { sessionId } = req.params;
560
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
561
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
562
+ return res.status(400).json({ error: 'Invalid sessionId' });
563
+ }
564
+ const { summary, provider } = req.body;
565
+ if (!summary || typeof summary !== 'string' || summary.trim() === '') {
566
+ return res.status(400).json({ error: 'Summary is required' });
567
+ }
568
+ if (summary.trim().length > 500) {
569
+ return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
570
+ }
571
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
572
+ return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
573
+ }
574
+ sessionNamesDb.setName(safeSessionId, provider, summary.trim());
575
+ res.json({ success: true });
576
+ } catch (error) {
577
+ console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
578
+ res.status(500).json({ error: error.message });
579
+ }
580
+ });
581
+
552
582
  // Delete project endpoint (force=true to delete with sessions)
553
583
  app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
554
584
  try {
@@ -1350,6 +1380,10 @@ class WebSocketWriter {
1350
1380
  }
1351
1381
  }
1352
1382
 
1383
+ updateWebSocket(newRawWs) {
1384
+ this.ws = newRawWs;
1385
+ }
1386
+
1353
1387
  setSessionId(sessionId) {
1354
1388
  this.sessionId = sessionId;
1355
1389
  }
@@ -1464,6 +1498,11 @@ function handleChatConnection(ws) {
1464
1498
  } else {
1465
1499
  // Use Claude Agents SDK
1466
1500
  isActive = isClaudeSDKSessionActive(sessionId);
1501
+ if (isActive) {
1502
+ // Reconnect the session's writer to the new WebSocket so
1503
+ // subsequent SDK output flows to the refreshed client.
1504
+ reconnectSessionWriter(sessionId, ws);
1505
+ }
1467
1506
  }
1468
1507
 
1469
1508
  writer.send({
@@ -1472,6 +1511,17 @@ function handleChatConnection(ws) {
1472
1511
  provider,
1473
1512
  isProcessing: isActive
1474
1513
  });
1514
+ } else if (data.type === 'get-pending-permissions') {
1515
+ // Return pending permission requests for a session
1516
+ const sessionId = data.sessionId;
1517
+ if (sessionId && isClaudeSDKSessionActive(sessionId)) {
1518
+ const pending = getPendingApprovalsForSession(sessionId);
1519
+ writer.send({
1520
+ type: 'pending-permissions-response',
1521
+ sessionId,
1522
+ data: pending
1523
+ });
1524
+ }
1475
1525
  } else if (data.type === 'get-active-sessions') {
1476
1526
  // Get all currently active sessions
1477
1527
  const activeSessions = {
@@ -2112,7 +2162,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
2112
2162
 
2113
2163
  // Allow only safe characters in sessionId
2114
2164
  const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
2115
- if (!safeSessionId) {
2165
+ if (!safeSessionId || safeSessionId !== String(sessionId)) {
2116
2166
  return res.status(400).json({ error: 'Invalid sessionId' });
2117
2167
  }
2118
2168
 
@@ -66,6 +66,7 @@ import sqlite3 from 'sqlite3';
66
66
  import { open } from 'sqlite';
67
67
  import os from 'os';
68
68
  import sessionManager from './sessionManager.js';
69
+ import { applyCustomSessionNames } from './database/db.js';
69
70
 
70
71
  // Import TaskMaster detection functions
71
72
  async function detectTaskMasterFolder(projectPath) {
@@ -458,6 +459,7 @@ async function getProjects(progressCallback = null) {
458
459
  total: 0
459
460
  };
460
461
  }
462
+ applyCustomSessionNames(project.sessions, 'claude');
461
463
 
462
464
  // Also fetch Cursor sessions for this project
463
465
  try {
@@ -466,6 +468,7 @@ async function getProjects(progressCallback = null) {
466
468
  console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
467
469
  project.cursorSessions = [];
468
470
  }
471
+ applyCustomSessionNames(project.cursorSessions, 'cursor');
469
472
 
470
473
  // Also fetch Codex sessions for this project
471
474
  try {
@@ -476,6 +479,7 @@ async function getProjects(progressCallback = null) {
476
479
  console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
477
480
  project.codexSessions = [];
478
481
  }
482
+ applyCustomSessionNames(project.codexSessions, 'codex');
479
483
 
480
484
  // Also fetch Gemini sessions for this project
481
485
  try {
@@ -484,6 +488,7 @@ async function getProjects(progressCallback = null) {
484
488
  console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
485
489
  project.geminiSessions = [];
486
490
  }
491
+ applyCustomSessionNames(project.geminiSessions, 'gemini');
487
492
 
488
493
  // Add TaskMaster detection
489
494
  try {
@@ -567,6 +572,7 @@ async function getProjects(progressCallback = null) {
567
572
  } catch (e) {
568
573
  console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
569
574
  }
575
+ applyCustomSessionNames(project.cursorSessions, 'cursor');
570
576
 
571
577
  // Try to fetch Codex sessions for manual projects too
572
578
  try {
@@ -576,6 +582,7 @@ async function getProjects(progressCallback = null) {
576
582
  } catch (e) {
577
583
  console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
578
584
  }
585
+ applyCustomSessionNames(project.codexSessions, 'codex');
579
586
 
580
587
  // Try to fetch Gemini sessions for manual projects too
581
588
  try {
@@ -583,6 +590,7 @@ async function getProjects(progressCallback = null) {
583
590
  } catch (e) {
584
591
  console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
585
592
  }
593
+ applyCustomSessionNames(project.geminiSessions, 'gemini');
586
594
 
587
595
  // Add TaskMaster detection for manual projects
588
596
  try {
@@ -1071,10 +1079,13 @@ async function renameProject(projectName, newDisplayName) {
1071
1079
 
1072
1080
  if (!newDisplayName || newDisplayName.trim() === '') {
1073
1081
  // Remove custom name if empty, will fall back to auto-generated
1074
- delete config[projectName];
1082
+ if (config[projectName]) {
1083
+ delete config[projectName].displayName;
1084
+ }
1075
1085
  } else {
1076
- // Set custom display name
1086
+ // Set custom display name, preserving other properties (manuallyAdded, originalPath)
1077
1087
  config[projectName] = {
1088
+ ...config[projectName],
1078
1089
  displayName: newDisplayName.trim()
1079
1090
  };
1080
1091
  }
@@ -1479,6 +1490,23 @@ async function getCodexSessions(projectPath, options = {}) {
1479
1490
  }
1480
1491
  }
1481
1492
 
1493
+ function isVisibleCodexUserMessage(payload) {
1494
+ if (!payload || payload.type !== 'user_message') {
1495
+ return false;
1496
+ }
1497
+
1498
+ // Codex logs internal context (environment, instructions) as non-plain user_message kinds.
1499
+ if (payload.kind && payload.kind !== 'plain') {
1500
+ return false;
1501
+ }
1502
+
1503
+ if (typeof payload.message !== 'string' || payload.message.trim().length === 0) {
1504
+ return false;
1505
+ }
1506
+
1507
+ return true;
1508
+ }
1509
+
1482
1510
  // Parse a Codex session JSONL file to extract metadata
1483
1511
  async function parseCodexSessionFile(filePath) {
1484
1512
  try {
@@ -1514,8 +1542,8 @@ async function parseCodexSessionFile(filePath) {
1514
1542
  };
1515
1543
  }
1516
1544
 
1517
- // Count messages and extract user messages for summary
1518
- if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
1545
+ // Count visible user messages and extract summary from the latest plain user input.
1546
+ if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
1519
1547
  messageCount++;
1520
1548
  if (entry.payload.message) {
1521
1549
  lastUserMessage = entry.payload.message;
@@ -1622,25 +1650,36 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
1622
1650
  };
1623
1651
  }
1624
1652
  }
1653
+
1654
+ // Use event_msg.user_message for user-visible inputs.
1655
+ if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
1656
+ messages.push({
1657
+ type: 'user',
1658
+ timestamp: entry.timestamp,
1659
+ message: {
1660
+ role: 'user',
1661
+ content: entry.payload.message
1662
+ }
1663
+ });
1664
+ }
1625
1665
 
1626
- // Extract messages from response_item
1627
- if (entry.type === 'response_item' && entry.payload?.type === 'message') {
1666
+ // response_item.message may include internal prompts for non-assistant roles.
1667
+ // Keep only assistant output from response_item.
1668
+ if (
1669
+ entry.type === 'response_item' &&
1670
+ entry.payload?.type === 'message' &&
1671
+ entry.payload.role === 'assistant'
1672
+ ) {
1628
1673
  const content = entry.payload.content;
1629
- const role = entry.payload.role || 'assistant';
1630
1674
  const textContent = extractText(content);
1631
1675
 
1632
- // Skip system context messages (environment_context)
1633
- if (textContent?.includes('<environment_context>')) {
1634
- continue;
1635
- }
1636
-
1637
1676
  // Only add if there's actual content
1638
1677
  if (textContent?.trim()) {
1639
1678
  messages.push({
1640
- type: role === 'user' ? 'user' : 'assistant',
1679
+ type: 'assistant',
1641
1680
  timestamp: entry.timestamp,
1642
1681
  message: {
1643
- role: role,
1682
+ role: 'assistant',
1644
1683
  content: textContent
1645
1684
  }
1646
1685
  });