Function hooks: classic.PermissionRequest is not aborted (next.signal) when the host answers the permission prompt

Status Open
Reported on v2.1.295
Maintainer reply None cached
Activity 0 comments · opened Oct 10, 2026

Environment

Claude Code 2.1.295 (the engine bundled with the Claude desktop app), macOS. The session is started the way the desktop app starts it: --input-format stream-json --output-format stream-json --permission-prompt-tool stdio. One plugin with a hooks module, loaded through CLAUDE_CODE_PLUGIN_DIRS.

What happens

A hooks module waits inside classic.PermissionRequest on a long $ call (in my case a helper process that shows a panel with the choice). When the person answers the dialog in the host instead (the app window, or another device), the engine goes on with the tool, but the hook's dispatch is left as it was: next.signal never aborts, and nothing tells the hook that the request is settled.

Expected

The typings say next.signal "aborts when the call this dispatch belongs to is abandoned: the user interrupted, a hook above settled first, or this hook ran out of budget", and that "anything the hook started (timers, requests) should stop on it". A permission request the host has answered is such a dispatch, so I expected next.signal to abort then, as it does on an interrupt.

Minimal repro

import type { Register } from 'claude-code'

export const register: Register = on => {
  on('classic.PermissionRequest', async ($, e, next) => {
    const started = Date.now()
    next.signal.addEventListener('abort', () => {
      $.ui.log(`repro: next.signal aborted after ${Date.now() - started} ms`, { to: 'debug' })
    })
    await $.process.run(['sleep', '41'], { timeoutMs: 60_000 })
    $.ui.log(`repro: hook resumed after ${Date.now() - started} ms`, { to: 'debug' })
    return next(e)
  })
}
  1. Start claude -p --input-format stream-json --output-format stream-json --verbose --permission-prompt-tool stdio --debug-file debug.log with that plugin loaded.
  2. Send a prompt that makes the model call AskUserQuestion (a Bash command that needs permission behaves the same).
  3. Answer the can_use_tool control request with behavior: "allow" three seconds later.

Result: the debug log has tool_dispatch_start tool=AskUserQuestion ... permissionDecisionMs=3003, the tool result is delivered and the turn completes, but neither repro: line is ever logged, and sleep 41 is still running six seconds after the answer.

Consequences

  1. A child started with $.process.run in that hook outlives the dialog by up to its timeoutMs.
  2. While that child is alive the engine does not exit: after stdin was closed the process was still there 20 seconds later.
  3. A user plugin has no other way to learn that the dialog was answered. tool.call's next(e) resolves only once the tool itself has finished, which for a long Bash command is minutes after the answer, and the anthropic telemetry stream is closed to user plugins.

Ask

Abort next.signal of a classic.PermissionRequest dispatch as soon as the request is settled by another route (the host's answer, another hook, the mode). It would also help if a $.process.run child were ended when its dispatch is abandoned and did not hold the exit, as a $.process.spawn child already is on an interrupt.

View original on GitHub ↗