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
- 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.
- 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.
- 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.
scripts/on-post-tool-use.shis registered onPostToolUsewith 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
v0.2026.07.15.08.55.stable_01,WARP_CLI_AGENT_PROTOCOL_VERSION=1warp@claude-code-warp2.2.0LAST_BROKEN_STABLE, soshould_use_structuredreturns 0 and the full structured path runs on every fireMeasurements
Claude Code records a
durationMsper hook run in its session transcripts. Attributed per script:on-post-tool-use.shon-stop.shon-session-start.shCost in context, over 521 turns totalling 47.1 hours of turn wall time:
on-post-tool-use.shaccounts for 3.1 hours of that, or 6.5%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
jqcalls, plus roughly a dozen$(...)subshells. MSYS2 emulatesfork(), 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.shsets the session to Blocked and fires rarely. Onlyon-post-tool-use.shclears 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
on-permission-request.shwrites a marker keyed onsession_id, already in the payload;on-post-tool-use.shnotifies and clears it only when present, making the common path onetest -fand an exit. Caveat: the README saysPostToolUsealso drives Warp's inline status indicators, so this breaks anything needing a continuoustool_completestream rather than edge transitions.jq. A singlejqover stdin can pullsession_id,cwd, andtool_name, deriveproject, and emit the{terminalSequence: ...}wrapper in one pass. Inliningwarp-notify.shdrops the second bash process, the duplicatesource, and thegrep/headversion parse.jqhere. Thetool_completepayload 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.