Session hangs mid-turn after a PreToolUse hook returns: tool never dispatches (2.1.220, Windows)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 24 comments · opened Sep 18, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Session hangs mid-turn after a PreToolUse hook returns: tool never dispatches, no timeout, no error

Environment

  • Claude Code 2.1.220 (native binary, VS Code extension anthropic.claude-code-2.1.220-win32-x64)
  • Windows 11 Pro 10.0.26200, PowerShell 7.6.6, Node v24.16.0
  • Hooks configured in ~/.claude/settings.json: one UserPromptSubmit, two PreToolUse

(matchers Bash and PowerShell, each with if: "<tool>(git *)"), all pwsh scripts

Summary

A session stops mid-turn with no error, no timeout and no crash. claude.exe stays alive and its
event loop keeps ticking (background timers still fire), but the current turn never advances. The
last transcript entry is a tool_use with no matching tool_result — the tool is never dispatched.
Esc does nothing; a subsequent prompt is accepted into the queue and never processed. The only
recovery is killing the process and --resume.

It happens specifically on turns where a PreToolUse hook ran and returned. The hook is not the
blocker — see the evidence below, which is the point of this report.

The fingerprint

  1. Transcript's last line is tool_use, with no tool_result after it.
  2. The hook process for that tool call has already exited (present in Win32_Process as a zombie,

absent from Get-Process).

  1. claude.exe burns ~0.4–1.2% of one core — idle, not spinning.
  2. The status message from the hook's statusMessage field stays on screen indefinitely, because the

parent never moves past that stage.

Why this is not the hook (the part I can now prove)

After two earlier hangs on 2026-09-16 that were my own hook's fault (a script blocking on
[Console]::In.ReadToEnd() waiting for an EOF that never came), I rewrote the shared stdin reader to
return as soon as the received bytes parse as JSON, and added a trace log around it.

On the most recent occurrence the trace shows the full hook lifecycle completing in 106 ms:

13:09:53.956 pid=43976 +  79ms review-before-commit  start
13:09:53.983 pid=43976 + 103ms review-before-commit  stdin    ok - 548 byte(s) in 1 read(s), 16ms, event=PreToolUse
13:09:53.986 pid=43976 + 106ms review-before-commit  verdict  allow (silent) - not a commit

The transcript for that session goes silent at 13:09:53 — the same second. So: the hook started,
read its payload, emitted its verdict, and exited, and the session hung anyway. The hook's configured
timeout is 20 s and it finished in 106 ms, so no timeout was involved.

Note the verdict on that path is an exit 0 with zero bytes on stdout (the gate only acts on
commits; this command was not one). ~91% of this hook's invocations take that silent path. If the
parent distinguishes "hook produced no output" from "hook has not produced output yet" by waiting for
the stdout pipe to close, a zero-byte response is the case most likely to expose a race there. That
is speculation about the cause; everything above it is measured.

Occurrences (after my own hook bug was fixed)

| When | Session uptime | Transcript | Hook that ran | Hook verdict reached? |
|---|---|---|---|---|
| 2026-09-17 ~10:06 | 26 h | 8.4 MB | context-gauge (UserPromptSubmit) | yes — cleared by probe in 1.2 s |
| 2026-09-18 12:24:20 | 4.4 h | 4.4 MB | review-before-commit (PreToolUse) | yes — process had exited |
| 2026-09-18 13:09:53 | ~27 min | 4.4 MB | review-before-commit (PreToolUse) | yes — trace log, 106 ms |

Session age and context size are not preconditions: the third occurrence was a 27-minute-old
session resumed from --resume. The 2026-09-17 instance additionally logged
[event-loop-stall] Total stalls: 77, cumulative: 366957ms.

Ruled out

  • Hook blocking on stdin — the original 09-16 cause; fixed, and disproven since by both a real

unclosed-pipe probe and the trace log above.

  • Nested shell wrapper — settings.json previously had "shell": "powershell" and pwsh -File

in the command, so hooks ran nested and the timeout killed the wrapper rather than the script,
leaving an orphan holding the stdout pipe. Commands are now & 'path\to\script.ps1'.

  • Hook timeout — 106 ms against a 20 s budget.
  • Orphaned tool children — reaped; the hangs persist without them.
  • Windows event log / crash — empty; the process never dies.

Workaround

Kill claude.exe and claude --resume <session-id>. Nothing is lost, as file edits are already on
disk, but the in-flight turn and its reasoning are gone.

What would help

  • A dispatch-stage timeout, so a lost hook verdict surfaces as an error rather than an indefinite hang.
  • Debug logging at the PreToolUse boundary that records the verdict as received by the parent, so the

gap between "hook wrote it" and "parent consumed it" is observable without instrumenting the hooks.

  • Confirmation of whether a zero-byte exit 0 from a PreToolUse hook is handled on a different path

from one that writes a permissionDecision JSON object.

What Should Happen?

The session should not hang.

Error Messages/Logs

Steps to Reproduce

This is hard to reproduce. Most of the time it doesn't happen. See description.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.220

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

VS Code integrated terminal

Additional Information

_No response_

View original on GitHub ↗

4 Comments

FrankRoosValk · 24 days ago

After two more occurrances, and looking at #81425, we have the following findings:

Cross-referencing #81425, which was suggested as a possible duplicate. It is not a duplicate, but I no
longer think it is a separate bug either: a freeze today puts a third failure point on the same path,
and the three of them line up in sequence. I also tested the workaround #81425 proposes, and it does not
work. Windows / VS Code extension.

Three stalls, one handoff

Normal order in my logs is: PreToolUse hook verdict -> permission classifier -> tool_dispatch_start ->
tool_dispatch_end. I now have a hang at each of the first three boundaries:

  1. hook verdict returned, classifier never starts - new, 2026-09-21, below.
  2. classifier_request_finished, no dispatch - #81425.
  3. tool_dispatch_start, no tool_dispatch_end - what I originally reported here.

Three consecutive points on one path, each losing the pending call silently and with no timeout, reads
like one defect - a permission verdict lost in the handoff to dispatch - rather than three.

The new one, in detail

Session's last transcript entry is an assistant message with stop_reason=tool_use and no tool_result.
29 minutes later the process was idle (0s CPU over a 5s sample), so this is past the 600s ceiling, not a
long-running tool.

13:04:45.000  assistant message persisted, stop_reason=tool_use, toolu_01Bw...5wmb
13:04:46.841  PreToolUse hook starts (pwsh, child of the claude process)
13:04:46.877  hook verdict: allow  -- 113 ms, against a 20 s hook timeout
              (nothing further, ever: no classifier_request_started,
               no tool_dispatch_start, no error)

The hook is positively cleared - it ran, answered in 113 ms and exited. This is not a logging gap either:
of that session's 47 tool_use entries, 46 have a tool_dispatch_start in the extension log and
only the last one has none
. Across the whole log file - 231 dispatch starts, 229 ends, 154 classifier
start/finish pairs - nothing else is unmatched.

Worth noting for #81425's channel-error theory: I get
claude-vscode notification channel error: Received a response for an unknown message ID exactly once
per process, at startup
, and never again. If that warning is suppressed after the first occurrence, a
message dropped later in the session leaves no trace anywhere - which is consistent with all three
failure points above.

The proposed workaround does not recover the session

#81425 says "only closing and reopening the chat (which rebuilds the webview channel) recovers the
session". Tested on a session frozen at stage 1 above. It does not recover it:

13:42:04  closing+reopening the panel spawns a SECOND claude.exe, --resume=<same session id>
13:42:07  transcript touched, byte-identical, no new entries
13:43:18  new process idle at 3s CPU; ORIGINAL process still alive, never killed

The reopen does not reattach to the stuck backend. It starts a fresh one against the same session file and
replays the transcript into the webview, so the UI looks restored - the last line is there - while the
pending tool call is still undispatched and that turn is permanently dead. It also orphans the original
backend (~450 MB each time).

So it is equivalent to killing the process and running claude --resume <session-id>, minus the kill.
As far as I can tell there is currently no recovery that preserves the in-flight turn, on either
issue. A timeout on the permission/dispatch path would not just avoid the hang, it would be the only way
to avoid losing the turn.

Two other things found while chasing this

A 600 s false positive - worth knowing before anyone debugs this. I diagnosed a hang that was not one.
Every indicator matched: last transcript entry a tool_use with no tool_result, tool_dispatch_start
with no tool_dispatch_end, the process at ~1% of one core, and the tool's child process still alive well
past the default 120 s timeout. It was fine. The call had been dispatched with the maximum 600 s timeout,
which fired exactly on schedule (tool_dispatch_end outcome=ok durationMs=600707) and moved the command
to the background, where it returned its output 53 s later. A live tool child past 120 s proves
nothing
- check the clock against tool_dispatch_start + 600 s.

Tool grandchildren are orphaned, not reaped. Tool commands are not spawned in a job object, so closing
VS Code kills the session but not the tree below the immediate child. A
pwsh -> dotnet test -> vstest.console -> testhost chain left a testhost.exe alive across three days and
several full VS Code restarts. It held the project's build output directory, which caused four consecutive
MSB3027 "file in use" build failures and made the next dotnet test block for 10m56s - for a suite that
runs in 4 seconds
. That run then hit the 600 s cap and leaked a fresh orphan of its own, so the failure
is self-perpetuating.

Environment

Claude Code 2.1.220.564, native binary, VS Code extension, Windows 11 Pro 26200. #81425 is Cursor on
macOS at 2.1.220.26d, so whatever the shared root cause is, it is not platform specific.

FrankRoosValk · 24 days ago

And another one:
Fourth stall stage, 16 minutes after my last comment — and this one is not on the tool path at all, which I think means the title of this issue is too narrow.

Same freeze signature, but on UserPromptSubmit, with no tool involved and no tool_use entry anywhere near it:

12:04:03.497Z  queue-operation enqueue
12:04:03.498Z  queue-operation dequeue
12:04:13.313   UserPromptSubmit hook (context-gauge.ps1) start
12:04:13.371   stdin ok - 354 bytes in 1 read, 38ms
12:04:13.542   exit - gauge emitted           <-- hook done, 539 ms total, 20 s timeout
               ...nothing. No [API REQUEST] source=sdk, ever.

The turn never reached the model. The next [API REQUEST] in the log belongs to a different session
four minutes later. claude.exe burned 0.02 s of CPU over a 5 s sample 5 minutes after the freeze —
idle, not spinning. The prompt is simply gone.

So across the occurrences I've now logged, the hang lands at four different stages:

  1. prompt dequeued -> UserPromptSubmit hook returns -> no API request (this one, and one on 09-17)
  2. PreToolUse hook returns -> classifier never starts (my previous comment)
  3. classifier_request_finished -> no dispatch (#81425)
  4. tool_dispatch_start -> no tool_dispatch_end (the original report above)

The common factor in every one of mine is not the tool pipeline — it is that a hook completed and
whatever was awaiting its result never resumed
, at whichever stage happened to come next. Stage 0 has
no permission decision, no classifier and no tool in it, so "lost permission verdict during the
permission->dispatch handoff" (my earlier framing) is too specific. Suggest retitling to something like
"Turn pipeline stalls after a hook returns; next stage never starts (UserPromptSubmit and PreToolUse)".

Negative evidence, since bloat keeps coming up as an explanation for this class of report:

| | 09-17 freeze | this freeze |
|---|---|---|
| process uptime | 26 h | 1 h 9 min |
| context | 272k tokens | 130k tokens |
| transcript | 8.4 MB | 1.1 MB |
| RSS | 467 MB | 448 MB |
| [event-loop-stall] | 77 stalls, 366957 ms | 24 stalls, 37273 ms |

An order of magnitude apart on stall accounting and 20x apart on uptime, identical outcome. Neither
session age, context size, nor cumulative event-loop stall predicts it.

Two small asks that would cost you little and help a lot:

  1. Persist the prompt text in the queue-operation enqueue record. Right now those entries carry

only type, operation, timestamp, sessionId — no text. So when the stall happens at stage 0
the user's prompt is unrecoverable even via --resume, because it was never written anywhere. If the
text were in the record, --resume could replay it and this class of stall would stop costing work.

  1. Tag [Stall] log lines with sessionId. Two sessions in one VS Code window write to the same

Claude VSCode.log with no session or PID marker, so reconstructing which session died means mapping
PID->session via --resume= on the command line and untangling interleaved lines by timestamp.

And the earlier ask, generalised: a watchdog on every stage, not just dispatch. If a dequeued prompt
has produced no API request within N seconds, or a returned hook verdict has produced no next-stage
event, surface an error and let the turn fail. Any error at all would be better than a session that sits
idle forever with a frozen status message — at the moment the only recovery is Stop-Process -Force
plus claude --resume <id>, and the in-flight turn is always lost.

Version unchanged: Claude Code 2.1.220, native binary, VS Code extension, Windows 11.

FrankRoosValk · 24 days ago

And while looking into it further, we found the following:
Follow-up to my two comments above. I wrote a triage script for the next occurrence, and building it
forced me to read the [Stall] log format properly. That produced one piece of direct evidence I did
not have before, and falsified one claim I made earlier. Both below.

Direct evidence that the verdict is lost between the hook and its consumer

This is the thing I would look at first if I were picking this up.

When a PreToolUse hook returns, the extension logs that it consumed the output:

[DEBUG] Hook output does not start with {, treating as plain text

That line appears reliably, roughly half a second after each verdict. So I correlated my own hook trace
log (which records start / stdin / verdict / exit per invocation) against that line, over one
day in one log file:

hook verdicts in my trace (today)   : 67
"Hook output does not start with {" : 95    (>67: other hook events log it too)
verdicts with no consume line <=5 s :  1
                                    -> 13:04:46.877   <- the frozen turn

One verdict out of 67 was never consumed, and it is the one the session died on. The hook process
wrote its verdict and exited 0 at 13:04:46.877 (113 ms, against a 20 s timeout). Nothing on the
extension side ever acknowledged it.

It is stronger than that. The extension log has no lines at all between 13:04:39.141 and
13:04:53.457 - a 14.3 s total silence - and that gap contains both the creation of the stalled tool call
(13:04:45.084) and its hook invocation (13:04:46.841). Every other tool call in the same minute produces
a burst of chatter (Permission suggestions for PowerShell: [], Fast mode unavailable: ...,
Skipping hook due to if condition ..., Dynamic tool loading: ...). The stalled call produced not one
line of it - not even the Skipping hook line it would emit if the matcher had declined.

So the call exists in the transcript, the hook ran for it and answered, and the extension side has no
record that it ever happened.

Correction: I was wrong to split "before the classifier" from "after the classifier"

I distinguished "hook verdict returned, classifier never starts" from "classifier finished, no
dispatch". That does not hold, because the classifier is optional:

[Stall] tool_dispatch_start tool=Read toolUseId=toolu_01Co37X1RLzsfHUzXDuoDByk permissionDecisionMs=2

No classifier line precedes that, or exists near it. A call whose permission decision is already
determined skips the classifier and dispatches directly; I confirmed the same on a live healthy call.
So "no classifier_request_started" is normal and not a symptom, and my stage 1 / stage 2 boundary is
not observable per call. Please read that part of my earlier comment as a list of observations, not of
distinct stages. What they share is what the section above shows: a verdict that returned and a dispatch
that never happened.

The untagged shared log is a correctness problem, not just an inconvenience

I asked earlier for sessionId on [Stall] lines. I want to upgrade that request, because its absence
produced two confident, wrong diagnoses of my own freeze while I was writing the script.

Both sessions in one VS Code window write to one Claude VSCode.log with no session tag, so a stalled
call cannot be separated from a neighbouring session's healthy traffic by timestamp. Joining "the
classifier run nearest after the stalled request" gave me first a run that had started before the
frozen call and merely finished 8 s after it, then - after I anchored on the start instead - a run from
four minutes later belonging to the other session. Both rendered a clean-looking "classifier
finished, dispatch never started, this is #81425". Both were wrong: no classifier ran for that call at
all. Anyone triaging this from logs will hit the same trap.

There is a second half. Even within one session the two stages cannot be joined, because they share no
key:

[Stall] classifier_request_started  reqId=dd1fe0d8-... tool=PowerShell stage=xml_s1
[Stall] classifier_request_finished reqId=dd1fe0d8-... tool=PowerShell
[Stall] tool_dispatch_start          tool=PowerShell toolUseId=toolu_01G35xrfsgPE41agcJwPvAtH permissionDecisionMs=...

reqId appears on classifier lines only; toolUseId on dispatch lines only; the sole link is adjacency.
Putting toolUseId on the classifier lines (or reqId on the dispatch line) would make the permission
path traceable end to end
, and costs one field.

You already have most of the watchdog

Slow permission decision: 15277ms for PowerShell appears 42 times in this one log. The detector for a
slow decision exists; what is missing is the detector for no decision at all.

For calibration, measuring every finished classifier run against the next dispatch in the same log:
median gap 1.0 ms, 200 of 235 within 10 ms. The 33 runs above 2 s line up with the
Slow permission decision entries, i.e. decisions that waited on me. So a threshold here would need to
be generous, or to exclude calls awaiting user input - but "hook verdict returned, nothing dispatched,
nothing logged, indefinitely" has no legitimate case at all, and that is the one I keep hitting.

The stalled call, exactly

  • tool: PowerShell
  • one line, beginning cd <repo>; foreach ($f in @('Global.json', ...)) { ... (git show ":$path") ... }
  • created 13:04:45.084; hook ran 13:04:46.841, stdin ok 1198 bytes, verdict allow 13:04:46.877,

exit 0 - the only hook invocation in the window

  • nothing followed, ever: no classifier, no tool_dispatch_start, no error, no timeout

Not a logging gap: of that session's 51 tool_use entries, 50 have a tool_dispatch_start and only
this one has none. Across the whole file, dispatch starts and ends differ by exactly the one call still
legitimately in flight.

Version

Still on 2.1.220 (VS Code extension, bundled native binary). 2.1.278 is current; I am updating and will
report back either way. I found nothing in 2.1.268-2.1.278 describing this path - the nearest is
2.1.278's "sessions that could hang with no result after an internal error", mentioned only because the
extension drives the binary over the SDK. I could not read the 2.1.221-2.1.267 notes.

Happy to attach the triage script if it is useful; it encodes the two log traps above.

FrankRoosValk · 23 days ago

We made some progress investigating, as it keeps happening:
Still reproduces on 2.1.278 (this issue was filed against 2.1.220), native binary, VS Code
extension, Windows 11. Adding one new discriminator, one environmental multiplier that may
explain why this reporter sees it so often, and two theories we chased and ruled out.

1. The Bash tool completes what the PowerShell tool stalls on

Same build-and-run, same session, minutes apart: stalls indefinitely when issued through the
PowerShell tool, completes in seconds through the Bash tool.

The stalled call has an unambiguous signature, confirmable in one command:

  • the backgrounded task's .output file stays at 0 bytes indefinitely, and
  • no child process ever existed — tasklist reports no dotnet.exe / MSBuild.exe /

testhost.exe at any point.

No child and no output means the work never started. This is a dispatch-path failure, consistent
with stages 0-2 in the four-stage list above, and in this environment it appears specific to the
PowerShell tool path.

Reliable workaround: stop the task and re-run identical work through the Bash tool, redirected to
a log file. Builds that had "hung" for 10+ minutes finished in under 30 s every time.

2. Process creation on this machine costs ~10x normal

Possibly the answer to "why this reporter and not others". On an otherwise idle machine
(CPU 12%, RAM 45% of 63.7 GB, disk 2% busy, queue length 0):

| spawn (10 runs each) | ms each |
|-----------------------------|---------|
| cmd /c exit | 213 |
| pwsh -NoProfile startup | 552 |
| git --version (PowerShell)| 327 → 532 over 20 min |
| /usr/bin/true (Git Bash) | 408 |
| git --version (Git Bash) | 487 |

Healthy is 20-40 ms. Note /usr/bin/true from bash at 408 ms — it does nothing at all, and bash
pays the same tax, so this is not a PowerShell property. Cause: Defender real-time protection is
off and Palo Alto Cortex XDR (PSANHost + the pselamsvc ELAM driver) hooks every process
creation.

Why it may matter here: if the dispatch stall is a race, a 10x-slower spawn widens the window on
every tool call. The same session logged [event-loop-stall] Total stalls: 77, cumulative:
366957ms
. It may be reproducible in-house by artificially delaying process creation.

3. Two theories ruled out — please don't spend triage time on them

EDR blocking process teardown: no. 10 pwsh processes told to exit immediately,
WaitForExit(15000): 0 of 10 failed to exit. The agent makes spawning slow; it does not block
teardown.

MSBuild node-reuse orphans holding the stdout pipe past EOF: no. This looked right — 9 orphaned
nodemode:1 workers with a dead parent, node reuse enabled, and a bash/PowerShell split. It does
not hold: the stalled calls had no child process at all, so nothing was holding a pipe. Separately,
in an earlier occurrence an orphan was holding the pipe and the call still ended with
tool_dispatch_end outcome=ok.

4. Hooks remain positively cleared

343 hook runs on 2026-09-21, zero stdin anomalies. Each completes in ~120 ms against a 20 s
timeout (hook-trace.log). The last stdin-level incidents were 2026-09-18, before the fix.

Showing cached comments. Read the full discussion on GitHub ↗