[Bug]: Claude approval stays pending after the CLI withdraws its request (PermissionRequest hook, Auto mode)#24690

opened3d agobyDderektrimm

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Summary

When Claude Code withdraws a permission request it sent T3 (control_cancel_request, seen by the adapter as the canUseTool abort signal), the Claude V2 adapter returns but never closes the approval it drew. The request stays pending, its node and approval_request item stay waiting, and Approve fails with No pending Claude runtime request.

Claude Code withdraws a request whenever something else answers it first. It runs PermissionRequest hooks at the same moment it sends can_use_tool, and cancels the T3 request when the hook answers. A hook that approves in bypass mode therefore leaves one stuck card per prompt.

This is the approval half of #15517. #22803 withdraws aborted AskUserQuestion requests and states that approval requests are unchanged. #22023 (Stop) and #22982 (run end, tools that never ran) don't cover it either: here the turn completes normally and the tool ran.

Steps to reproduce

  1. Add a PermissionRequest hook to the Claude home's settings.json that allows in bypass mode:

    json
    1{ "hooks": { "PermissionRequest": [ { "hooks": [ { "type": "command", "command": "/path/to/allow.sh" } ] } ] } }
    bash
    1#!/usr/bin/env bash
    2# allow.sh
    3[ "$(jq -r .permission_mode)" = bypassPermissions ] &&
    4 echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
  2. In a Full access Claude thread, have Claude run a command that trips one of Claude Code's bypass-immune safety checks, for example echo victim > dir.txt; mkdir -p victim; touch victim/a; F=$(cat dir.txt); rm -f $F/* ("Dangerous rm operation on possibly-empty variable path").

  3. The command runs at once, because the hook answered. An approval card for it is still shown.

  4. Press Approve on that card.

Expected behavior

When the CLI withdraws a request nobody answered, its card closes as cancelled. The request, node and item settle the way #22803 settles a withdrawn question.

Actual behavior

Observed on the desktop app (0.1.2) with Claude Code 2.1.308:

  • The command ran and the turn continued. The hook's log shows the approval at 02:29:57Z.
  • orchestration_v2_projection_runtime_requests: kind command, status pending, resolvedAt null. Node approval_request and item approval_request are waiting, with fullAccessOverride: "provider_check" and the reason "Dangerous rm operation on possibly-empty variable path: $F/* ...".
  • It was still pending after the turn finished. The turn-end sweep only closes native user_input requests (ProjectionStore.ts#L4731).
  • Pressing Approve on two such cards produced 20 failed ClaudeAdapterV2.respondToRuntimeRequest spans, each ProviderAdapterRuntimeRequestResponseError caused by No pending Claude runtime request ....
  • While a request is pending, the thread can't be settled (Orchestrator.ts#L3015), snoozed, resumed after a usage limit or idle-compacted.

The SDK side, outside T3: with @anthropic-ai/claude-agent-sdk 0.3.276, permissionMode: "bypassPermissions", the hook above and a canUseTool that waits 15 s like a person, Claude Code called canUseTool for the same command and aborted it after 15 ms. The command then ran. Without the hook, the callback's answer decides it: an immediate deny kept the files.

Cause

After awaitClaudeApprovalDecision resolves through the abort signal, the adapter deletes its pendingRuntimeRequests entry and returns. It never emits an update for the request, node or turn item it opened, so nothing makes them terminal until a server restart reconciles them. A respond after that hits the No pending Claude runtime request branch.

The same path should apply to anything else that aborts a pending approval: Auto mode's classifier answering while a card is open, or a steer (#21895).

Possible fix

Mirror #22803 on the approval path: when the decision Deferred is not done after the race (the signal won, not a person's answer), emit cancelled for the request, node and approval item. A person's answer, including Cancel, keeps its own resolution. I have this change with two adapter tests: a withdrawn request closes (fails on current main with the card left pending/waiting), and an answered request keeps its answer (fails if the fix cancels on any cancel decision). I can open a PR if that approach suits you.

Impact

Major degradation or frequent failure

Version or commit

Desktop 0.1.2; same code at main @ 24c80f34a1

Environment

Linux (Omarchy), T3 Code desktop 0.1.2, Claude Code 2.1.308, Claude Opus 5.5, Full access thread

Workaround

Restarting the T3 server cancels the stuck requests. Writing rm targets as literal paths or guarded variables ("${F:?}"/*) avoids the safety check, so no request is sent.

Synced from GitHub

Also seeing this on Windows 11 with T3 Code desktop 0.1.2 and Claude Code 2.1.310 (native install), in a Full access Claude thread.

Setup was a PermissionRequest hook (matcher Bash|PowerShell) that allows only when permission_mode is bypassPermissions. The command was a multi-line Bash script whose rm -rf $OUT/* trips the dangerousRemoval safety check.

What the logs show:

  • The provider event log records the tool result with permission_decision: {"decision":"accept","source":"hook","reason_type":"hook"}, and the command ran.
  • server.trace.ndjson still has an awaitClaudeApprovalDecision span for it, lasting ~116 ms (129 ms on a second repro). That matches the CLI sending can_use_tool and withdrawing it once the hook answers.
  • The approval card stayed up in the UI afterwards.

I also tried adding a PreToolUse hook that returns permissionDecision: "allow" in bypass mode. It doesn't avoid the request: the safety check still sends can_use_tool to T3, so the card still appears. Closing withdrawn requests on the T3 side, as you proposed, looks like the only fix.