Skip to content

fix(hooks): don't emit invalid permissionDecision 'defer' — it swallows the AskUserQuestion widget - #2325

Closed
jawadakram20 wants to merge 2 commits into
garrytan:mainfrom
jawadakram20:fix/auq-defer-swallows-widget
Closed

jawadakram20 wants to merge 2 commits into
garrytan:mainfrom
jawadakram20:fix/auq-defer-swallows-widget

Conversation

@jawadakram20

@jawadakram20 jawadakram20 commented Jul 22, 2026 •

Copy link
Copy Markdown

The bug

question-preference-hook returns {"permissionDecision":"defer"} on its no-opinion path, intending "let the permission flow continue."

defer 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 logged as a hook_deferred_tool attachment. It never executes.

For AskUserQuestion this 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:

tool_use          AskUserQuestion  toolu_01PWVYTunnejtJTZv3SF6Bsw
hook_success      PreToolUse:AskUserQuestion
                  stdout: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}
hook_deferred_tool  AskUserQuestion   <-- call swallowed here
user              "questions are not loading"

Blast radius

This is not limited to users with plan-tune preferences configured. defer() is the default path for every non-auto-decidable question:

  • no preference set
  • recommendation marker missing
  • always-ask preference
  • one-way-door safety override
  • empty/unparseable stdin (crash safety)
  • non-AUQ tool names (defensive)

So AskUserQuestion is broken for essentially every user on Claude Code 2.1.14 with gstack installed.

The 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 are untouched: never-ask auto-decide and the Conductor prose redirect still enforce exactly as before.

Docs

docs/spikes/claude-code-hook-mutation.md documented "defer" — let permission flow continue as 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') to toBeUndefined(), matching the corrected contract.

bun test test/question-preference-hook.test.ts \
         test/memory-cache-injection.test.ts \
         test/gstack-question-log.test.ts

 51 pass
 0 fail
 101 expect() calls

Verified end-to-end on Claude Code 2.1.14: with the patch applied, AskUserQuestion renders and returns answers normally. Both PostToolUse AUQ hooks (question-log-hook, auq-error-fallback-hook) were also checked and are unaffected.

@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

@jawadakram20

Copy link
Copy Markdown
Author

/trunk merge

@trunk-io

trunk-io Bot commented Jul 22, 2026

Copy link
Copy Markdown

An error occurred while submitting your PR to the queue: Only users that are a part of this repo's Trunk organization or have write permissions to the repo can submit a 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.
@jawadakram20
jawadakram20 force-pushed the fix/auq-defer-swallows-widget branch from ade59ab to 2faf6ae Compare July 22, 2026 18:49
@jawadakram20

Copy link
Copy Markdown
Author

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 /trunk merge above was rejected with Only users that are a part of this repo's Trunk organization or have write permissions to the repo can submit a PR to the queue. (The bot prompt is posted automatically on fork PRs too, hence the confusion.)

CI also hasn't run yet — workflows from first-time fork contributors need the "Approve and run" click, so the combined status is still pending with 0 reported checks.

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:

bun test test/question-preference-hook.test.ts \
         test/memory-cache-injection.test.ts \
         test/gstack-question-log.test.ts

 51 pass
 0 fail
 101 expect() calls

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.
@devty

devty commented Jul 26, 2026

Copy link
Copy Markdown

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 defer as an invalid value that fails validation and aborts the call. The transcript records show the opposite: Claude Code honors defer and deliberately parks the tool.

A failing AskUserQuestion produces exactly two attachments and then nothing:

attachment.type = hook_success
  hookEvent  = PreToolUse
  hookName   = PreToolUse:AskUserQuestion
  command    = ~/.claude/skills/gstack/hosts/claude/hooks/question-preference-hook
  exitCode   = 0
  durationMs = 38
  stdout     = {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}

attachment.type = hook_deferred_tool          <-- the tool is parked here
  hookEvent      = PreToolUse
  hookName       = settings
  toolName       = AskUserQuestion
  permissionMode = auto

No PostToolUse attachment, no tool_result, no error. The hook exits 0 and is logged as a success — the call is simply suspended. The widget never renders and the turn sits idle until the user types something else, which surfaces as Tool execution was interrupted.

A successful call instead shows hook_success(PreToolUse) followed by hook_success(PostToolUse).

This is why the symptom is a silent hang rather than an error, and why gstack's own auq-error-fallback-hook can never help: it is a PostToolUse hook, and the tool never completes.

Confirmed in the Claude Code binary:

`permissionDecision` - "allow", "deny", or "ask" (PreToolUse only)
returned permissionDecision=defer in interactive mode; ignoring (defer is print-mode only)

So defer is a real, recognized, print-mode-only value — not a schema violation.

Data

Every AskUserQuestion call across 118 local Claude Code transcripts (34 calls), paired with its tool_result:

Period Claude Code Outcome
Jun 7 – Jul 18 ≤ 2.1.212 / 2.1.214 20 / 20 answered
Jul 19 – Jul 26 2.1.215 → 2.1.220 12 of 14 hung

Correlation with the hook_deferred_tool attachment, no exceptions:

  • Hung and deferred: 12 / 12
  • Answered but deferred: 0 / 22

Contributing factors I was able to rule out:

  • Not payload shape. Failures include option labels from 20 to 45 chars, 1–3 questions, 2–10 options, multiSelect both true and false.
  • Not permissions.defaultMode: "auto". Set since 2026-05-12, long before the first failure.
  • Not hook registration. The hooks were installed by 2026-07-15 and calls succeeded on Jul 15 and Jul 18.
  • Not a gstack change. question-preference-hook.ts was last modified 2026-06-20.
  • Not a change in defer handling. The hook_deferred_tool and permissionDecision=defer strings are byte-identical across the 2.1.214, 2.1.215, 2.1.217 and 2.1.220 binaries.

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.

Fix

I applied the equivalent of this PR locally — drop permissionDecision from defer() entirely, emit nothing unless there is additionalContext:

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 AskUserQuestion renders and returns normally; the Conductor deny path and the non-AUQ / empty-stdin paths are unaffected.

Environment: gstack installed at ~/.claude/skills/gstack, Claude Code 2.1.220, macOS 26.5.1, reproduced identically on two machines.

@redsilver

Copy link
Copy Markdown

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.

defer is not an unrecognized value. CLI 2.1.220 lists it among the valid ones and handles it deliberately:

Unknown hook permissionDecision type: ${...}. Valid types are: allow, deny, ask, defer

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 defer means "park this call as hook_deferred_tool awaiting an external resume". In an interactive terminal claude it is ignored with a warning — which is why the hook looks harmless there. In a non-interactive host it is honored, nothing ever resumes the call, no tool_result is written, and the UI fills the gap with [Tool result missing due to internal error] or Tool execution was interrupted.

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>.jsonl

Every parked call is there with its full toolInput. In my session both calls appeared, with zero tool_result lines.

Two things I hit while tracking this down:

  • Omitting the field is definitely the correct no-op. The host guards the switch with if (hookSpecificOutput?.hookEventName === "PreToolUse" && hookSpecificOutput.permissionDecision) and reads additionalContext independently, so the plan-tune memory injection survives the change.
  • The isConductor() deny-bailout further down main() can't cover this. It keys on CONDUCTOR_WORKSPACE_PATH / CONDUCTOR_PORT, which Claude Desktop doesn't set, so this host falls straight through to defer(). Worth noting alongside fix: pre-empt broken AskUserQuestion on the Claude Desktop host #2146.
  • auq-error-fallback-hook never fires on this path either. Its own comment (lines 14–19) flags as unverified whether PostToolUse runs when the result is missing; it does not. The prose fallback is dead code for precisely the failure it was written for.

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.

@garrytan

Copy link
Copy Markdown
Owner

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!

@garrytan garrytan closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants