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:
parent
697ee49752
commit
ba2aad5c09
10
src/main.tsx
10
src/main.tsx
|
|
@ -3975,7 +3975,15 @@ async function run(): Promise<CommanderCommand> {
|
||||||
commands: mcpCommands,
|
commands: mcpCommands,
|
||||||
tools: mcpTools,
|
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:
|
effortValue:
|
||||||
parseEffortValue(options.effort) ??
|
parseEffortValue(options.effort) ??
|
||||||
getInitialEffortSetting(),
|
getInitialEffortSetting(),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from '../../services/mcp/mcpStringUtils.js'
|
} from '../../services/mcp/mcpStringUtils.js'
|
||||||
import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js'
|
import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js'
|
||||||
import { AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.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 { 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 { 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'
|
import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js'
|
||||||
|
|
@ -90,6 +91,7 @@ import {
|
||||||
} from '../messages.js'
|
} from '../messages.js'
|
||||||
import { calculateCostFromTokens } from '../modelCost.js'
|
import { calculateCostFromTokens } from '../modelCost.js'
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
import { getInitialSettings } from '../settings/settings.js'
|
||||||
import { jsonStringify } from '../slowOperations.js'
|
import { jsonStringify } from '../slowOperations.js'
|
||||||
import {
|
import {
|
||||||
createDenialTrackingState,
|
createDenialTrackingState,
|
||||||
|
|
@ -1233,8 +1235,12 @@ async function hasPermissionsToUseToolInner(
|
||||||
behavior: 'passthrough',
|
behavior: 'passthrough',
|
||||||
message: createPermissionRequestMessage(tool.name),
|
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 {
|
try {
|
||||||
const parsedInput = tool.inputSchema.parse(input)
|
parsedInput = tool.inputSchema.parse(input)
|
||||||
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
|
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Rethrow abort errors so they propagate properly
|
// Rethrow abort errors so they propagate properly
|
||||||
|
|
@ -1248,12 +1254,87 @@ async function hasPermissionsToUseToolInner(
|
||||||
if (toolPermissionResult?.behavior === 'deny') {
|
if (toolPermissionResult?.behavior === 'deny') {
|
||||||
return toolPermissionResult
|
return toolPermissionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1e. Tool requires user interaction even in bypass mode
|
// 1e. Tool requires user interaction even in bypass mode
|
||||||
if (
|
if (
|
||||||
tool.requiresUserInteraction?.() &&
|
tool.requiresUserInteraction?.() &&
|
||||||
toolPermissionResult?.behavior === 'ask'
|
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
|
return toolPermissionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user