diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 3ba6ea24..cdf41ff6 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -34,7 +34,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; @@ -78,9 +78,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); }); } @@ -209,13 +214,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 }); } @@ -512,6 +518,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', @@ -562,7 +574,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 @@ -572,7 +584,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') { @@ -712,11 +724,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 }; diff --git a/server/index.js b/server/index.js index 07e635db..8f25fc29 100755 --- a/server/index.js +++ b/server/index.js @@ -45,7 +45,7 @@ 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 { 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'; @@ -1380,6 +1380,10 @@ class WebSocketWriter { } } + updateWebSocket(newRawWs) { + this.ws = newRawWs; + } + setSessionId(sessionId) { this.sessionId = sessionId; } @@ -1494,6 +1498,11 @@ function handleChatConnection(ws) { } 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({ @@ -1502,6 +1511,17 @@ function handleChatConnection(ws) { 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 = { diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index ab436aa3..1168919a 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,8 @@ export default function AppContent() { const { sessionId } = useParams<{ sessionId?: string }>(); const { t } = useTranslation('common'); const { isMobile } = useDeviceSettings({ trackPWA: false }); - const { ws, sendMessage, latestMessage } = useWebSocket(); + const { ws, sendMessage, latestMessage, isConnected } = useWebSocket(); + const wasConnectedRef = useRef(false); const { activeSessions, @@ -71,6 +72,24 @@ export default function AppContent() { }; }, [openSettings]); + // Permission recovery: query pending permissions on WebSocket reconnect or session change + useEffect(() => { + const isReconnect = isConnected && !wasConnectedRef.current; + + if (isReconnect) { + wasConnectedRef.current = true; + } else if (!isConnected) { + wasConnectedRef.current = false; + } + + if (isConnected && selectedSession?.id) { + sendMessage({ + type: 'get-pending-permissions', + sessionId: selectedSession.id + }); + } + }, [isConnected, selectedSession?.id, sendMessage]); + return (
{!isMobile ? ( diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index d563261a..84984a33 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1080,6 +1080,19 @@ export function useChatRealtimeHandlers({ break; } + case 'pending-permissions-response': { + // Server returned pending permissions for this session + const permSessionId = latestMessage.sessionId; + const isCurrentPermSession = + permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id); + if (permSessionId && !isCurrentPermSession) { + break; + } + const serverRequests = latestMessage.data || []; + setPendingPermissionRequests(serverRequests); + break; + } + default: break; }