Repository navigation
fix(hooks): don't emit invalid permissionDecision 'defer' — it swallows the AskUserQuestion widget - #2325
fix(hooks): don't emit invalid permissionDecision 'defer' — it swallows the AskUserQuestion widget#2325jawadakram20 wants to merge 2 commits into
Conversation
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
|
/trunk merge |
|
An error occurred while submitting your PR to the queue: |
…ws AskUserQuestion
The PreToolUse question-preference-hook returned
`{"permissionDecision":"defer"}` to mean "no opinion, let the permission
flow continue". That value is not in Claude Code's PreToolUse schema, which
accepts only `allow` / `deny` / `ask`.
As of Claude Code 2.1.14 the unrecognized value is interpreted as "defer the
tool call": the call is dropped and recorded as a `hook_deferred_tool`
attachment. For AskUserQuestion this means the question widget never renders
— the user sees nothing at all and the agent is left waiting. Observed in a
real transcript: hook_success with `"permissionDecision":"defer"`, then
`hook_deferred_tool`, then the user typing "questions are not loading".
Because the defer path is the default for every non-auto-decidable question
(no preference set, marker missing, always-ask, one-way door safety
override), this broke AskUserQuestion for essentially all users, not just
those with plan-tune preferences configured.
Fix: omit `permissionDecision` entirely in defer(). That is the correct
"no opinion" signal — normal permission flow continues and
`additionalContext` (the memory-cache injection) is still delivered.
The deny paths (never-ask auto-decide, Conductor prose redirect) are
untouched and still enforce.
Tests updated to assert `permissionDecision` is undefined on the defer path
rather than the string 'defer'. 51 pass / 0 fail across
question-preference-hook, memory-cache-injection and gstack-question-log.
ade59ab to
2faf6ae
Compare
|
Heads up for maintainers: I'm an outside contributor pushing from a fork, so I can't submit this to the Trunk queue myself — my CI also hasn't run yet — workflows from first-time fork contributors need the "Approve and run" click, so the combined status is still Whenever someone with write access has a moment, this needs an approve-and-run and then an enqueue from your side. Happy to rebase or adjust anything in the meantime. For convenience, the test run locally on this branch: |
Follow-up to the same file's corrected values list. The multi-hook precedence line still presented 'defer' as a decision value, contradicting the note above it. Precedence is deny > ask > allow; a hook with no opinion omits permissionDecision entirely.
|
Independent confirmation of this fix, plus a correction to the stated mechanism that I think matters for the changelog. The mechanism is not "invalid value → abort"#2310 / #2340 / #2292 all describe A failing No A successful call instead shows This is why the symptom is a silent hang rather than an error, and why gstack's own Confirmed in the Claude Code binary: So DataEvery
Correlation with the
Contributing factors I was able to rule out:
The clean break is at Claude Code 2.1.215 (2026-07-19). One anomaly I cannot explain: two calls on 2.1.215 (Jul 20) succeeded and were not deferred, in a different project. Everything from Jul 21 onward is deferred. If a maintainer knows what varies there, it may be worth pinning before release. FixI applied the equivalent of this PR locally — drop function defer(additionalContext?: string): void {
if (additionalContext) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext },
}));
}
process.exit(0);
}Verified after patching: pass-through emits empty stdout at exit 0 and Environment: gstack installed at |
|
Confirming this on the Claude desktop app for macOS (Claude.app 1.24012.9, CLI 2.1.220). The fix is right, but one detail in the description is worth correcting — I think it's why this keeps getting rediagnosed as flaky transport and reopened as a duplicate.
The branch: case "defer": {
if (!options.isNonInteractiveSession) {
warn(`Hook ${hookName} returned permissionDecision=defer in interactive mode; ignoring (defer is print-mode only)`)
break
}
if (toolCallsInBatch > 1) {
warn(`... ignoring (defer is solo-only — siblings would be orphaned on resume)`)
break
}
return push({ type: "hook_deferred_tool", toolUseID, toolName, toolInput, hookName, hookEvent: "PreToolUse", ... })
}So That accounts for the "works on my other Mac, same app version" reports: the variable is session interactivity, not the app build. Cheap repro check, no instrumentation — after a swallowed AskUserQuestion: grep -c hook_deferred_tool ~/.claude/projects/<slug>/<session-id>.jsonlEvery parked call is there with its full Two things I hit while tracking this down:
Applied the one-line change locally and AskUserQuestion works again immediately — no restart needed, since hooks are executed per call. Happy to test a build. |
|
Closing: this was absorbed or superseded on main by v1.67.0.0 or earlier (see the v1.67 tracker-wave receipts in PR #2604). Thank you! |
The bug
question-preference-hookreturns{"permissionDecision":"defer"}on its no-opinion path, intending "let the permission flow continue."deferis not in Claude Code's PreToolUse schema, which accepts onlyallow/deny/ask. As of Claude Code 2.1.14 the unrecognized value is interpreted as defer the tool call: the call is dropped and logged as ahook_deferred_toolattachment. It never executes.For
AskUserQuestionthis means the question widget never renders. The user sees nothing at all, and the agent is left waiting on an answer that can never arrive.Captured in a real transcript:
Blast radius
This is not limited to users with plan-tune preferences configured.
defer()is the default path for every non-auto-decidable question:always-askpreferenceSo
AskUserQuestionis broken for essentially every user on Claude Code 2.1.14 with gstack installed.The fix
Omit
permissionDecisionentirely indefer(). That is the correct "no opinion" signal — normal permission flow continues, andadditionalContext(the memory-cache injection) is still delivered.The
deny()paths are untouched: never-ask auto-decide and the Conductor prose redirect still enforce exactly as before.Docs
docs/spikes/claude-code-hook-mutation.mddocumented"defer" — let permission flow continueas a valid value. That stale assumption is how this got written, so it's corrected here to prevent reintroduction.Tests
Defer-path assertions changed from
toBe('defer')totoBeUndefined(), matching the corrected contract.Verified end-to-end on Claude Code 2.1.14: with the patch applied,
AskUserQuestionrenders and returns answers normally. Both PostToolUse AUQ hooks (question-log-hook,auq-error-fallback-hook) were also checked and are unaffected.