Skip to content

PermissionRequest hook is awaited before the local dialog for background subagents; the main session runs them in parallel #82150

Description

@y49

Summary

For the main session, a PermissionRequest command hook runs concurrently with the local permission dialog — both surfaces are live and the first answer wins. For background subagents, the hook is awaited before the dialog is constructed, so a hook that holds (for example to route the approval to a phone or a chat channel) removes the local prompt for the entire hold.

Both halves of the subagent path work as documented: the hook fires and carries agent_id, and a returned hookSpecificOutput.decision of allow is honored. The only asymmetry is the ordering.

Environment

  • Claude Code 2.1.220, Linux x86_64, interactive TTY
  • PermissionRequest command hook registered from a plugin's hooks.json, matcher: "*", timeout: 86400
  • The hook forwards the event to a local daemon over a unix socket and does not write stdout until an answer arrives from elsewhere. "Holding" below means exactly that: the hook process is alive and has produced no output yet.
  • All timings are from daemon-side logs that record each hook dispatch and each Notification of type permission_prompt.

Measurement 1 — main session: dialog is present while the hook holds

  • The permission_prompt Notification fired 6.0s after the request in 5 of 5 holds (spread 5.992s–6.008s).
  • 4 requests were answered at the keyboard while the hook was still holding; the hook's later answer was discarded, which is the expected outcome of a first-answer-wins race.
  • Two holds shorter than 6s produced no Notification at all, which is consistent with that notification having its own delay rather than with the dialog being absent.

So for the main session both surfaces coexist, and either one can settle the request.

Measurement 2 — background subagent: no dialog until the hook answers

Same hook, same session, request originating from a backgrounded subagent:

T+0.000s    PermissionRequest hook invoked      (agent_id present, tool_name Read)
            ... hook holds; configured window = 120s
            zero permission_prompt Notifications for the entire hold
T+120.0s    hook returns {}                     (window expired, no decision)
T+126.079s  permission_prompt Notification      → the dialog is constructed only now

The +6.079s offset after the pass-through matches the notification delay measured above, so the dialog appeared essentially the moment the hook answered — not before.

Measurement 3 — the decision path itself is fine

Same setup, but answered remotely instead of timing out:

T+0.000s    PermissionRequest hook invoked      (agent_id a3a3…, tool_name Read)
T+26.000s   hook returns hookSpecificOutput.decision = { "behavior": "allow" }
T+26.080s   PostToolUse for the same agent_id + tool_name  → the tool executed
            zero permission_prompt Notifications at any point

So allow is honored for subagents, and it correctly suppresses the prompt. Nothing is broken here; the report is only about when the dialog is built.

For what it is worth, the behavior looks like it comes from a flag set on a spawned agent's permission context when that agent is async, which turns the hook into an await ahead of dialog construction. The main-session path instead starts the hook as a background task that races the dialog, with a shared claim latch deciding the winner.

Why it matters

Any integration that routes approvals to another surface has to choose, for subagents only, between being able to approve remotely and a local prompt existing at all:

  • Hold the request, and someone sitting at the keyboard cannot answer it — there is nothing on screen for the duration of the window.
  • Pass it through, and the request becomes unanswerable from anywhere except the keyboard, because the hook invocation is over.

The main session gets both surfaces for free. Since 2.1.198 subagents run in the background by default, so this applies to most subagent tool calls.

Request

Run the PermissionRequest hook concurrently with dialog construction for these agents too, with first-answer-wins — the same arrangement the main-session path already uses.

Related

Activity

  1. y49 commented on Aug 20, 2026

    @y49
    Author

    Still reproduces on 2.1.235. Since this has sat for three weeks, here is the path in the shipped bundle so the behaviour can be confirmed without a repro. Identifiers are minified and will change between builds; the shape is the point.

    The flag. A backgrounded/async agent's permission context is built with awaitAutomatedChecksBeforeDialog: true:

    let Ut = i !== void 0 ? !i : Ke === "bubble" || Qe ? !1 : o;
    if (Ut) vr = { ...vr, shouldAvoidPermissionPrompts: !0 };
    if (o && !Ut) vr = { ...vr, awaitAutomatedChecksBeforeDialog: !0 };

    The gate. In the ask branch of the permission coordinator, that flag makes the automated checks a precondition of the dialog rather than a racer (reflowed for readability):

    case "ask": {
      if (CIc.awaitAutomatedChecksBeforeDialog) {
        let T_A = await BWn({ ctx: fFe, updatedInput: t6.updatedInput,
                              suggestions: t6.suggestions, permissionMode: CIc.mode });
        if (T_A) { bst(T_A); return }          // decided → the dialog is never constructed
      }
      if (fFe.resolveIfAborted(bst)) return;
      ...
      return Iqn({ ctx: fFe, description: nwg, result: t6,
                   awaitAutomatedChecksBeforeDialog: CIc.awaitAutomatedChecksBeforeDialog,
                   bridgeCallbacks: A_A.replBridgePermissionCallbacks,
                   channelCallbacks: A_A.channelPermissionCallbacks }, bst)
    }

    What is being awaited. BWn is the hook run and nothing else:

    async function BWn(e) {
      let { ctx: t, updatedInput: r, suggestions: n, permissionMode: o } = e;
      let s = await t.runHooks(o, n, r);
      if (s && !("reprompted" in s)) return s;
      return null
    }

    So for a backgrounded subagent the PermissionRequest hook runs to completion first, and the dialog is built only if the hook declines to decide. On the main session the flag is falsy, control goes straight to Iqn(...), and the dialog, bridgeCallbacks and channelCallbacks all resolve through one claim() — first answer wins.

    Why this is worth changing rather than documenting. A hook that asks a human somewhere else (a phone, a chat, another machine) can only be given a long timeout if it accepts removing the terminal as an answering surface for that entire window. On the main session that trade-off does not exist: the hook may hold as long as it likes and the person at the keyboard is never locked out, because whoever answers first wins. External approval tools want exactly the main-session semantics. Today, for backgrounded subagents, they must either keep the window short — and usually lose the race, making the feature pointless — or accept that a subagent's prompt cannot be answered at the keyboard while the hook waits.

    Either shape would resolve it:

    • run the hook concurrently with the dialog for backgrounded subagents, the way the main session already does; or
    • keep the current ordering but add an explicit "not deciding now, show the dialog, I may still answer" hook result, with the loser notified that it lost so it can withdraw its own UI.

    Family context. #82418 was updated on 2026-08-18 with a re-verification on 2.1.234. Taken together, the same hook-originated ask now behaves three different ways depending only on how the agent was spawned:

    spawn kind PermissionRequest dispatched? local dialog while the hook holds?
    in-process subagent (#79177) yes yes — reported working on 2.1.220
    backgrounded / async subagent (this issue) yes no — awaited first
    agent-teams teammate (#82418) never forwarded to the team lead's TUI; observable through the lead's worker_permission_prompt Notification, but not answerable by any hook

    Three spawn kinds, three answers, one ask. Even without a fix here, knowing which of the three is the intended model would tell integrators what to build against.

  2. MunifTanjim commented on Oct 3, 2026

    @MunifTanjim

    Would be really nice if this is fixed. This hampers the workflow when you want the permission decision to come from either the hook or from the claude-code ui manually.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions