mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 17:37:24 +00:00
fix: notification banner would cause refresh of page
This commit is contained in:
@@ -35,7 +35,7 @@ function createRequestId() {
|
||||
}
|
||||
|
||||
function waitForToolApproval(requestId, options = {}) {
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
@@ -79,9 +79,14 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
pendingToolApprovals.set(requestId, (decision) => {
|
||||
const resolver = (decision) => {
|
||||
finalize(decision);
|
||||
});
|
||||
};
|
||||
// Attach metadata for getPendingApprovalsForSession lookup
|
||||
if (metadata) {
|
||||
Object.assign(resolver, metadata);
|
||||
}
|
||||
pendingToolApprovals.set(requestId, resolver);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,13 +215,14 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
||||
* @param {string} tempDir - Temp directory for cleanup
|
||||
*/
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
|
||||
activeSessions.set(sessionId, {
|
||||
instance: queryInstance,
|
||||
startTime: Date.now(),
|
||||
status: 'active',
|
||||
tempImagePaths,
|
||||
tempDir
|
||||
tempDir,
|
||||
writer
|
||||
});
|
||||
}
|
||||
|
||||
@@ -567,6 +573,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
signal: context?.signal,
|
||||
metadata: {
|
||||
_sessionId: capturedSessionId || sessionId || null,
|
||||
_toolName: toolName,
|
||||
_input: input,
|
||||
_receivedAt: new Date(),
|
||||
},
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
type: 'claude-permission-cancelled',
|
||||
@@ -629,7 +641,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
|
||||
// Track the query instance for abort capability
|
||||
if (capturedSessionId) {
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
}
|
||||
|
||||
// Process streaming messages
|
||||
@@ -639,7 +651,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
if (message.session_id && !capturedSessionId) {
|
||||
|
||||
capturedSessionId = message.session_id;
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
|
||||
// Set session ID on writer
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
@@ -788,11 +800,50 @@ function getActiveClaudeSDKSessions() {
|
||||
return getAllSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tool approvals for a specific session.
|
||||
* @param {string} sessionId - The session ID
|
||||
* @returns {Array} Array of pending permission request objects
|
||||
*/
|
||||
function getPendingApprovalsForSession(sessionId) {
|
||||
const pending = [];
|
||||
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
||||
if (resolver._sessionId === sessionId) {
|
||||
pending.push({
|
||||
requestId,
|
||||
toolName: resolver._toolName || 'UnknownTool',
|
||||
input: resolver._input,
|
||||
context: resolver._context,
|
||||
sessionId,
|
||||
receivedAt: resolver._receivedAt || new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
||||
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
||||
* @param {string} sessionId - The session ID
|
||||
* @param {Object} newRawWs - The new raw WebSocket connection
|
||||
* @returns {boolean} True if writer was successfully reconnected
|
||||
*/
|
||||
function reconnectSessionWriter(sessionId, newRawWs) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session?.writer?.updateWebSocket) return false;
|
||||
session.writer.updateWebSocket(newRawWs);
|
||||
console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Export public API
|
||||
export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval
|
||||
resolveToolApproval,
|
||||
getPendingApprovalsForSession,
|
||||
reconnectSessionWriter
|
||||
};
|
||||
|
||||
@@ -121,6 +121,18 @@ const runMigrations = () => {
|
||||
)
|
||||
`);
|
||||
|
||||
// Create session_names table if it doesn't exist (for existing installations)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, provider)
|
||||
)`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
@@ -488,6 +500,60 @@ const pushSubscriptionsDb = {
|
||||
}
|
||||
};
|
||||
|
||||
// Session custom names database operations
|
||||
const sessionNamesDb = {
|
||||
// Set (insert or update) a custom session name
|
||||
setName: (sessionId, provider, customName) => {
|
||||
db.prepare(`
|
||||
INSERT INTO session_names (session_id, provider, custom_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, provider)
|
||||
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
|
||||
`).run(sessionId, provider, customName);
|
||||
},
|
||||
|
||||
// Get a single custom session name
|
||||
getName: (sessionId, provider) => {
|
||||
const row = db.prepare(
|
||||
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).get(sessionId, provider);
|
||||
return row?.custom_name || null;
|
||||
},
|
||||
|
||||
// Batch lookup — returns Map<sessionId, customName>
|
||||
getNames: (sessionIds, provider) => {
|
||||
if (!sessionIds.length) return new Map();
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT session_id, custom_name FROM session_names
|
||||
WHERE session_id IN (${placeholders}) AND provider = ?`
|
||||
).all(...sessionIds, provider);
|
||||
return new Map(rows.map(r => [r.session_id, r.custom_name]));
|
||||
},
|
||||
|
||||
// Delete a custom session name
|
||||
deleteName: (sessionId, provider) => {
|
||||
return db.prepare(
|
||||
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).run(sessionId, provider).changes > 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Apply custom session names from the database (overrides CLI-generated summaries)
|
||||
function applyCustomSessionNames(sessions, provider) {
|
||||
if (!sessions?.length) return;
|
||||
try {
|
||||
const ids = sessions.map(s => s.id);
|
||||
const customNames = sessionNamesDb.getNames(ids, provider);
|
||||
for (const session of sessions) {
|
||||
const custom = customNames.get(session.id);
|
||||
if (custom) session.summary = custom;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
@@ -515,5 +581,7 @@ export {
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
sessionNamesDb,
|
||||
applyCustomSessionNames,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
|
||||
@@ -77,3 +77,16 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Session custom names (provider-agnostic display name overrides)
|
||||
CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, provider)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
||||
|
||||
103
server/index.js
103
server/index.js
@@ -44,8 +44,8 @@ import pty from 'node-pty';
|
||||
import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
||||
@@ -64,11 +64,13 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
|
||||
|
||||
// File system watchers for provider project/session folders
|
||||
const PROVIDER_WATCH_PATHS = [
|
||||
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
||||
@@ -494,6 +496,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
|
||||
try {
|
||||
const { limit = 5, offset = 0 } = req.query;
|
||||
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
||||
applyCustomSessionNames(result.sessions, 'claude');
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -542,6 +545,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
||||
const { projectName, sessionId } = req.params;
|
||||
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
||||
await deleteSession(projectName, sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'claude');
|
||||
console.log(`[API] Session ${sessionId} deleted successfully`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -550,6 +554,32 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
||||
}
|
||||
});
|
||||
|
||||
// Rename session endpoint
|
||||
app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
const { summary, provider } = req.body;
|
||||
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
|
||||
return res.status(400).json({ error: 'Summary is required' });
|
||||
}
|
||||
if (summary.trim().length > 500) {
|
||||
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
|
||||
}
|
||||
if (!provider || !VALID_PROVIDERS.includes(provider)) {
|
||||
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
||||
}
|
||||
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete project endpoint (force=true to delete with sessions)
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -579,6 +609,51 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Search conversations content (SSE streaming)
|
||||
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
||||
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
|
||||
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
|
||||
|
||||
if (query.length < 2) {
|
||||
return res.status(400).json({ error: 'Query must be at least 2 characters' });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const abortController = new AbortController();
|
||||
req.on('close', () => { closed = true; abortController.abort(); });
|
||||
|
||||
try {
|
||||
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
||||
if (closed) return;
|
||||
if (projectResult) {
|
||||
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
} else {
|
||||
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
}
|
||||
}, abortController.signal);
|
||||
if (!closed) {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching conversations:', error);
|
||||
if (!closed) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
if (!closed) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const expandWorkspacePath = (inputPath) => {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === '~') {
|
||||
@@ -1352,6 +1427,10 @@ class WebSocketWriter {
|
||||
}
|
||||
}
|
||||
|
||||
updateWebSocket(newRawWs) {
|
||||
this.ws = newRawWs;
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
@@ -1466,6 +1545,11 @@ function handleChatConnection(ws, request) {
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
isActive = isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
// Reconnect the session's writer to the new WebSocket so
|
||||
// subsequent SDK output flows to the refreshed client.
|
||||
reconnectSessionWriter(sessionId, ws);
|
||||
}
|
||||
}
|
||||
|
||||
writer.send({
|
||||
@@ -1474,6 +1558,17 @@ function handleChatConnection(ws, request) {
|
||||
provider,
|
||||
isProcessing: isActive
|
||||
});
|
||||
} else if (data.type === 'get-pending-permissions') {
|
||||
// Return pending permission requests for a session
|
||||
const sessionId = data.sessionId;
|
||||
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
||||
const pending = getPendingApprovalsForSession(sessionId);
|
||||
writer.send({
|
||||
type: 'pending-permissions-response',
|
||||
sessionId,
|
||||
data: pending
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'get-active-sessions') {
|
||||
// Get all currently active sessions
|
||||
const activeSessions = {
|
||||
@@ -2114,7 +2209,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import os from 'os';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import { applyCustomSessionNames } from './database/db.js';
|
||||
|
||||
// Import TaskMaster detection functions
|
||||
async function detectTaskMasterFolder(projectPath) {
|
||||
@@ -458,6 +459,7 @@ async function getProjects(progressCallback = null) {
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
applyCustomSessionNames(project.sessions, 'claude');
|
||||
|
||||
// Also fetch Cursor sessions for this project
|
||||
try {
|
||||
@@ -466,6 +468,7 @@ async function getProjects(progressCallback = null) {
|
||||
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
@@ -476,14 +479,20 @@ async function getProjects(progressCallback = null) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Also fetch Gemini sessions for this project
|
||||
// Also fetch Gemini sessions for this project (UI + CLI)
|
||||
try {
|
||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
project.geminiSessions = mergedGemini;
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||
project.geminiSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
// Add TaskMaster detection
|
||||
try {
|
||||
@@ -567,6 +576,7 @@ async function getProjects(progressCallback = null) {
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
@@ -576,13 +586,18 @@ async function getProjects(progressCallback = null) {
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Try to fetch Gemini sessions for manual projects too
|
||||
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
|
||||
try {
|
||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
// Add TaskMaster detection for manual projects
|
||||
try {
|
||||
@@ -1071,10 +1086,13 @@ async function renameProject(projectName, newDisplayName) {
|
||||
|
||||
if (!newDisplayName || newDisplayName.trim() === '') {
|
||||
// Remove custom name if empty, will fall back to auto-generated
|
||||
delete config[projectName];
|
||||
if (config[projectName]) {
|
||||
delete config[projectName].displayName;
|
||||
}
|
||||
} else {
|
||||
// Set custom display name
|
||||
// Set custom display name, preserving other properties (manuallyAdded, originalPath)
|
||||
config[projectName] = {
|
||||
...config[projectName],
|
||||
displayName: newDisplayName.trim()
|
||||
};
|
||||
}
|
||||
@@ -1479,6 +1497,23 @@ async function getCodexSessions(projectPath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleCodexUserMessage(payload) {
|
||||
if (!payload || payload.type !== 'user_message') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Codex logs internal context (environment, instructions) as non-plain user_message kinds.
|
||||
if (payload.kind && payload.kind !== 'plain') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof payload.message !== 'string' || payload.message.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse a Codex session JSONL file to extract metadata
|
||||
async function parseCodexSessionFile(filePath) {
|
||||
try {
|
||||
@@ -1514,8 +1549,8 @@ async function parseCodexSessionFile(filePath) {
|
||||
};
|
||||
}
|
||||
|
||||
// Count messages and extract user messages for summary
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||||
// Count visible user messages and extract summary from the latest plain user input.
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
||||
messageCount++;
|
||||
if (entry.payload.message) {
|
||||
lastUserMessage = entry.payload.message;
|
||||
@@ -1622,25 +1657,36 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use event_msg.user_message for user-visible inputs.
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
||||
messages.push({
|
||||
type: 'user',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: entry.payload.message
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract messages from response_item
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||
// response_item.message may include internal prompts for non-assistant roles.
|
||||
// Keep only assistant output from response_item.
|
||||
if (
|
||||
entry.type === 'response_item' &&
|
||||
entry.payload?.type === 'message' &&
|
||||
entry.payload.role === 'assistant'
|
||||
) {
|
||||
const content = entry.payload.content;
|
||||
const role = entry.payload.role || 'assistant';
|
||||
const textContent = extractText(content);
|
||||
|
||||
// Skip system context messages (environment_context)
|
||||
if (textContent?.includes('<environment_context>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add if there's actual content
|
||||
if (textContent?.trim()) {
|
||||
messages.push({
|
||||
type: role === 'user' ? 'user' : 'assistant',
|
||||
type: 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: role,
|
||||
role: 'assistant',
|
||||
content: textContent
|
||||
}
|
||||
});
|
||||
@@ -1823,6 +1869,675 @@ async function deleteCodexSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
|
||||
const safeQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const results = [];
|
||||
let totalMatches = 0;
|
||||
const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
||||
if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };
|
||||
|
||||
const isAborted = () => signal?.aborted === true;
|
||||
|
||||
const isSystemMessage = (textContent) => {
|
||||
return typeof textContent === 'string' && (
|
||||
textContent.startsWith('<command-name>') ||
|
||||
textContent.startsWith('<command-message>') ||
|
||||
textContent.startsWith('<command-args>') ||
|
||||
textContent.startsWith('<local-command-stdout>') ||
|
||||
textContent.startsWith('<system-reminder>') ||
|
||||
textContent.startsWith('Caveat:') ||
|
||||
textContent.startsWith('This session is being continued from a previous') ||
|
||||
textContent.startsWith('Invalid API key') ||
|
||||
textContent.includes('{"subtasks":') ||
|
||||
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||
|
||||
textContent === 'Warmup'
|
||||
);
|
||||
};
|
||||
|
||||
const extractText = (content) => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(part => part.type === 'text' && part.text)
|
||||
.map(part => part.text)
|
||||
.join(' ');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wordPatterns = words.map(w => new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u'));
|
||||
const allWordsMatch = (textLower) => {
|
||||
return wordPatterns.every(p => p.test(textLower));
|
||||
};
|
||||
|
||||
const buildSnippet = (text, textLower, snippetLen = 150) => {
|
||||
let firstIndex = -1;
|
||||
let firstWordLen = 0;
|
||||
for (const w of words) {
|
||||
const re = new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u');
|
||||
const m = re.exec(textLower);
|
||||
if (m && (firstIndex === -1 || m.index < firstIndex)) {
|
||||
firstIndex = m.index;
|
||||
firstWordLen = w.length;
|
||||
}
|
||||
}
|
||||
if (firstIndex === -1) firstIndex = 0;
|
||||
const halfLen = Math.floor(snippetLen / 2);
|
||||
let start = Math.max(0, firstIndex - halfLen);
|
||||
let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
|
||||
let snippet = text.slice(start, end).replace(/\n/g, ' ');
|
||||
const prefix = start > 0 ? '...' : '';
|
||||
const suffix = end < text.length ? '...' : '';
|
||||
snippet = prefix + snippet + suffix;
|
||||
const snippetLower = snippet.toLowerCase();
|
||||
const highlights = [];
|
||||
for (const word of words) {
|
||||
const re = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
|
||||
let match;
|
||||
while ((match = re.exec(snippetLower)) !== null) {
|
||||
highlights.push({ start: match.index, end: match.index + word.length });
|
||||
}
|
||||
}
|
||||
highlights.sort((a, b) => a.start - b.start);
|
||||
const merged = [];
|
||||
for (const h of highlights) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && h.start <= last.end) {
|
||||
last.end = Math.max(last.end, h.end);
|
||||
} else {
|
||||
merged.push({ ...h });
|
||||
}
|
||||
}
|
||||
return { snippet, highlights: merged };
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.access(claudeDir);
|
||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||
const projectDirs = entries.filter(e => e.isDirectory());
|
||||
let scannedProjects = 0;
|
||||
const totalProjects = projectDirs.length;
|
||||
|
||||
for (const projectEntry of projectDirs) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
|
||||
const projectName = projectEntry.name;
|
||||
const projectDir = path.join(claudeDir, projectName);
|
||||
const displayName = config[projectName]?.displayName
|
||||
|| await generateDisplayName(projectName);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await fs.readdir(projectDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsonlFiles = files.filter(
|
||||
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
|
||||
);
|
||||
|
||||
const projectResult = {
|
||||
projectName,
|
||||
projectDisplayName: displayName,
|
||||
sessions: []
|
||||
};
|
||||
|
||||
for (const file of jsonlFiles) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
|
||||
const filePath = path.join(projectDir, file);
|
||||
const sessionMatches = new Map();
|
||||
const sessionSummaries = new Map();
|
||||
const pendingSummaries = new Map();
|
||||
const sessionLastMessages = new Map();
|
||||
let currentSessionId = null;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.sessionId) {
|
||||
currentSessionId = entry.sessionId;
|
||||
}
|
||||
if (entry.type === 'summary' && entry.summary) {
|
||||
const sid = entry.sessionId || currentSessionId;
|
||||
if (sid) {
|
||||
sessionSummaries.set(sid, entry.summary);
|
||||
} else if (entry.leafUuid) {
|
||||
pendingSummaries.set(entry.leafUuid, entry.summary);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending summary via parentUuid
|
||||
if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {
|
||||
const pending = pendingSummaries.get(entry.parentUuid);
|
||||
if (pending) sessionSummaries.set(currentSessionId, pending);
|
||||
}
|
||||
|
||||
// Track last user/assistant message for fallback title
|
||||
if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {
|
||||
const role = entry.message.role;
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
const text = extractText(entry.message.content);
|
||||
if (text && !isSystemMessage(text)) {
|
||||
if (!sessionLastMessages.has(currentSessionId)) {
|
||||
sessionLastMessages.set(currentSessionId, {});
|
||||
}
|
||||
const msgs = sessionLastMessages.get(currentSessionId);
|
||||
if (role === 'user') msgs.user = text;
|
||||
else msgs.assistant = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.message?.content) continue;
|
||||
if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;
|
||||
if (entry.isApiErrorMessage) continue;
|
||||
|
||||
const text = extractText(entry.message.content);
|
||||
if (!text || isSystemMessage(text)) continue;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');
|
||||
if (!sessionMatches.has(sessionId)) {
|
||||
sessionMatches.set(sessionId, []);
|
||||
}
|
||||
|
||||
const matches = sessionMatches.get(sessionId);
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role: entry.message.role,
|
||||
snippet,
|
||||
highlights,
|
||||
timestamp: entry.timestamp || null,
|
||||
provider: 'claude',
|
||||
messageUuid: entry.uuid || null
|
||||
});
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [sessionId, matches] of sessionMatches) {
|
||||
projectResult.sessions.push({
|
||||
sessionId,
|
||||
provider: 'claude',
|
||||
sessionSummary: sessionSummaries.get(sessionId) || (() => {
|
||||
const msgs = sessionLastMessages.get(sessionId);
|
||||
const lastMsg = msgs?.user || msgs?.assistant;
|
||||
return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';
|
||||
})(),
|
||||
matches
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Search Codex sessions for this project
|
||||
try {
|
||||
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||
await searchCodexSessionsForProject(
|
||||
actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Skip codex search errors
|
||||
}
|
||||
|
||||
// Search Gemini sessions for this project
|
||||
try {
|
||||
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||
await searchGeminiSessionsForProject(
|
||||
actualProjectDir, projectResult, words, allWordsMatch,
|
||||
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Skip gemini search errors
|
||||
}
|
||||
|
||||
scannedProjects++;
|
||||
if (projectResult.sessions.length > 0) {
|
||||
results.push(projectResult);
|
||||
if (onProjectResult) {
|
||||
onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });
|
||||
}
|
||||
} else if (onProjectResult && scannedProjects % 10 === 0) {
|
||||
onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// claudeDir doesn't exist
|
||||
}
|
||||
|
||||
return { results, totalMatches, query: safeQuery };
|
||||
}
|
||||
|
||||
async function searchCodexSessionsForProject(
|
||||
projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||
buildSnippet, limit, getTotalMatches, addMatches, isAborted
|
||||
) {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return;
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
if (getTotalMatches() >= limit || isAborted()) break;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
|
||||
// First pass: read session_meta to check project path match
|
||||
let sessionMeta = null;
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'session_meta' && entry.payload) {
|
||||
sessionMeta = entry.payload;
|
||||
break;
|
||||
}
|
||||
} catch { continue; }
|
||||
}
|
||||
|
||||
// Skip sessions that don't belong to this project
|
||||
if (!sessionMeta) continue;
|
||||
const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);
|
||||
if (sessionProjectPath !== normalizedProjectPath) continue;
|
||||
|
||||
// Second pass: re-read file to find matching messages
|
||||
const fileStream2 = fsSync.createReadStream(filePath);
|
||||
const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });
|
||||
let lastUserMessage = null;
|
||||
const matches = [];
|
||||
|
||||
for await (const line of rl2) {
|
||||
if (getTotalMatches() >= limit || isAborted()) break;
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
|
||||
let text = null;
|
||||
let role = null;
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {
|
||||
text = entry.payload.message;
|
||||
role = 'user';
|
||||
lastUserMessage = text;
|
||||
} else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||
const contentParts = entry.payload.content || [];
|
||||
if (entry.payload.role === 'user') {
|
||||
text = contentParts
|
||||
.filter(p => p.type === 'input_text' && p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
role = 'user';
|
||||
if (text) lastUserMessage = text;
|
||||
} else if (entry.payload.role === 'assistant') {
|
||||
text = contentParts
|
||||
.filter(p => p.type === 'output_text' && p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
role = 'assistant';
|
||||
}
|
||||
}
|
||||
|
||||
if (!text || !role) continue;
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
projectResult.sessions.push({
|
||||
sessionId: sessionMeta.id,
|
||||
provider: 'codex',
|
||||
sessionSummary: lastUserMessage
|
||||
? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)
|
||||
: 'Codex Session',
|
||||
matches
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchGeminiSessionsForProject(
|
||||
projectPath, projectResult, words, allWordsMatch,
|
||||
buildSnippet, limit, getTotalMatches, addMatches
|
||||
) {
|
||||
// 1) Search in-memory sessions (created via UI)
|
||||
for (const [sessionId, session] of sessionManager.sessions) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (session.projectPath !== projectPath) continue;
|
||||
|
||||
const matches = [];
|
||||
for (const msg of session.messages) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
|
||||
const text = typeof msg.content === 'string' ? msg.content
|
||||
: Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')
|
||||
: '';
|
||||
if (!text) continue;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role: msg.role, snippet, highlights,
|
||||
timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const firstUserMsg = session.messages.find(m => m.role === 'user');
|
||||
const summary = firstUserMsg?.content
|
||||
? (typeof firstUserMsg.content === 'string'
|
||||
? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)
|
||||
: 'Gemini Session')
|
||||
: 'Gemini Session';
|
||||
|
||||
projectResult.sessions.push({
|
||||
sessionId,
|
||||
provider: 'gemini',
|
||||
sessionSummary: summary,
|
||||
matches
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return;
|
||||
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
try {
|
||||
await fs.access(geminiTmpDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackedSessionIds = new Set();
|
||||
for (const [sid] of sessionManager.sessions) {
|
||||
trackedSessionIds.add(sid);
|
||||
}
|
||||
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
|
||||
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||
let projectRoot;
|
||||
try {
|
||||
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||
|
||||
const cliSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
if (trackedSessionIds.has(cliSessionId)) continue;
|
||||
|
||||
const matches = [];
|
||||
let firstUserText = null;
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
|
||||
const role = msg.type === 'user' ? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||
: null;
|
||||
if (!role) continue;
|
||||
|
||||
let text = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
text = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
text = msg.content
|
||||
.filter(p => p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
if (role === 'user' && !firstUserText) firstUserText = text;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role, snippet, highlights,
|
||||
timestamp: msg.timestamp || null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const summary = firstUserText
|
||||
? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)
|
||||
: 'Gemini CLI Session';
|
||||
|
||||
projectResult.sessions.push({
|
||||
sessionId: cliSessionId,
|
||||
provider: 'gemini',
|
||||
sessionSummary: summary,
|
||||
matches
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiCliSessions(projectPath) {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return [];
|
||||
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
try {
|
||||
await fs.access(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||
let projectRoot;
|
||||
try {
|
||||
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||
|
||||
const sessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
const firstUserMsg = session.messages.find(m => m.type === 'user');
|
||||
let summary = 'Gemini CLI Session';
|
||||
if (firstUserMsg) {
|
||||
const text = Array.isArray(firstUserMsg.content)
|
||||
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
|
||||
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
|
||||
if (text) {
|
||||
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id: sessionId,
|
||||
summary,
|
||||
messageCount: session.messages.length,
|
||||
lastActivity: session.lastUpdated || session.startTime || null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) =>
|
||||
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId) {
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
if (fileSessionId !== sessionId) continue;
|
||||
|
||||
return (session.messages || []).map(msg => {
|
||||
const role = msg.type === 'user' ? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||
: msg.type;
|
||||
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
message: { role, content },
|
||||
timestamp: msg.timestamp || null
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
@@ -1839,5 +2554,8 @@ export {
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessions,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession
|
||||
deleteCodexSession,
|
||||
getGeminiCliSessions,
|
||||
getGeminiCliSessionMessages,
|
||||
searchConversations
|
||||
};
|
||||
|
||||
@@ -14,13 +14,14 @@ router.get('/claude/status', async (req, res) => {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: 'credentials_file'
|
||||
method: credentialsResult.method // 'api_key' or 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ router.get('/claude/status', async (req, res) => {
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
@@ -115,6 +117,20 @@ router.get('/gemini/status', async (req, res) => {
|
||||
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
|
||||
*/
|
||||
async function checkClaudeCredentials() {
|
||||
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
|
||||
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
|
||||
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
|
||||
// This is the standard authentication method used by Claude CLI after running
|
||||
// 'claude /login' or 'claude setup-token' commands.
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
@@ -127,19 +143,22 @@ async function checkClaudeCredentials() {
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
||||
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -59,6 +60,7 @@ router.get('/sessions', async (req, res) => {
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
applyCustomSessionNames(sessions, 'codex');
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
@@ -88,6 +90,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'codex');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
import { applyCustomSessionNames } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -560,8 +561,10 @@ router.get('/sessions', async (req, res) => {
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
applyCustomSessionNames(sessions, 'cursor');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessions: sessions,
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from 'express';
|
||||
import sessionManager from '../sessionManager.js';
|
||||
import { sessionNamesDb } from '../database/db.js';
|
||||
import { getGeminiCliSessionMessages } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -11,7 +13,12 @@ router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
||||
}
|
||||
|
||||
const messages = sessionManager.getSessionMessages(sessionId);
|
||||
let messages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
// Fallback to Gemini CLI sessions on disk
|
||||
if (messages.length === 0) {
|
||||
messages = await getGeminiCliSessionMessages(sessionId);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -36,6 +43,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
}
|
||||
|
||||
await sessionManager.deleteSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'gemini');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
||||
|
||||
Reference in New Issue
Block a user