diff --git a/server/__tests__/codex-session-events.test.mjs b/server/__tests__/codex-session-events.test.mjs new file mode 100644 index 00000000..a7d2cbe3 --- /dev/null +++ b/server/__tests__/codex-session-events.test.mjs @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodexSessionCreatedEvent } from '../utils/codexSessionEvents.js'; + +describe('codex session event payloads', () => { + it('includes projectName when provided', () => { + const projectName = 'C--Users-test-user-dr-claw-project'; + const event = buildCodexSessionCreatedEvent({ + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + sessionMode: 'research', + projectName, + }); + + expect(event).toEqual({ + type: 'session-created', + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + provider: 'codex', + mode: 'research', + projectName, + }); + }); + + it('keeps backward-compatible payload shape when projectName is missing', () => { + const event = buildCodexSessionCreatedEvent({ + sessionId: 'session-no-project', + sessionMode: 'workspace_qa', + }); + + expect(event).toEqual({ + type: 'session-created', + sessionId: 'session-no-project', + provider: 'codex', + mode: 'workspace_qa', + }); + }); +}); diff --git a/server/__tests__/session-lifecycle.test.mjs b/server/__tests__/session-lifecycle.test.mjs new file mode 100644 index 00000000..6eafbe96 --- /dev/null +++ b/server/__tests__/session-lifecycle.test.mjs @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { + inferProviderFromMessageType, + resolveProjectName, + enrichSessionEventPayload, + buildLifecycleMessageFromPayload, +} from '../utils/sessionLifecycle.js'; + +describe('inferProviderFromMessageType', () => { + it.each([ + ['claude-complete', 'claude'], + ['cursor-result', 'cursor'], + ['codex-complete', 'codex'], + ['gemini-complete', 'gemini'], + ['openrouter-complete', 'openrouter'], + ['localgpu-complete', 'local'], + ['nano-complete', 'nano'], + ])('infers %s → %s', (type, expected) => { + expect(inferProviderFromMessageType(type)).toBe(expected); + }); + + it('returns fallbackProvider when prefix is unknown', () => { + expect(inferProviderFromMessageType('unknown-type', 'codex')).toBe('codex'); + }); + + it('returns null when no prefix matches and no fallback', () => { + expect(inferProviderFromMessageType('unknown-type')).toBeNull(); + }); + + it('handles null/undefined type gracefully', () => { + expect(inferProviderFromMessageType(null)).toBeNull(); + expect(inferProviderFromMessageType(undefined)).toBeNull(); + }); +}); + +describe('resolveProjectName', () => { + it('returns explicit projectName when provided', () => { + expect(resolveProjectName('my-project', null)).toBe('my-project'); + }); + + it('returns null for empty projectName and no path', () => { + expect(resolveProjectName(null, null)).toBeNull(); + expect(resolveProjectName('', '')).toBeNull(); + expect(resolveProjectName(' ', null)).toBeNull(); + }); + + it('resolves from projectPath via deps when projectName is missing', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + expect(resolveProjectName(null, '/some/path', deps)).toBe('encoded-/some/path'); + }); + + it('returns null when isKnownPath returns false', () => { + const deps = { + isKnownPath: () => false, + encodePath: () => 'should-not-be-called', + }; + expect(resolveProjectName(null, '/unknown/path', deps)).toBeNull(); + }); + + it('returns null when encodePath throws', () => { + const deps = { + isKnownPath: () => true, + encodePath: () => { throw new Error('encode failed'); }, + }; + expect(resolveProjectName(null, '/bad/path', deps)).toBeNull(); + }); + + it('returns null when deps are not provided and projectName is missing', () => { + expect(resolveProjectName(null, '/some/path')).toBeNull(); + }); +}); + +describe('enrichSessionEventPayload', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + + it('returns non-object payloads unchanged', () => { + expect(enrichSessionEventPayload(null)).toBeNull(); + expect(enrichSessionEventPayload(undefined)).toBeUndefined(); + expect(enrichSessionEventPayload('string')).toBe('string'); + }); + + it('ignores non-session message types', () => { + const payload = { type: 'claude-complete', projectPath: '/p' }; + expect(enrichSessionEventPayload(payload, null, deps)).toBe(payload); + }); + + it('enriches session payload with resolved projectName from projectPath', () => { + const payload = { type: 'session-created', projectPath: '/my/project' }; + const result = enrichSessionEventPayload(payload, null, deps); + expect(result.projectName).toBe('encoded-/my/project'); + expect(result.type).toBe('session-created'); + }); + + it('uses fallbackProjectPath when payload has no projectPath', () => { + const payload = { type: 'session-created' }; + const result = enrichSessionEventPayload(payload, '/fallback/path', deps); + expect(result.projectName).toBe('encoded-/fallback/path'); + }); + + it('does not overwrite existing projectName', () => { + const payload = { type: 'session-created', projectName: 'already-set' }; + const result = enrichSessionEventPayload(payload, null, deps); + expect(result).toBe(payload); + }); + + it('returns original payload when resolved name matches existing', () => { + const depsMatch = { + isKnownPath: () => true, + encodePath: () => 'same-name', + }; + const payload = { type: 'session-created', projectName: 'same-name' }; + expect(enrichSessionEventPayload(payload, null, depsMatch)).toBe(payload); + }); +}); + +describe('buildLifecycleMessageFromPayload', () => { + it('returns null for non-object payloads', () => { + expect(buildLifecycleMessageFromPayload(null)).toBeNull(); + expect(buildLifecycleMessageFromPayload(undefined)).toBeNull(); + expect(buildLifecycleMessageFromPayload(42)).toBeNull(); + }); + + it('returns null for non-terminal message types', () => { + expect(buildLifecycleMessageFromPayload({ type: 'claude-chunk' })).toBeNull(); + expect(buildLifecycleMessageFromPayload({ type: 'session-created' })).toBeNull(); + }); + + it('builds completed lifecycle for -complete suffix', () => { + const now = Date.now(); + const result = buildLifecycleMessageFromPayload({ + type: 'claude-complete', + sessionId: 'sess-1', + }); + expect(result).toMatchObject({ + type: 'session-state-changed', + provider: 'claude', + sessionId: 'sess-1', + state: 'completed', + reason: 'claude-complete', + }); + expect(result.changedAt).toBeGreaterThanOrEqual(now); + }); + + it('builds completed lifecycle for cursor-result', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'cursor-result', + sessionId: 'cursor-sess', + }); + expect(result.state).toBe('completed'); + expect(result.provider).toBe('cursor'); + }); + + it('builds failed lifecycle for -error suffix', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'codex-error', + sessionId: 'codex-sess', + }); + expect(result).toMatchObject({ + state: 'failed', + provider: 'codex', + reason: 'codex-error', + }); + }); + + it('prefers actualSessionId over sessionId', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'gemini-complete', + sessionId: 'old-id', + actualSessionId: 'real-id', + }); + expect(result.sessionId).toBe('real-id'); + }); + + it('uses fallbackProvider when type prefix is unknown', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'custom-complete', sessionId: 's1' }, + 'openrouter', + ); + expect(result.provider).toBe('openrouter'); + }); + + it('uses payload.provider over fallbackProvider', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'custom-complete', sessionId: 's1', provider: 'nano' }, + 'openrouter', + ); + expect(result.provider).toBe('nano'); + }); + + it('includes projectName from fallbackProjectName', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'claude-complete', sessionId: 's1' }, + null, + 'my-project', + ); + expect(result.projectName).toBe('my-project'); + }); + + it('omits projectName when not resolvable', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'claude-complete', sessionId: 's1' }, + null, + null, + ); + expect(result).not.toHaveProperty('projectName'); + }); + + it('resolves projectName from projectPath via deps', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + const result = buildLifecycleMessageFromPayload( + { type: 'claude-error', sessionId: 's1', projectPath: '/proj' }, + null, + null, + deps, + ); + expect(result.projectName).toBe('encoded-/proj'); + expect(result.state).toBe('failed'); + }); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index f0faa351..e484149a 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -172,13 +172,14 @@ function mapCliOptionsToSDK(options = {}) { * @param {Array} 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, }); } @@ -645,7 +646,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 @@ -657,7 +658,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') { @@ -681,7 +682,8 @@ async function queryClaudeSDK(command, options = {}, ws) { type: 'session-created', sessionId: capturedSessionId, provider: 'claude', - mode: sessionMode || 'research' + mode: sessionMode || 'research', + projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined, }); } else { console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); @@ -779,6 +781,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId, provider: 'claude', mode: sessionMode || 'research', + projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined, }); } @@ -938,12 +941,23 @@ async function runClaudeBtw({ question, transcript, cwd, model, signal }) { } // Export public API +function rebindClaudeSDKSessionWriter(sessionId, newWriter) { + const session = getSession(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + export { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, + rebindClaudeSDKSessionWriter, resolveToolApproval, getContextWindowForModel, runClaudeBtw, diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 57ac1b94..7f36e816 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; import { resolveCursorCliCommand } from './utils/cursorCommand.js'; import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js'; +import { encodeProjectPath } from './projects.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -54,6 +55,7 @@ async function spawnCursor(command, options = {}, ws) { // Use cwd (actual project directory) instead of projectPath const workingDir = cwd || projectPath || process.cwd(); + const encodedProjectName = workingDir ? encodeProjectPath(workingDir) : undefined; const cursorCommand = resolveCursorCliCommand(); // Synchronous (better-sqlite3) — no await needed. @@ -156,6 +158,8 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'session-created', sessionId: capturedSessionId, + provider: 'cursor', + projectName: encodedProjectName, model: response.model, cwd: response.cwd, mode: sessionMode || 'research', @@ -271,7 +275,7 @@ async function spawnCursor(command, options = {}, ws) { // Clean up process reference const finalSessionId = capturedSessionId || sessionId || processKey; ws.send({ - type: 'claude-complete', + type: 'cursor-complete', sessionId: finalSessionId, exitCode: code, isNewSession: !sessionId && !!command // Flag to indicate this was a new session diff --git a/server/gemini-cli.js b/server/gemini-cli.js index e587fe1d..8e4f12de 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -711,7 +711,8 @@ export async function spawnGemini(command, options = {}, ws) { startTime: startTimeValue, options, sessionAllowedTools, - sessionDisallowedTools + sessionDisallowedTools, + writer: ws, }; const statusHeartbeat = setInterval(() => { @@ -931,7 +932,13 @@ export async function spawnGemini(command, options = {}, ws) { stageTagKeys, tagSource: stageTagSource, }); - ws.send({ type: 'session-created', sessionId: capturedSessionId, provider: 'gemini', mode: sessionMode || 'research' }); + ws.send({ + type: 'session-created', + sessionId: capturedSessionId, + provider: 'gemini', + mode: sessionMode || 'research', + projectName: workingDir ? encodeProjectPath(workingDir) : undefined, + }); } } break; @@ -1361,3 +1368,13 @@ export function getGeminiSessionStartTime(sessionId) { export function getActiveGeminiSessions() { return Array.from(activeGeminiSessions.keys()); } + +export function rebindGeminiSessionWriter(sessionId, newWriter) { + const session = activeGeminiSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} diff --git a/server/index.js b/server/index.js index 1f861604..a3822743 100755 --- a/server/index.js +++ b/server/index.js @@ -42,15 +42,21 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, encodeProjectPath, resolveCodexSessionFilePath } from './projects.js'; +import { + inferProviderFromMessageType as _inferProviderFromMessageType, + resolveProjectName as _resolveProjectName, + enrichSessionEventPayload as _enrichSessionEventPayload, + buildLifecycleMessageFromPayload as _buildLifecycleMessageFromPayload, +} from './utils/sessionLifecycle.js'; import { getProjectTokenUsageSummary } from './project-token-usage.js'; -import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; +import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, rebindClaudeSDKSessionWriter, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js'; -import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionStartTime, getActiveCodexSessions } from './openai-codex.js'; -import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions } from './gemini-cli.js'; -import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, getActiveOpenRouterSessions } from './openrouter.js'; -import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions } from './local-gpu.js'; -import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions } from './nano-claude-code.js'; +import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionStartTime, getActiveCodexSessions, rebindCodexSessionWriter } from './openai-codex.js'; +import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions, rebindGeminiSessionWriter } from './gemini-cli.js'; +import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, rebindOpenRouterSessionWriter } from './openrouter.js'; +import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions, rebindLocalGPUSessionWriter } from './local-gpu.js'; +import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions, rebindNanoClaudeCodeSessionWriter } from './nano-claude-code.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -72,7 +78,7 @@ import computeRoutes from './routes/compute.js'; import newsRoutes from './routes/news.js'; import autoResearchRoutes from './routes/auto-research.js'; import referencesRoutes from './routes/references.js'; -import { initializeDatabase, sessionDb, tagDb } from './database/db.js'; +import { initializeDatabase, projectDb, sessionDb, tagDb } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; import { enqueueTelemetryEvent } from './telemetry.js'; @@ -1361,6 +1367,73 @@ wss.on('connection', (ws, request) => { } }); +// --- Session lifecycle helpers (delegated to server/utils/sessionLifecycle.js) --- + +const DEBUG_SESSION_LIFECYCLE = process.env.DEBUG_SESSION_LIFECYCLE === '1'; +const warnedUnknownLifecycleProjectPaths = new Set(); + +function isKnownLifecycleProjectPath(projectPath) { + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return false; + } + + const normalizedPath = path.resolve(projectPath); + if (!fs.existsSync(normalizedPath)) { + return false; + } + + const encodedProjectName = encodeProjectPath(normalizedPath); + return Boolean( + projectDb.getProjectByPath(normalizedPath) || + projectDb.getProjectById(encodedProjectName), + ); +} + +function warnUnknownLifecycleProjectPath(projectPath) { + const normalizedPath = path.resolve(projectPath); + if (warnedUnknownLifecycleProjectPaths.has(normalizedPath)) { + return; + } + + warnedUnknownLifecycleProjectPaths.add(normalizedPath); + console.warn(`[WARN] Ignoring lifecycle projectPath that is not a known project: ${normalizedPath}`); +} + +/** Shared deps object that wires the extracted helpers to real DB + fs. */ +const _lifecycleDeps = { + isKnownPath(projectPath) { + const normalizedPath = path.resolve(projectPath); + if (!isKnownLifecycleProjectPath(normalizedPath)) { + warnUnknownLifecycleProjectPath(normalizedPath); + return false; + } + return true; + }, + encodePath(projectPath) { + const normalizedPath = path.resolve(projectPath); + try { + return encodeProjectPath(normalizedPath); + } catch (error) { + if (DEBUG_SESSION_LIFECYCLE) { + console.debug('[DEBUG] Failed to encode project path for lifecycle payload:', normalizedPath, error?.message || error); + } + throw error; + } + }, +}; + +function resolveProjectName(projectName = null, projectPath = null) { + return _resolveProjectName(projectName, projectPath, _lifecycleDeps); +} + +function enrichSessionEventPayload(payload, fallbackProjectPath = null) { + return _enrichSessionEventPayload(payload, fallbackProjectPath, _lifecycleDeps); +} + +function buildLifecycleMessageFromPayload(payload, fallbackProvider = null, fallbackProjectName = null) { + return _buildLifecycleMessageFromPayload(payload, fallbackProvider, fallbackProjectName, _lifecycleDeps); +} + /** * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ @@ -1375,12 +1448,25 @@ class WebSocketWriter { send(data) { if (this.ws.readyState === 1) { // WebSocket.OPEN - // Providers send raw objects, we stringify for WebSocket - this.ws.send(JSON.stringify(data)); - trackAgentResponseTelemetry(data, this.telemetryContext); + const outboundData = enrichSessionEventPayload(data, this.projectPath); + this.ws.send(JSON.stringify(outboundData)); + trackAgentResponseTelemetry(outboundData, this.telemetryContext); + + const lifecycle = buildLifecycleMessageFromPayload( + outboundData, + this.telemetryContext?.provider || null, + resolveProjectName(outboundData?.projectName, this.projectPath), + ); + if (lifecycle) { + this.ws.send(JSON.stringify(lifecycle)); + } } } + replaceSocket(newWs) { + this.ws = newWs; + } + setSessionId(sessionId) { this.sessionId = sessionId; } @@ -1522,10 +1608,87 @@ function handleChatConnection(ws, request) { // Wrap WebSocket with writer for consistent interface with SSEStreamWriter const writer = new WebSocketWriter(ws, telemetryContext); + const sendSessionStateChanged = ({ + provider, + sessionId = null, + state, + reason = null, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-state-changed', + provider, + sessionId, + state, + reason, + changedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + }; + + const sendSessionAccepted = ({ + provider, + sessionId = null, + requestType, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-accepted', + provider, + sessionId, + requestType, + acceptedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + // Keep these writes adjacent and synchronous so clients always see + // `session-accepted` before the corresponding `running` state transition. + sendSessionStateChanged({ + provider, + sessionId, + state: 'running', + reason: 'command-accepted', + projectPath, + projectName: resolvedProjectName, + }); + }; + + const sendSessionBusy = ({ + provider, + sessionId = null, + requestType, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-busy', + provider, + sessionId, + requestType, + projectPath, + isProcessing: true, + reason: 'session-already-active', + message: 'Session is already running. Wait for completion or stop it before sending another command.', + reportedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + sendSessionStateChanged({ + provider, + sessionId, + state: 'running', + reason: 'session-already-active', + projectPath, + projectName: resolvedProjectName, + }); + }; + ws.on('message', async (message) => { try { const data = JSON.parse(message); - console.log(`[DEBUG] Received WebSocket message: ${data.type}`); if (data.type === 'telemetry-settings') { const enabled = data.enabled !== false; @@ -1556,8 +1719,21 @@ function handleChatConnection(ws, request) { const sessionId = data.options?.sessionId || data.sessionId; if (sessionId && isClaudeSDKSessionActive(sessionId)) { console.log(`[WARN] Session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'claude', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } + + sendSessionAccepted({ + provider: 'claude', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryClaudeSDK(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Claude query error:', error); @@ -1572,6 +1748,12 @@ function handleChatConnection(ws, request) { if (sessionId && isCursorSessionActive(sessionId)) { console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1588,6 +1770,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'cursor', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnCursor(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Cursor spawn error:', error); }); @@ -1601,6 +1789,12 @@ function handleChatConnection(ws, request) { if (sessionId && isCodexSessionActive(sessionId)) { console.log(`[WARN] Codex session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'codex', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1617,6 +1811,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'codex', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'codex', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryCodex(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Codex query error:', error); }); @@ -1630,6 +1830,12 @@ function handleChatConnection(ws, request) { if (sessionId && isGeminiSessionActive(sessionId)) { console.log(`[WARN] Gemini session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'gemini', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1646,6 +1852,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'gemini', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'gemini', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnGemini(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Gemini spawn error:', error); }); @@ -1659,6 +1871,12 @@ function handleChatConnection(ws, request) { if (sessionId && isOpenRouterSessionActive(sessionId)) { console.log(`[WARN] OpenRouter session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'openrouter', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1675,6 +1893,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'openrouter', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'openrouter', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryOpenRouter(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] OpenRouter query error:', error); }); @@ -1689,6 +1913,12 @@ function handleChatConnection(ws, request) { if (sessionId && isLocalGPUSessionActive(sessionId)) { console.log(`[WARN] Local GPU session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'local', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1705,6 +1935,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'local', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'local', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryLocalGPU(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Local GPU query error:', error); }); @@ -1718,6 +1954,12 @@ function handleChatConnection(ws, request) { if (sessionId && isNanoClaudeCodeSessionActive(sessionId)) { console.log(`[WARN] Nano Claude Code session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'nano', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1734,6 +1976,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'nano', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'nano', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnNanoClaudeCode(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Nano Claude Code error:', error); }); @@ -1744,9 +1992,22 @@ function handleChatConnection(ws, request) { if (sessionId && isCursorSessionActive(sessionId)) { console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } - + + writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'cursor', + sessionId: data.sessionId || null, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnCursor('', { sessionId: data.sessionId, resume: true, @@ -1783,6 +2044,13 @@ function handleChatConnection(ws, request) { provider, success }); + sendSessionStateChanged({ + provider, + sessionId: data.sessionId, + state: success ? 'aborted' : 'running', + reason: success ? 'session-aborted' : 'abort-failed', + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); } else if (data.type === 'claude-permission-response') { // Relay UI approval decisions back into the SDK control flow. // This does not persist permissions; it only resolves the in-flight request, @@ -1804,6 +2072,13 @@ function handleChatConnection(ws, request) { provider: 'cursor', success }); + sendSessionStateChanged({ + provider: 'cursor', + sessionId: data.sessionId, + state: success ? 'aborted' : 'running', + reason: success ? 'session-aborted' : 'abort-failed', + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); } else if (data.type === 'check-session-status') { // Check if a specific session is currently processing const provider = data.provider || 'claude'; @@ -1835,6 +2110,29 @@ function handleChatConnection(ws, request) { startTime = getClaudeSDKSessionStartTime(sessionId); } + // If the session is still running, rebind its writer to the + // current WebSocket so that subsequent messages reach the + // reconnected client instead of the stale (closed) socket. + if (isActive && sessionId) { + let rebound = false; + if (provider === 'codex') { + rebound = rebindCodexSessionWriter(sessionId, writer); + } else if (provider === 'gemini') { + rebound = rebindGeminiSessionWriter(sessionId, writer); + } else if (provider === 'openrouter') { + rebound = rebindOpenRouterSessionWriter(sessionId, writer); + } else if (provider === 'local') { + rebound = rebindLocalGPUSessionWriter(sessionId, writer); + } else if (provider === 'nano') { + rebound = rebindNanoClaudeCodeSessionWriter(sessionId, writer); + } else if (provider !== 'cursor') { + rebound = rebindClaudeSDKSessionWriter(sessionId, writer); + } + if (rebound) { + console.log(`[INFO] Rebound ${provider} session ${sessionId} writer to new WebSocket`); + } + } + writer.send({ type: 'session-status', sessionId, @@ -2731,28 +3029,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica // Handle Codex sessions if (provider === 'codex') { - const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); - - // Find the session file by searching for the session ID - const findSessionFile = async (dir) => { - try { - const entries = await fsPromises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const found = await findSessionFile(fullPath); - if (found) return found; - } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { - return fullPath; - } - } - } catch (error) { - // Skip directories we can't read - } - return null; - }; - - const sessionFilePath = await findSessionFile(codexSessionsDir); + const sessionFilePath = await resolveCodexSessionFilePath(safeSessionId); if (!sessionFilePath) { return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); diff --git a/server/local-gpu.js b/server/local-gpu.js index a94537a3..47ecc1a0 100644 --- a/server/local-gpu.js +++ b/server/local-gpu.js @@ -737,6 +737,7 @@ export async function queryLocalGPU(command, options = {}, ws) { status: 'running', abortController, startTime: Date.now(), + writer: ws, }); const userText = (command || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim(); @@ -992,6 +993,16 @@ export function getActiveLocalGPUSessions() { .map(([id, s]) => ({ sessionId: id, startTime: s.startTime })); } +export function rebindLocalGPUSessionWriter(sessionId, newWriter) { + const session = activeLocalGPUSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + setInterval(() => { const now = Date.now(); for (const [id, session] of activeLocalGPUSessions.entries()) { diff --git a/server/nano-claude-code.js b/server/nano-claude-code.js index 86d51238..d1f51ece 100644 --- a/server/nano-claude-code.js +++ b/server/nano-claude-code.js @@ -174,7 +174,7 @@ export async function spawnNanoClaudeCode(command, options = {}, ws) { env: { ...(env || process.env) }, }); - activeNanoSessions.set(capturedSessionId, { process: child, startTime: Date.now() }); + activeNanoSessions.set(capturedSessionId, { process: child, startTime: Date.now(), writer: ws }); const getSessionStartTime = () => activeNanoSessions.get(capturedSessionId)?.startTime; @@ -325,6 +325,16 @@ export function getActiveNanoClaudeCodeSessions() { return Array.from(activeNanoSessions.keys()); } +export function rebindNanoClaudeCodeSessionWriter(sessionId, newWriter) { + const sessionData = activeNanoSessions.get(sessionId); + if (!sessionData || !sessionData.writer) return false; + if (typeof sessionData.writer.replaceSocket === 'function') { + sessionData.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + /** Kill all in-flight Nano CLI children (e.g. before process exit). */ export function killAllNanoClaudeCodeChildren() { for (const { process: childProc } of activeNanoSessions.values()) { diff --git a/server/openai-codex.js b/server/openai-codex.js index 1fa818c8..3a2a477b 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -24,6 +24,7 @@ import { classifyError, classifySDKError } from '../shared/errorClassifier.js'; import { buildTempAttachmentFilename } from './utils/imageAttachmentFiles.js'; import { buildCodexRealtimeTokenBudget } from './utils/sessionTokenUsage.js'; import { expandSkillCommand } from './utils/skillExpander.js'; +import { buildCodexSessionCreatedEvent } from './utils/codexSessionEvents.js'; import { CODEX_MODELS } from '../shared/modelConstants.js'; import { BTW_SYSTEM_PROMPT, buildBtwUserMessage } from './utils/btw.js'; @@ -414,7 +415,8 @@ export async function queryCodex(command, options = {}, ws) { codex, status: 'running', abortController, - startTime: Date.now() + startTime: Date.now(), + writer: ws, }); const publishSessionId = (resolvedSessionId) => { @@ -440,12 +442,11 @@ export async function queryCodex(command, options = {}, ws) { }); } - sendMessage(ws, { - type: 'session-created', + sendMessage(ws, buildCodexSessionCreatedEvent({ sessionId: currentSessionId, - provider: 'codex', - mode: sessionMode || 'research' - }); + sessionMode: sessionMode || 'research', + projectName: workingDirectory ? encodeProjectPath(workingDirectory) : null, + })); }; publishSessionId(thread.id || sessionId || null); @@ -696,6 +697,16 @@ export function getActiveCodexSessions() { return sessions; } +export function rebindCodexSessionWriter(sessionId, newWriter) { + const session = activeCodexSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + /** * Helper to send message via WebSocket or writer * @param {WebSocket|object} ws - WebSocket or response writer diff --git a/server/openrouter.js b/server/openrouter.js index 555c7823..b1b036e2 100644 --- a/server/openrouter.js +++ b/server/openrouter.js @@ -627,6 +627,7 @@ export async function queryOpenRouter(command, options = {}, ws) { status: 'running', abortController, startTime: Date.now(), + writer: ws, }); // Strip [Context: ...] prefixes to extract the user's actual text for the display name @@ -894,6 +895,16 @@ export function getActiveOpenRouterSessions() { .map(([id, s]) => ({ sessionId: id, startTime: s.startTime })); } +export function rebindOpenRouterSessionWriter(sessionId, newWriter) { + const session = activeOpenRouterSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + // Periodic cleanup (mirrors Codex pattern) setInterval(() => { const now = Date.now(); diff --git a/server/utils/codexSessionEvents.js b/server/utils/codexSessionEvents.js new file mode 100644 index 00000000..0fe4a816 --- /dev/null +++ b/server/utils/codexSessionEvents.js @@ -0,0 +1,13 @@ +export function buildCodexSessionCreatedEvent({ + sessionId, + sessionMode = 'research', + projectName = null, +}) { + return { + type: 'session-created', + sessionId, + provider: 'codex', + mode: sessionMode || 'research', + ...(projectName ? { projectName } : {}), + }; +} diff --git a/server/utils/sessionLifecycle.js b/server/utils/sessionLifecycle.js new file mode 100644 index 00000000..069bd7b9 --- /dev/null +++ b/server/utils/sessionLifecycle.js @@ -0,0 +1,148 @@ +/** + * Session lifecycle helpers — extracted from server/index.js for testability. + * + * These pure-ish functions handle: + * - provider inference from message type prefixes + * - project-name resolution (with optional filesystem + DB validation) + * - session-event payload enrichment + * - lifecycle message construction from completion/error payloads + */ + +/** + * Infer the canonical provider name from a message-type prefix. + * Falls back to `fallbackProvider` when no prefix matches. + */ +export function inferProviderFromMessageType(type, fallbackProvider = null) { + const messageType = String(type || ''); + if (messageType.startsWith('claude-')) return 'claude'; + if (messageType.startsWith('cursor-')) return 'cursor'; + if (messageType.startsWith('codex-')) return 'codex'; + if (messageType.startsWith('gemini-')) return 'gemini'; + if (messageType.startsWith('openrouter-')) return 'openrouter'; + if (messageType.startsWith('localgpu-')) return 'local'; + if (messageType.startsWith('nano-')) return 'nano'; + return fallbackProvider || null; +} + +/** + * Resolve a human-readable project name from either an explicit name or a + * filesystem path. When `isKnownPath` and `encodePath` callbacks are + * provided the function validates the path before encoding; otherwise it + * performs a simple passthrough (useful in unit tests that don't need a + * real filesystem). + * + * @param {string|null} projectName - explicit project name (returned as-is when non-empty) + * @param {string|null} projectPath - filesystem path to resolve from + * @param {object} [deps] - optional dependency overrides for testing + * @param {function} [deps.isKnownPath] - (path) => boolean + * @param {function} [deps.encodePath] - (path) => string + */ +export function resolveProjectName( + projectName = null, + projectPath = null, + deps = {}, +) { + if (typeof projectName === 'string' && projectName.trim().length > 0) { + return projectName; + } + + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return null; + } + + const { isKnownPath, encodePath } = deps; + + // When no validators are injected we cannot resolve from path alone. + if (typeof isKnownPath !== 'function' || typeof encodePath !== 'function') { + return null; + } + + if (!isKnownPath(projectPath)) { + return null; + } + + try { + return encodePath(projectPath); + } catch { + return null; + } +} + +/** + * Enrich a session-event payload with a resolved `projectName` when the + * original payload is missing one. + */ +export function enrichSessionEventPayload(payload, fallbackProjectPath = null, deps = {}) { + if (!payload || typeof payload !== 'object') { + return payload; + } + + const messageType = String(payload.type || ''); + if (!messageType.startsWith('session-')) { + return payload; + } + + const resolvedProjectName = resolveProjectName( + payload.projectName, + payload.projectPath || fallbackProjectPath || null, + deps, + ); + if (!resolvedProjectName || payload.projectName === resolvedProjectName) { + return payload; + } + + return { + ...payload, + projectName: resolvedProjectName, + }; +} + +/** + * Build a normalised `session-state-changed` lifecycle message from a + * provider completion or error payload. + * + * Returns `null` when the payload does not represent a terminal state. + */ +export function buildLifecycleMessageFromPayload( + payload, + fallbackProvider = null, + fallbackProjectName = null, + deps = {}, +) { + if (!payload || typeof payload !== 'object') { + return null; + } + + const messageType = String(payload.type || ''); + let state = null; + + if (messageType === 'cursor-result' || messageType.endsWith('-complete')) { + state = 'completed'; + } else if (messageType.endsWith('-error')) { + state = 'failed'; + } + + if (!state) { + return null; + } + + const provider = inferProviderFromMessageType( + messageType, + typeof payload.provider === 'string' ? payload.provider : fallbackProvider, + ); + const projectName = resolveProjectName( + payload.projectName || fallbackProjectName || null, + payload.projectPath || null, + deps, + ); + + return { + type: 'session-state-changed', + provider, + sessionId: payload.actualSessionId || payload.sessionId || null, + state, + reason: messageType, + changedAt: Date.now(), + ...(projectName ? { projectName } : {}), + }; +}