+ Permission saved. Retry the request to use the tool.
+
+ )}
+
+ )}
);
@@ -4099,6 +4255,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
+ const handleGrantToolPermission = useCallback((suggestion) => {
+ if (!suggestion || provider !== 'claude') {
+ return { success: false };
+ }
+ return grantClaudeToolPermission(suggestion.entry);
+ }, [provider]);
+
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
handleSubmitRef.current = handleSubmit;
@@ -4675,10 +4838,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
+ onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
+ provider={provider}
/>
);
})}
From ef449427677aab1e6c85ebc2d4dd1ed2aad98ca4 Mon Sep 17 00:00:00 2001
From: Haileyesus Dessie <118998054+blackmammoth@users.noreply.github.com>
Date: Wed, 7 Jan 2026 22:31:17 +0300
Subject: [PATCH 2/6] feat: add Bash command approval handling in Claude tool
permissions
---
src/components/ChatInterface.jsx | 30 ++++++++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx
index 37943c6f..7314a74f 100644
--- a/src/components/ChatInterface.jsx
+++ b/src/components/ChatInterface.jsx
@@ -238,6 +238,7 @@ const safeLocalStorage = {
const CLAUDE_SETTINGS_KEY = 'claude-settings';
const TOOL_PERMISSION_ERROR_REGEX = /requested permissions? to use\s+([^.,\n]+)/i;
+const BASH_APPROVAL_REGEX = /requires approval:\s*([^\n]+)/i;
function safeJsonParse(value) {
if (!value || typeof value !== 'string') return null;
@@ -296,17 +297,38 @@ function buildClaudeToolPermissionEntry(toolName, toolInput) {
return `Bash(${tokens[0]}:*)`;
}
+function getBashCommandLabel(command) {
+ if (!command || typeof command !== 'string') return 'Bash';
+ const tokens = command.trim().split(/\s+/);
+ if (tokens.length === 0) return 'Bash';
+ if (tokens[0] === 'git' && tokens[1]) {
+ return `git ${tokens[1]}`;
+ }
+ return tokens[0];
+}
+
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
const content = String(message.toolResult.content || '');
- if (!TOOL_PERMISSION_ERROR_REGEX.test(content)) return null;
+ let toolName = null;
+ let entry = null;
const match = content.match(TOOL_PERMISSION_ERROR_REGEX);
- const requestedTool = match?.[1]?.trim();
- const toolName = requestedTool || message.toolName;
- const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
+ if (match) {
+ const requestedTool = match?.[1]?.trim();
+ toolName = requestedTool || message.toolName;
+ entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
+ } else if (message.toolName === 'Bash') {
+ const approvalMatch = content.match(BASH_APPROVAL_REGEX);
+ const command = approvalMatch?.[1]?.trim();
+ if (command) {
+ entry = buildClaudeToolPermissionEntry('Bash', JSON.stringify({ command }));
+ toolName = getBashCommandLabel(command);
+ }
+ }
+
if (!entry) return null;
const settings = getClaudeSettings();
From 3f66179e72852dc4c259f3148034cec8c5023be0 Mon Sep 17 00:00:00 2001
From: Haileyesus Dessie <118998054+blackmammoth@users.noreply.github.com>
Date: Thu, 8 Jan 2026 12:21:28 +0300
Subject: [PATCH 3/6] fix: remove regex for tool permission extraction
---
src/components/ChatInterface.jsx | 33 +++-----------------------------
1 file changed, 3 insertions(+), 30 deletions(-)
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx
index 7314a74f..8692d016 100644
--- a/src/components/ChatInterface.jsx
+++ b/src/components/ChatInterface.jsx
@@ -237,8 +237,6 @@ const safeLocalStorage = {
};
const CLAUDE_SETTINGS_KEY = 'claude-settings';
-const TOOL_PERMISSION_ERROR_REGEX = /requested permissions? to use\s+([^.,\n]+)/i;
-const BASH_APPROVAL_REGEX = /requires approval:\s*([^\n]+)/i;
function safeJsonParse(value) {
if (!value || typeof value !== 'string') return null;
@@ -297,43 +295,18 @@ function buildClaudeToolPermissionEntry(toolName, toolInput) {
return `Bash(${tokens[0]}:*)`;
}
-function getBashCommandLabel(command) {
- if (!command || typeof command !== 'string') return 'Bash';
- const tokens = command.trim().split(/\s+/);
- if (tokens.length === 0) return 'Bash';
- if (tokens[0] === 'git' && tokens[1]) {
- return `git ${tokens[1]}`;
- }
- return tokens[0];
-}
-
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
- const content = String(message.toolResult.content || '');
- let toolName = null;
- let entry = null;
-
- const match = content.match(TOOL_PERMISSION_ERROR_REGEX);
- if (match) {
- const requestedTool = match?.[1]?.trim();
- toolName = requestedTool || message.toolName;
- entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
- } else if (message.toolName === 'Bash') {
- const approvalMatch = content.match(BASH_APPROVAL_REGEX);
- const command = approvalMatch?.[1]?.trim();
- if (command) {
- entry = buildClaudeToolPermissionEntry('Bash', JSON.stringify({ command }));
- toolName = getBashCommandLabel(command);
- }
- }
+ const toolName = message?.toolName;
+ const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
- return { toolName: toolName || entry, entry, isAllowed };
+ return { toolName, entry, isAllowed };
}
function grantClaudeToolPermission(entry) {
From 64ebbaf387964bd81714e415cb802acc5e6d89d0 Mon Sep 17 00:00:00 2001
From: Haileyesus Dessie <118998054+blackmammoth@users.noreply.github.com>
Date: Fri, 9 Jan 2026 19:08:59 +0300
Subject: [PATCH 4/6] feat: setup canUseTool for claude messages
---
server/claude-sdk.js | 230 ++++++++++++++++++++++++++++---
server/index.js | 14 +-
src/components/ChatInterface.jsx | 225 ++++++++++++++++++++++++++++++
src/lib/utils.js | 5 +-
4 files changed, 452 insertions(+), 22 deletions(-)
diff --git a/server/claude-sdk.js b/server/claude-sdk.js
index 1abf7089..5ebcd0de 100644
--- a/server/claude-sdk.js
+++ b/server/claude-sdk.js
@@ -13,6 +13,9 @@
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
+// Used to mint unique approval request IDs when randomUUID is not available.
+// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
+import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -20,6 +23,123 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
+// In-memory registry of pending tool approvals keyed by requestId.
+// This does not persist approvals or share across processes; it exists so the
+// SDK can pause tool execution while the UI decides what to do.
+const pendingToolApprovals = new Map();
+
+// Default approval timeout kept under the SDK's 60s control timeout.
+// This does not change SDK limits; it only defines how long we wait for the UI,
+// introduced to avoid hanging the run when no decision arrives.
+const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
+
+// Generate a stable request ID for UI approval flows.
+// This does not encode tool details or get shown to users; it exists so the UI
+// can respond to the correct pending request without collisions.
+function createRequestId() {
+ if (typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ return crypto.randomBytes(16).toString('hex');
+}
+
+// Wait for a UI approval decision, honoring SDK cancellation.
+// This does not auto-approve or auto-deny; it only resolves with UI input,
+// and it cleans up the pending map to avoid leaks, introduced to prevent
+// replying after the SDK cancels the control request.
+function waitForToolApproval(requestId, options = {}) {
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
+
+ return new Promise(resolve => {
+ let settled = false;
+
+ const finalize = (decision) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(decision);
+ };
+
+ const cleanup = () => {
+ pendingToolApprovals.delete(requestId);
+ clearTimeout(timeout);
+ if (signal && abortHandler) {
+ signal.removeEventListener('abort', abortHandler);
+ }
+ };
+
+ // Timeout is local to this process; it does not override SDK timing.
+ // It exists to prevent the UI prompt from lingering indefinitely.
+ const timeout = setTimeout(() => {
+ onCancel?.('timeout');
+ finalize(null);
+ }, timeoutMs);
+
+ const abortHandler = () => {
+ // If the SDK cancels the control request, stop waiting to avoid
+ // replying after the process is no longer ready for writes.
+ onCancel?.('cancelled');
+ finalize({ cancelled: true });
+ };
+
+ if (signal) {
+ if (signal.aborted) {
+ onCancel?.('cancelled');
+ finalize({ cancelled: true });
+ return;
+ }
+ signal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ pendingToolApprovals.set(requestId, (decision) => {
+ finalize(decision);
+ });
+ });
+}
+
+// Resolve a pending approval. This does not validate the decision payload;
+// validation and tool matching remain in canUseTool, which keeps this as a
+// lightweight WebSocket -> SDK relay.
+function resolveToolApproval(requestId, decision) {
+ const resolver = pendingToolApprovals.get(requestId);
+ if (resolver) {
+ resolver(decision);
+ }
+}
+
+// Match stored permission entries against a tool + input combo.
+// This only supports exact tool names and the Bash(command:*) shorthand
+// used by the UI; it intentionally does not implement full glob semantics,
+// introduced to stay consistent with the UI's "Allow rule" format.
+function matchesToolPermission(entry, toolName, input) {
+ if (!entry || !toolName) {
+ return false;
+ }
+
+ if (entry === toolName) {
+ return true;
+ }
+
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
+ if (toolName === 'Bash' && bashMatch) {
+ const allowedPrefix = bashMatch[1];
+ let command = '';
+
+ if (typeof input === 'string') {
+ command = input.trim();
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
+ command = input.command.trim();
+ }
+
+ if (!command) {
+ return false;
+ }
+
+ return command.startsWith(allowedPrefix);
+ }
+
+ return false;
+}
/**
* Maps CLI options to SDK-compatible options format
@@ -52,30 +172,29 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
- } else {
- // Map allowed tools
- let allowedTools = [...(settings.allowedTools || [])];
+ }
- // Add plan mode default tools
- if (permissionMode === 'plan') {
- const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
- for (const tool of planModeTools) {
- if (!allowedTools.includes(tool)) {
- allowedTools.push(tool);
- }
+ // Map allowed tools (always set to avoid implicit "allow all" defaults).
+ // This does not grant permissions by itself; it just configures the SDK,
+ // introduced because leaving it undefined made the SDK treat it as "all tools allowed."
+ let allowedTools = [...(settings.allowedTools || [])];
+
+ // Add plan mode default tools
+ if (permissionMode === 'plan') {
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
+ for (const tool of planModeTools) {
+ if (!allowedTools.includes(tool)) {
+ allowedTools.push(tool);
}
}
-
- if (allowedTools.length > 0) {
- sdkOptions.allowedTools = allowedTools;
- }
-
- // Map disallowed tools
- if (settings.disallowedTools && settings.disallowedTools.length > 0) {
- sdkOptions.disallowedTools = settings.disallowedTools;
- }
}
+ sdkOptions.allowedTools = allowedTools;
+
+ // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
+ // This does not override allowlists; it only feeds the canUseTool gate.
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
+
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
@@ -370,6 +489,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
+ // Gate tool usage with explicit UI approval when not auto-approved.
+ // This does not render UI or persist permissions; it only bridges to the UI
+ // via WebSocket and waits for the response, introduced so tool calls pause
+ // instead of auto-running when the allowlist is empty.
+ sdkOptions.canUseTool = async (toolName, input, context) => {
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
+ return { behavior: 'allow', updatedInput: input };
+ }
+
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
+ matchesToolPermission(entry, toolName, input)
+ );
+ if (isDisallowed) {
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
+ }
+
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
+ matchesToolPermission(entry, toolName, input)
+ );
+ if (isAllowed) {
+ return { behavior: 'allow', updatedInput: input };
+ }
+
+ const requestId = createRequestId();
+ ws.send({
+ type: 'claude-permission-request',
+ requestId,
+ toolName,
+ input,
+ sessionId: capturedSessionId || sessionId || null
+ });
+
+ // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
+ // This does not retry or resurface the prompt; it just reflects the cancellation.
+ const decision = await waitForToolApproval(requestId, {
+ signal: context?.signal,
+ onCancel: (reason) => {
+ ws.send({
+ type: 'claude-permission-cancelled',
+ requestId,
+ reason,
+ sessionId: capturedSessionId || sessionId || null
+ });
+ }
+ });
+ if (!decision) {
+ return { behavior: 'deny', message: 'Permission request timed out' };
+ }
+
+ if (decision.cancelled) {
+ return { behavior: 'deny', message: 'Permission request cancelled' };
+ }
+
+ if (decision.allow) {
+ // rememberEntry only updates this run's in-memory allowlist to prevent
+ // repeated prompts in the same session; persistence is handled by the UI.
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
+ sdkOptions.allowedTools.push(decision.rememberEntry);
+ }
+ if (Array.isArray(sdkOptions.disallowedTools)) {
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
+ }
+ }
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
+ }
+
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
+ };
+
// Create SDK query instance
const queryInstance = query({
prompt: finalCommand,
@@ -526,5 +715,6 @@ export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
- getActiveClaudeSDKSessions
+ getActiveClaudeSDKSessions,
+ resolveToolApproval
};
diff --git a/server/index.js b/server/index.js
index d6b46e51..a638d0d6 100755
--- a/server/index.js
+++ b/server/index.js
@@ -58,7 +58,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 } from './claude-sdk.js';
+import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import gitRoutes from './routes/git.js';
@@ -804,6 +804,18 @@ function handleChatConnection(ws) {
provider,
success
});
+ } 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,
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
+ if (data.requestId) {
+ resolveToolApproval(data.requestId, {
+ allow: Boolean(data.allow),
+ updatedInput: data.updatedInput,
+ message: data.message,
+ rememberEntry: data.rememberEntry
+ });
+ }
} else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId);
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx
index 0d97fb97..93203851 100644
--- a/src/components/ChatInterface.jsx
+++ b/src/components/ChatInterface.jsx
@@ -295,6 +295,19 @@ function buildClaudeToolPermissionEntry(toolName, toolInput) {
return `Bash(${tokens[0]}:*)`;
}
+// Normalize tool inputs for display in the permission banner.
+// This does not sanitize/redact secrets; it is strictly formatting so users
+// can see the raw input that triggered the permission prompt.
+function formatToolInputForDisplay(input) {
+ if (input === undefined || input === null) return '';
+ if (typeof input === 'string') return input;
+ try {
+ return JSON.stringify(input, null, 2);
+ } catch {
+ return String(input);
+ }
+}
+
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
@@ -1839,6 +1852,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const MESSAGES_PER_PAGE = 20;
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default');
+ // In-memory queue of tool permission prompts for the current UI view.
+ // These are not persisted and do not survive a page refresh; introduced so
+ // the UI can present pending approvals while the SDK waits.
+ const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map());
@@ -1886,6 +1903,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
+ // Track provider transitions so we only clear approvals when provider truly changes.
+ // This does not sync with the backend; it just prevents UI prompts from disappearing.
+ const lastProviderRef = useRef(provider);
// Load permission mode for the current session
useEffect(() => {
if (selectedSession?.id) {
@@ -1905,6 +1925,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
localStorage.setItem('selected-provider', selectedSession.__provider);
}
}, [selectedSession]);
+
+ // Clear pending permission prompts when switching providers; filter when switching sessions.
+ // This does not preserve prompts across provider changes; it exists to keep the
+ // Claude approval flow intact while preventing prompts from a different provider.
+ useEffect(() => {
+ if (lastProviderRef.current !== provider) {
+ setPendingPermissionRequests([]);
+ lastProviderRef.current = provider;
+ }
+ }, [provider]);
+
+ // When the selected session changes, drop prompts that belong to other sessions.
+ // This does not attempt to migrate prompts across sessions; it only filters,
+ // introduced so the UI does not show approvals for a session the user is no longer viewing.
+ useEffect(() => {
+ setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id));
+ }, [selectedSession?.id]);
// Load Cursor default model from config
useEffect(() => {
@@ -3165,6 +3202,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (onReplaceTemporarySession) {
onReplaceTemporarySession(latestMessage.sessionId);
}
+
+ // Attach the real session ID to any pending permission requests so they
+ // do not disappear during the "new-session -> real-session" transition.
+ // This does not create or auto-approve requests; it only keeps UI state aligned.
+ setPendingPermissionRequests(prev => prev.map(req => (
+ req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId }
+ )));
}
break;
@@ -3395,6 +3439,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]);
break;
+ case 'claude-permission-request': {
+ // Receive a tool approval request from the backend and surface it in the UI.
+ // This does not approve anything automatically; it only queues a prompt,
+ // introduced so the user can decide before the SDK continues.
+ if (provider !== 'claude' || !latestMessage.requestId) {
+ break;
+ }
+
+ setPendingPermissionRequests(prev => {
+ if (prev.some(req => req.requestId === latestMessage.requestId)) {
+ return prev;
+ }
+ return [
+ ...prev,
+ {
+ requestId: latestMessage.requestId,
+ toolName: latestMessage.toolName || 'UnknownTool',
+ input: latestMessage.input,
+ context: latestMessage.context,
+ sessionId: latestMessage.sessionId || null,
+ receivedAt: new Date()
+ }
+ ];
+ });
+
+ // Keep the session in a "waiting" state while approval is pending.
+ // This does not resume the run; it only updates the UI status so the
+ // user knows Claude is blocked on a decision.
+ setIsLoading(true);
+ setCanAbortSession(true);
+ setClaudeStatus({
+ text: 'Waiting for permission',
+ tokens: 0,
+ can_interrupt: true
+ });
+ break;
+ }
+
+ case 'claude-permission-cancelled': {
+ // Backend cancelled the approval (timeout or SDK cancel); remove the banner.
+ // We currently do not show a user-facing warning here; this is intentional
+ // to avoid noisy alerts when the SDK cancels in the background.
+ if (!latestMessage.requestId) {
+ break;
+ }
+ setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId));
+ break;
+ }
+
case 'claude-error':
setChatMessages(prev => [...prev, {
type: 'error',
@@ -3591,6 +3684,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
+ // Conversation finished; clear any stale permission prompts.
+ // This does not remove saved permissions; it only resets transient UI state.
+ setPendingPermissionRequests([]);
break;
case 'codex-response':
@@ -3766,6 +3862,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
+ // Abort ends the run; clear permission prompts to avoid dangling UI state.
+ // This does not change allowlists; it only clears the current banner.
+ setPendingPermissionRequests([]);
+
setChatMessages(prev => [...prev, {
type: 'assistant',
content: 'Session interrupted by user.',
@@ -4291,6 +4391,36 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return grantClaudeToolPermission(suggestion.entry);
}, [provider]);
+ // Send a UI decision back to the server (single or batched request IDs).
+ // This does not validate tool inputs or permissions; the backend enforces rules.
+ // It exists so "Allow & remember" can resolve multiple queued prompts at once.
+ const handlePermissionDecision = useCallback((requestIds, decision) => {
+ const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
+ const validIds = ids.filter(Boolean);
+ if (validIds.length === 0) {
+ return;
+ }
+
+ validIds.forEach((requestId) => {
+ sendMessage({
+ type: 'claude-permission-response',
+ requestId,
+ allow: Boolean(decision?.allow),
+ updatedInput: decision?.updatedInput,
+ message: decision?.message,
+ rememberEntry: decision?.rememberEntry
+ });
+ });
+
+ setPendingPermissionRequests(prev => {
+ const next = prev.filter(req => !validIds.includes(req.requestId));
+ if (next.length === 0) {
+ setClaudeStatus(null);
+ }
+ return next;
+ });
+ }, [sendMessage]);
+
// Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => {
handleSubmitRef.current = handleSubmit;
@@ -4929,6 +5059,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
+ {pendingPermissionRequests.length > 0 && (
+ // Permission banner for tool approvals. This renders the input, allows
+ // "allow once" or "allow & remember", and supports batching similar requests.
+ // It does not persist permissions by itself; persistence is handled by
+ // the existing localStorage-based settings helpers, introduced to surface
+ // approvals before tool execution resumes.
+
+ {pendingPermissionRequests.map((request) => {
+ const rawInput = formatToolInputForDisplay(request.input);
+ const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
+ const settings = getClaudeSettings();
+ const alreadyAllowed = permissionEntry
+ ? settings.allowedTools.includes(permissionEntry)
+ : false;
+ const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
+ // Group pending prompts that resolve to the same allow rule so
+ // a single "Allow & remember" can clear them in one click.
+ // This does not attempt fuzzy matching; it only batches identical rules.
+ const matchingRequestIds = permissionEntry
+ ? pendingPermissionRequests
+ .filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry)
+ .map(item => item.requestId)
+ : [request.requestId];
+
+ return (
+