Skip to content

PostToolUse hook adds ~1.7s to every tool call on Windows #77

Description

@matthoneycutt-sm

scripts/on-post-tool-use.sh is registered on PostToolUse with no matcher, so it runs after every tool call. Under Git Bash on Windows each run blocks for about 1.7 seconds. Measured across my sessions that is roughly 11% of the typical gap between consecutive tool calls and 6.5% of total turn wall time.

Environment

  • Claude Code 2.1.220, native install
  • Windows 11 Pro 26200, hooks running under Git Bash (MSYS2)
  • Warp v0.2026.07.15.08.55.stable_01, WARP_CLI_AGENT_PROTOCOL_VERSION=1
  • Plugin warp@claude-code-warp 2.2.0
  • Past LAST_BROKEN_STABLE, so should_use_structured returns 0 and the full structured path runs on every fire

Measurements

Claude Code records a durationMs per hook run in its session transcripts. Attributed per script:

script runs median p90 worst
on-post-tool-use.sh 5,643 1,699 ms 4,060 ms 9,814 ms
on-stop.sh 410 3,856 ms 6,612 ms 14,211 ms
on-session-start.sh 42 2,813 ms 6,056 ms 8,746 ms

Cost in context, over 521 turns totalling 47.1 hours of turn wall time:

  • on-post-tool-use.sh accounts for 3.1 hours of that, or 6.5%
  • median gap between consecutive tool calls is 15.0 s, of which the hook is 1.7 s
  • it fires 12x more often than the plugin's other two hooks combined

Where the time goes

Benchmarked against a bare bash -c ':' spawn, arms interleaved, 15 iterations each: shipped hook 5,816 ms/run, bare spawn 540 ms/run. Both arms ran loaded, which is why they sit above the telemetry median. The ratio is the point, and it puts one fire at about eleven bash process spawns.

The chain explains that. One fire spawns two bash processes and eleven helper binaries, five of them separate jq calls, plus roughly a dozen $(...) subshells. MSYS2 emulates fork(), so process creation is expensive and the fixed startup cost dwarfs the work being done. Untested on macOS and Linux.

The asymmetry

on-permission-request.sh sets the session to Blocked and fires rarely. Only on-post-tool-use.sh clears it mid-turn, and that fires after every tool call regardless. Hooks are stateless, so it cannot know whether the session is currently Blocked and notifies unconditionally: thousands of notifications to clear a state set on the order of tens of times.

Suggestions

  1. Sentinel gate. on-permission-request.sh writes a marker keyed on session_id, already in the payload; on-post-tool-use.sh notifies and clears it only when present, making the common path one test -f and an exit. Caveat: the README says PostToolUse also drives Warp's inline status indicators, so this breaks anything needing a continuous tool_complete stream rather than edge transitions.
  2. One process, one jq. A single jq over stdin can pull session_id, cwd, and tool_name, derive project, and emit the {terminalSequence: ...} wrapper in one pass. Inlining warp-notify.sh drops the second bash process, the duplicate source, and the grep/head version parse.
  3. Drop jq here. The tool_complete payload is fixed-shape with two fields needing escaping; building it in bash spawns no helper binary at all.

Happy to run a patched build and send back before-and-after durationMs.

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