From ba2aad5c09465c199cd6c98936a9ff2cbbda1e65 Mon Sep 17 00:00:00 2001 From: yhangf Date: Fri, 8 May 2026 17:57:15 +0800 Subject: [PATCH] feat(permissions): auto-select AskUserQuestion answers in headless mode - Add headless fallback in `hasPermissionsToUseToolInner` that respects `askUserQuestionTimeoutSeconds` from settings (default 600s) when `shouldAvoidPermissionPrompts` is true, instead of hanging indefinitely - Set `shouldAvoidPermissionPrompts: true` on `toolPermissionContext` in `-p` (pipe/headless) mode so the new path is triggered - Hoist `parsedInput` before the try block so the headless path can use Zod-transformed input (includes defaults like `multiSelect: false`) --- src/main.tsx | 10 +++- src/utils/permissions/permissions.ts | 85 +++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index bc3cb088e..d8bcf23e5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3975,7 +3975,15 @@ async function run(): Promise { commands: mcpCommands, tools: mcpTools, }, - toolPermissionContext, + // In headless (-p) mode the TUI is never mounted, so + // AskUserQuestionPermissionRequest's useEffect/setTimeout never fires. + // Mark shouldAvoidPermissionPrompts so permissions.ts step 1e-headless + // picks up askUserQuestionTimeoutSeconds from settings and applies the + // configured auto-select timeout instead of hanging indefinitely. + toolPermissionContext: { + ...toolPermissionContext, + shouldAvoidPermissionPrompts: true, + }, effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), diff --git a/src/utils/permissions/permissions.ts b/src/utils/permissions/permissions.ts index 435f13e65..6d7b5596a 100644 --- a/src/utils/permissions/permissions.ts +++ b/src/utils/permissions/permissions.ts @@ -7,6 +7,7 @@ import { } from '../../services/mcp/mcpStringUtils.js' import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js' import { AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js' +import { ASK_USER_QUESTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/prompt.js' import { shouldUseSandbox } from '@claude-code-best/builtin-tools/tools/BashTool/shouldUseSandbox.js' import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js' import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js' @@ -90,6 +91,7 @@ import { } from '../messages.js' import { calculateCostFromTokens } from '../modelCost.js' /* eslint-enable @typescript-eslint/no-require-imports */ +import { getInitialSettings } from '../settings/settings.js' import { jsonStringify } from '../slowOperations.js' import { createDenialTrackingState, @@ -1233,8 +1235,12 @@ async function hasPermissionsToUseToolInner( behavior: 'passthrough', message: createPermissionRequestMessage(tool.name), } + // parsedInput is hoisted so the 1e-headless path can build updatedInput from + // the Zod-transformed value (includes defaults like multiSelect: false) rather + // than the raw input. + let parsedInput: { [key: string]: unknown } = input try { - const parsedInput = tool.inputSchema.parse(input) + parsedInput = tool.inputSchema.parse(input) toolPermissionResult = await tool.checkPermissions(parsedInput, context) } catch (e) { // Rethrow abort errors so they propagate properly @@ -1248,12 +1254,87 @@ async function hasPermissionsToUseToolInner( if (toolPermissionResult?.behavior === 'deny') { return toolPermissionResult } - // 1e. Tool requires user interaction even in bypass mode if ( tool.requiresUserInteraction?.() && toolPermissionResult?.behavior === 'ask' ) { + // 1e-headless. In headless mode (e.g. -p pipe mode, sub-agents), the TUI component + // AskUserQuestionPermissionRequest is never mounted, so its useEffect/setTimeout + // timeout logic never fires. We replicate that behaviour here: + // - read askUserQuestionTimeoutSeconds from settings (default 600 s) + // - wait that long, then auto-select the first option for each question + // This respects the user-configured timeout value, not just 0. + if ( + appState.toolPermissionContext.shouldAvoidPermissionPrompts && + tool.name === ASK_USER_QUESTION_TOOL_NAME + ) { + const settings = getInitialSettings() + const AUTO_SELECT_TIMEOUT_S = settings.askUserQuestionTimeoutSeconds ?? 600 + // Use parsedInput (Zod-transformed, includes defaults) for questions extraction + // and as the base for updatedInput so call() receives a fully-valid input object. + const questionsRaw = parsedInput.questions + const baseInput = toolPermissionResult.updatedInput ?? parsedInput + + // Build auto-answers: select first option for each question. + // Falls back to empty answers if questions array is missing/malformed, + // which is still safe — call() treats answers={} as "no answers yet". + const buildAutoAnswers = (): Record => { + const autoAnswers: Record = {} + if (!Array.isArray(questionsRaw)) return autoAnswers + for (const q of questionsRaw) { + if ( + q && + typeof q === 'object' && + typeof (q as Record).question === 'string' && + Array.isArray((q as Record).options) && + ((q as Record).options as unknown[]).length > 0 + ) { + const qObj = q as { question: string; options: { label: string }[] } + autoAnswers[qObj.question] = qObj.options[0].label + } + } + return autoAnswers + } + + if (AUTO_SELECT_TIMEOUT_S === 0) { + // Immediately auto-select without waiting + const autoAnswers = buildAutoAnswers() + logForDebugging( + `[AskUserQuestion] headless immediate auto-select (askUserQuestionTimeoutSeconds=0): ${jsonStringify(autoAnswers)}`, + ) + return { + behavior: 'allow', + updatedInput: { ...baseInput, answers: autoAnswers }, + decisionReason: { + type: 'mode', + mode: appState.toolPermissionContext.mode, + }, + } + } + + // Wait for the configured timeout, then auto-select the first option + logForDebugging( + `[AskUserQuestion] headless mode: waiting ${AUTO_SELECT_TIMEOUT_S}s before auto-selecting first options`, + ) + await new Promise(resolve => setTimeout(resolve, AUTO_SELECT_TIMEOUT_S * 1000)) + // Re-check abort after the wait + if (context.abortController.signal.aborted) { + throw new AbortError() + } + const autoAnswers = buildAutoAnswers() + logForDebugging( + `[AskUserQuestion] headless auto-select after ${AUTO_SELECT_TIMEOUT_S}s timeout: ${jsonStringify(autoAnswers)}`, + ) + return { + behavior: 'allow', + updatedInput: { ...baseInput, answers: autoAnswers }, + decisionReason: { + type: 'mode', + mode: appState.toolPermissionContext.mode, + }, + } + } return toolPermissionResult }