fix: preserve pending permission requests across WebSocket reconnections (#462)

* fix: preserve pending permission requests across WebSocket reconnections

- Store WebSocketWriter reference in active sessions for reconnection
- Add reconnectSessionWriter() to swap writer when client reconnects
- Add getPendingApprovalsForSession() to query pending permissions by session
- Add get-pending-permissions WebSocket message handler on server
- Add pending-permissions-response handler on frontend
- Query pending permissions on WebSocket reconnect and session change
- Reconnect SDK output writer when client resumes an active session

* fix: address CodeRabbit review feedback for websocket-permission PR

- Use consistent session matching in pending-permissions-response handler,
  checking both currentSessionId and selectedSession.id (matching the
  session-status handler pattern)
- Guard get-pending-permissions with isClaudeSDKSessionActive check to
  prevent returning permissions for inactive sessions
This commit is contained in:
shikihane
2026-03-04 01:31:27 +08:00
committed by GitHub
parent 688d73477a
commit 4ee88f0eb0
4 changed files with 114 additions and 11 deletions

View File

@@ -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<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
});
}
@@ -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
};

View File

@@ -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 = {

View File

@@ -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 (
<div className="fixed inset-0 flex bg-background">
{!isMobile ? (

View File

@@ -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;
}