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`)
This commit is contained in:
yhangf 2026-05-08 17:57:15 +08:00
parent 697ee49752
commit ba2aad5c09
2 changed files with 92 additions and 3 deletions

View File

@ -3975,7 +3975,15 @@ async function run(): Promise<CommanderCommand> {
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(),

View File

@ -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<string, string> => {
const autoAnswers: Record<string, string> = {}
if (!Array.isArray(questionsRaw)) return autoAnswers
for (const q of questionsRaw) {
if (
q &&
typeof q === 'object' &&
typeof (q as Record<string, unknown>).question === 'string' &&
Array.isArray((q as Record<string, unknown>).options) &&
((q as Record<string, unknown>).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<void>(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
}