diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 918a7bd6d..61ef0f120 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -26,6 +26,7 @@ import { } from './services/notification-orchestrator.js'; import { claudeAdapter } from './providers/claude/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { generateAndPersistSessionName } from './session-naming.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -471,6 +472,7 @@ async function queryClaudeSDK(command, options = {}, ws) { let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; + let firstAssistantResponse = null; const emitNotification = (event) => { notifyUserIfEnabled({ @@ -658,6 +660,14 @@ async function queryClaudeSDK(command, options = {}, ws) { ws.send(msg); } + // Capture first assistant response for session naming + if (!firstAssistantResponse && message.type === 'assistant' && message.message?.content) { + const c = message.message.content; + firstAssistantResponse = Array.isArray(c) + ? c.find(p => p.type === 'text')?.text + : typeof c === 'string' ? c : null; + } + // Extract and send token budget updates from result messages if (message.type === 'result') { const models = Object.keys(message.modelUsage || {}); @@ -690,6 +700,14 @@ async function queryClaudeSDK(command, options = {}, ws) { }); // Complete + // Fire-and-forget: auto-name new sessions (opt-in only — chat sessions pass this flag) + if (options.autoNameSession && !sessionId && !!command && capturedSessionId) { + generateAndPersistSessionName( + capturedSessionId, command, firstAssistantResponse, + options.broadcastSessionNameUpdated || null + ).catch(err => console.error('Session naming failed:', err)); + } + } catch (error) { console.error('SDK query error:', error); diff --git a/server/index.js b/server/index.js index d295fae43..2e1fbdd42 100755 --- a/server/index.js +++ b/server/index.js @@ -35,6 +35,7 @@ console.log('SERVER_PORT from env:', process.env.SERVER_PORT); import express from 'express'; import { WebSocketServer, WebSocket } from 'ws'; +import { connectedClients, broadcastProgress, broadcastSessionNameUpdated } from './utils/websocket-clients.js'; import os from 'os'; import http from 'http'; import cors from 'cors'; @@ -68,7 +69,7 @@ import pluginsRoutes from './routes/plugins.js'; import messagesRoutes from './routes/messages.js'; import { createNormalizedMessage } from './providers/types.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; -import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; +import { initializeDatabase, sessionNamesDb, apiKeysDb, 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'; @@ -96,22 +97,8 @@ const WATCHER_IGNORED_PATTERNS = [ const WATCHER_DEBOUNCE_MS = 300; let projectsWatchers = []; let projectsWatcherDebounceTimer = null; -const connectedClients = new Set(); let isGetProjectsRunning = false; // Flag to prevent reentrant calls -// Broadcast progress to all connected WebSocket clients -function broadcastProgress(progress) { - const message = JSON.stringify({ - type: 'loading_progress', - ...progress - }); - connectedClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(message); - } - }); -} - // Setup file system watchers for Claude, Cursor, and Codex project/session folders async function setupProjectsWatcher() { const chokidar = (await import('chokidar')).default; @@ -398,12 +385,55 @@ app.use('/api/gemini', authenticateToken, geminiRoutes); // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); +// Rename session endpoint (JWT or API key header) — must be registered +// before the /api/sessions router so the API-key auth path is not blocked +// by the global authenticateToken middleware. +app.put('/api/sessions/:sessionId/rename', (req, res, next) => { + // JWT-first: if Authorization header present, use JWT + const authHeader = req.headers['authorization']; + if (authHeader) { + return authenticateToken(req, res, next); + } + // Fallback: DB API key via x-api-key header only (no query string for security) + const apiKey = req.headers['x-api-key']; + if (apiKey) { + const user = apiKeysDb.validateApiKey(apiKey); + if (user) { req.user = user; return next(); } + return res.status(401).json({ error: 'Invalid or inactive API key' }); + } + return res.status(401).json({ error: 'Authentication required (Authorization or x-api-key header)' }); +}, 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 }); + } +}); + // Unified session messages route (protected) app.use('/api/sessions', authenticateToken, messagesRoutes); // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); + // Serve public files (like api-docs.html) app.use(express.static(path.join(__dirname, '../public'))); @@ -540,32 +570,6 @@ 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 { @@ -1498,7 +1502,7 @@ function handleChatConnection(ws, request) { console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); // Use Claude Agents SDK - await queryClaudeSDK(data.command, data.options, writer); + await queryClaudeSDK(data.command, { ...data.options, autoNameSession: true, broadcastSessionNameUpdated }, writer); } else if (data.type === 'cursor-command') { console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.cwd || 'Unknown'); diff --git a/server/routes/agent.js b/server/routes/agent.js index cdcd3a65a..e660e0793 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -7,6 +7,7 @@ import crypto from 'crypto'; import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js'; import { addProjectManually } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; +import { broadcastSessionNameUpdated } from '../utils/websocket-clients.js'; import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; import { spawnGemini } from '../gemini-cli.js'; @@ -952,7 +953,9 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: sessionId || null, model: model, - permissionMode: 'bypassPermissions' // Bypass all permissions for API calls + permissionMode: 'bypassPermissions', // Bypass all permissions for API calls + autoNameSession: true, + broadcastSessionNameUpdated }, writer); } else if (provider === 'cursor') { diff --git a/server/session-naming.js b/server/session-naming.js new file mode 100644 index 000000000..1a19d212d --- /dev/null +++ b/server/session-naming.js @@ -0,0 +1,99 @@ +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { sessionNamesDb } from './database/db.js'; + +const inFlight = new Set(); + +function cleanUserMessage(text) { + if (!text || typeof text !== 'string') return ''; + return text + .replace(/[\s\S]*?<\/command-name>/g, '') + .replace(/[\s\S]*?<\/command-message>/g, '') + .replace(/[\s\S]*?<\/command-args>/g, '') + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/local-command-stdout>/g, '') + .trim(); +} + +function sanitizeSummary(text) { + if (!text || typeof text !== 'string') return null; + let s = text + .replace(/[\n\r]/g, ' ') + .replace(/^["'`]+|["'`]+$/g, '') // strip surrounding quotes + .replace(/^(Session name|Name|Title|Summary):\s*/i, '') // strip common prefixes + .trim(); + if (!s) return null; + return s.substring(0, 60); +} + +async function generateSessionName(userMessage, assistantResponse) { + const cleaned = cleanUserMessage(userMessage); + if (!cleaned) return null; + + const prompt = `Given this coding assistant conversation, generate a concise session name (max 60 chars). + +User: ${cleaned.substring(0, 500)} +Assistant: ${(assistantResponse || '').substring(0, 500)} + +Rules: +- Return ONLY the session name, no quotes, no explanation +- Focus on the actual task, ignore XML tags and boilerplate content +- Be specific (e.g. "Fix auth token refresh bug" not "Code changes") +- Max 60 characters`; + + const q = query({ + prompt, + options: { + model: 'haiku', + tools: [], + maxTurns: 1, + persistSession: false, + permissionMode: 'plan', + }, + }); + + const timeout = setTimeout(() => q.return?.(), 30000); + try { + for await (const msg of q) { + if (msg.type === 'result' && msg.result) { + return sanitizeSummary(msg.result); + } + } + } finally { + clearTimeout(timeout); + } + return null; +} + +/** + * Generate a session name using Claude SDK and persist it to SQLite. + * Skips if a custom name (manual rename) already exists for this session. + * @param {string} sessionId + * @param {string} userMessage - First user message + * @param {string|null} assistantResponse - First assistant response + * @param {function|null} broadcastFn - Optional callback to broadcast projects_updated + */ +export async function generateAndPersistSessionName(sessionId, userMessage, assistantResponse, broadcastFn) { + if (inFlight.has(sessionId)) return; + inFlight.add(sessionId); + try { + // Skip if user already manually renamed this session + const existing = sessionNamesDb.getName(sessionId, 'claude'); + if (existing) return; + + const name = await generateSessionName(userMessage, assistantResponse); + if (!name) return; + + // Re-check: manual rename may have occurred during LLM call + if (sessionNamesDb.getName(sessionId, 'claude')) return; + + sessionNamesDb.setName(sessionId, 'claude', name); + console.log(`Session ${sessionId} auto-named: "${name}"`); + + // SQLite writes don't trigger file watchers, so broadcast explicitly + if (broadcastFn) { + broadcastFn(sessionId, 'claude', name); + } + } finally { + inFlight.delete(sessionId); + } +} diff --git a/server/utils/websocket-clients.js b/server/utils/websocket-clients.js new file mode 100644 index 000000000..092c8bffd --- /dev/null +++ b/server/utils/websocket-clients.js @@ -0,0 +1,32 @@ +import WebSocket from 'ws'; + +// Shared set of connected WebSocket clients, used by both the main server +// (index.js) and the agent API routes to broadcast real-time updates. +export const connectedClients = new Set(); + +// Broadcast loading/progress updates to all connected clients. +export function broadcastProgress(progress) { + const message = JSON.stringify({ + type: 'loading_progress', + ...progress + }); + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} + +// Broadcast a lightweight session-name-updated event to all connected clients. +// Unlike the old broadcastProjectsUpdated(), this does NOT re-scan projects — +// the frontend patches its local state directly. +export function broadcastSessionNameUpdated(sessionId, provider, name) { + const msg = JSON.stringify({ + type: 'session_name_updated', + sessionId, provider, name, + timestamp: new Date().toISOString() + }); + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) client.send(msg); + }); +} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 28cf682e7..932a2650c 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -8,6 +8,8 @@ import type { Project, ProjectSession, ProjectsUpdatedMessage, + SessionNameUpdatedMessage, + SessionProvider, } from '../types/app'; type UseProjectsStateArgs = { @@ -226,6 +228,54 @@ export function useProjectsState({ return; } + if (latestMessage.type === 'session_name_updated') { + const { sessionId: updatedId, provider, name } = latestMessage as SessionNameUpdatedMessage; + + const sessionKey = (p: SessionProvider): keyof Project => { + switch (p) { + case 'cursor': return 'cursorSessions'; + case 'codex': return 'codexSessions'; + case 'gemini': return 'geminiSessions'; + default: return 'sessions'; + } + }; + + setProjects(prev => + prev.map(project => { + const key = sessionKey(provider); + const sessions = project[key] as ProjectSession[] | undefined; + if (!sessions) return project; + + const idx = sessions.findIndex(s => s.id === updatedId); + if (idx === -1) return project; + + const updated = [...sessions]; + updated[idx] = { ...updated[idx], summary: name }; + return { ...project, [key]: updated }; + }) + ); + + // Also update selectedProject / selectedSession if they match + if (selectedProject) { + const key = sessionKey(provider); + const sessions = selectedProject[key] as ProjectSession[] | undefined; + if (sessions) { + const idx = sessions.findIndex(s => s.id === updatedId); + if (idx !== -1) { + const updated = [...sessions]; + updated[idx] = { ...updated[idx], summary: name }; + setSelectedProject({ ...selectedProject, [key]: updated }); + + if (selectedSession?.id === updatedId) { + setSelectedSession({ ...selectedSession, summary: name }); + } + } + } + } + + return; + } + if (latestMessage.type !== 'projects_updated') { return; } diff --git a/src/types/app.ts b/src/types/app.ts index 9abaac6be..040dc0f4f 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -60,6 +60,14 @@ export interface ProjectsUpdatedMessage { [key: string]: unknown; } +export interface SessionNameUpdatedMessage { + type: 'session_name_updated'; + sessionId: string; + provider: SessionProvider; + name: string; + timestamp?: string; +} + export interface LoadingProgressMessage extends LoadingProgress { type: 'loading_progress'; } @@ -67,4 +75,5 @@ export interface LoadingProgressMessage extends LoadingProgress { export type AppSocketMessage = | LoadingProgressMessage | ProjectsUpdatedMessage + | SessionNameUpdatedMessage | { type?: string;[key: string]: unknown };