cd /news/developer-tools/your-claude-code-hooks-are-costing-y… · home topics developer-tools article
[ARTICLE · art-104317] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Your Claude Code Hooks Are Costing You Minutes a Day — Here's How I Measured It

A developer has created a shell script wrapper to measure the latency of Claude Code hooks, which can silently add minutes of waiting time per session. The wrapper, hook-latency-wrap.sh, uses bash's EPOCHREALTIME variable to log execution times without altering hook behavior, addressing a common performance issue in AI-assisted development workflows.

read24 min views2 publishedAug 20, 2026

If Claude Code feels sluggish lately, the culprit probably isn't the model — it's the pile of shell scripts you wired into it months ago and never looked at again. Going from $0/month to a real income in six months came down to a lot of small habits, and one of them was refusing to leave my Claude Code hooks unmeasured.

Claude Code has a feature called "hooks." It's a simple mechanism, wired up in settings.json

, that lets you inject arbitrary shell scripts before and after tool calls. Right now I have a dozen-plus hooks bundled across three types — PreToolUse, PostToolUse, and Stop — running everything from automatic git commits to latency reports, self-audits, and project categorization.

The problem is that hooks run dozens of times in a single session.

Say Claude Code edits files 10 times in one session. If a PostToolUse hook fires each time, the execution time of that one hook × 10 becomes pure waiting cost. A lot of people never notice this and just feel like "Claude Code got slower somehow." I was one of them — I once had a heavy Python-based process wired into my self-audit hook, and everything felt sluggish. It took me days to figure out why.

Perceived "heaviness" is proportional not to the number of hooks, but to the latency of each one.

Three hooks are fine if they all finish under 50ms. But a single hook with a p95 above 2000ms racks up 20 seconds of pure waiting after just 10 calls. The operator is doing nothing and losing 20 seconds. Run several sessions a day, and you're burning minutes — or tens of minutes — without realizing it.

You can't spot this loss by staring at the hooks section of settings.json

. All that's written there is a command string; nothing records how many milliseconds it takes. That's why measurement is the only answer.

What matters here is that you can start with zero configuration changes. The hook-latency-wrap.sh

script described below is just a shell script that takes an existing hook binary as an argument and wraps it. Without altering the original hook's behavior at all, it writes the elapsed time and exit code of each invocation to a log file in JSONL format. You don't need to rewrite your production hook logic to instrument it.

Most people using Claude Code for side projects or solo development get absorbed in just getting hooks working and never get around to measuring them. I was the same. Every time I added a hook, I felt "upgraded" and left it at that. But that's close to a car with so many parts that fuel economy tanks while you celebrate the "mods."

Precisely because Claude Code is an autonomous agent, the density of a single session matters. The time between issuing an instruction and getting a response shifts a lot with accumulated hooks. I think of it as "cutting hook latency = raising my own hourly rate." Behind a figure like ¥1.2M monthly revenue is a stack of unglamorous habits, including making the tools I use as fast as possible.

$EPOCHREALTIME

The wrapper script I'm showing here doesn't use Python — it measures with the bash builtin variable $EPOCHREALTIME

. This variable is available in bash 5.0 and later and returns the current time in seconds.microseconds

format (e.g. 1720000000.123456

).

Why not call Python? Because it defeats the purpose if the measurement script itself becomes heavy. Python3 startup can cost tens to hundreds of milliseconds depending on the environment. If you invoke Python every time just to measure, that startup cost contaminates the measurement itself. With a bash builtin, you get the current time at microsecond resolution without spawning an additional process.

That said, if bash is older than 5.0 (like the default shell on older macOS), $EPOCHREALTIME

comes back empty. The implementation includes a Python3 fallback for that case. I'll cover it in the code walkthrough in the next section.

Here's how the whole system is structured.

settings.json
  └─ command: "hook-latency-wrap.sh  本来のhook.sh"
                        │
                        ├─ 本来の hook.sh を実行(動作は変わらない)
                        │
                        └─ 経過時間・終了コード を JSONL に追記
                                      │
                            ~/.claude/logs/hook-latency.jsonl
                                      │
                            hook-latency-report.sh [days]
                                      │
                              ターミナルに集計表を出力
                              (hook名・回数・mean・p95・max・fail)

The wrapper writes to JSONL, and the reporter reads the JSONL and aggregates it. Two scripts, one log file. The only change to settings.json is "prepend the wrapper's path to the command string."

It's 43 lines total. Here's the actual code, verbatim.

#!/usr/bin/env bash
#
#

set -uo pipefail

HOOK_BIN="${1:-}"
[ -z "$HOOK_BIN" ] && { echo "usage: $0 <hook-binary> [args...]" >&2; exit 64; }
shift || true

LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
JSONL="$LOG_DIR/hook-latency.jsonl"

start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
[ -z "$start_us" ] && start_us=$(python3 -c 'import time;print(int(time.time()*1000000))')

"$HOOK_BIN" "$@"
exit_code=$?

end_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
[ -z "$end_us" ] && end_us=$(python3 -c 'import time;print(int(time.time()*1000000))')

elapsed_ms=$(( (end_us - start_us) / 1000 ))
hook_name=$(basename "$HOOK_BIN")
ts=$(date -u +%Y-%m-%dT%H:%M:%S)

printf '{"ts":"%s","hook":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
  "$ts" "$hook_name" "$elapsed_ms" "$exit_code" >> "$JSONL"

exit "$exit_code"

Three points worth calling out.

① How timestamps are taken

$EPOCHREALTIME

returns a string like 1720000000.123456

. Stripping the dot with ${EPOCHREALTIME//./}

gives 1720000000123456

— an integer in microseconds. Leading zeros are trimmed with sed 's/^0*//'

. Taking the difference between start and end times in microseconds and dividing by 1000 gives elapsed time in milliseconds. The external date

command is used only to record the end timestamp (the ts

field); it isn't on the measurement critical path.

② Preserving the original hook's exit code

It matters that the wrapper ends with exit "$exit_code"

. Claude Code looks at a hook's exit code to determine errors. If the wrapper always returned 0, a failure in the original hook would never reach Claude Code. This design means inserting the wrapper doesn't change behavior as a hook.

③ Writing in append mode ( >>)

Because it appends with >> "$JSONL"

, the file doesn't get corrupted even when multiple hooks are called at the same time. JSONL is one record per line, so even if appends collide, the damage is limited to individual lines. The aggregation script uses try/except

to skip broken lines, so it's a non-issue in practice.

Here's the aggregation script in full as well.

#!/usr/bin/env bash

set -uo pipefail
DAYS="${1:-7}"
JSONL="$HOME/.claude/logs/hook-latency.jsonl"
[ -f "$JSONL" ] || { echo "no data: $JSONL"; exit 0; }

python3 - "$JSONL" "$DAYS" <<'PY'
import sys, json, datetime, collections
log, days = sys.argv[1], int(sys.argv[2])
cutoff = datetime.datetime.now() - datetime.timedelta(days=days)

stats = collections.defaultdict(list)
fail = collections.Counter()
total_records = 0
with open(log) as f:
    for line in f:
        try:
            r = json.loads(line)
            ts = datetime.datetime.fromisoformat(r["ts"])
            if ts < cutoff:
                continue
            total_records += 1
            stats[r["hook"]].append(r["elapsed_ms"])
            if r.get("exit_code", 0) not in (0, ):
                fail[r["hook"]] += 1
        except Exception:
            continue

if not stats:
    print(f"no records in last {days}d")
    sys.exit(0)

rows = []
for hook, vals in stats.items():
    vals_sorted = sorted(vals)
    n = len(vals_sorted)
    p95 = vals_sorted[min(n-1, int(n*0.95))]
    rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))
rows.sort(key=lambda r: -r[3])  # p95 降順(遅いものを上に)

print(f"=== hook latency (last {days}d, {total_records} records) ===")
print(f"{'hook':<32} {'n':>5} {'mean':>7} {'p95':>7} {'max':>7} {'fail':>5}")
print("-" * 70)
for hook, n, mean, p95, mx, fl in rows:
    flag = " ⚠" if p95 > 1500 else ""
    print(f"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}")
PY

The Python script is defined inline with a <<'PY'

heredoc. I didn't split it into a separate .py

file because I didn't want to create a "doesn't work unless you have both files" dependency. Hold on to this one script and aggregation is self-contained.

The core of the aggregation logic is the single line p95 = vals_sorted[min(n-1, int(n*0.95))]

. It computes the 95th-percentile index from the sorted list. The min(n-1, ...)

is a guard against out-of-range access when the sample count is small. Output is sorted by p95 descending, so the most problematic hook is always at the top. One glance tells you what to fix.

A

is appended to the end of the line when p95 exceeds 1500ms. I picked that threshold on the judgment that "1.5 seconds of pure waiting per tool call is simply not acceptable."

Sample output looks like this.

=== hook latency (last 7d, 843 records) ===
hook                              n    mean     p95     max  fail
----------------------------------------------------------------------
self_audit_stop.sh              127  1823ms  3240ms  8102ms     0 ⚠
pre_git_guard.sh                 98   420ms   890ms  2100ms     2
hook-latency-wrap.sh            618    12ms    18ms    45ms     0

Looking at this table, you immediately see: "self_audit_stop.sh

has a p95 of 3240ms with a ⚠ — that's the top improvement target." Its mean is 1823ms, so even on average it takes 1.8 seconds. It was called 127 times, so over 7 days it consumed at least 1823ms × 127 ≈ 3 minutes 54 seconds of pure waiting. These are numbers you only see once you measure.

The code itself is what you've read so far, but to actually run it, how you write the settings.json

side is the key. It looks like this.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh"
          }
        ]
      }
    ]
  }
}

You put the wrapper's path at the front of the command

field and pass the original hook path as the first argument. If there are additional arguments, write them after the original hook and they pass straight through via "$@"

. The only change is this one line — just move the original command behind command

.

When Claude Code invokes a hook, it streams the tool-call context to stdin as JSON. For PostToolUse it's a structure like {"tool_name":"Edit","tool_input":{...},"tool_response":{...}}

. Because the wrapper launches the original hook with "$HOOK_BIN" "$@"

without reading stdin, stdin is automatically inherited by the child process through bash's process inheritance. This part requires no special code — "doing nothing" is the correct answer.

Let's read the timestamp line carefully once more.

start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')

$EPOCHREALTIME

returns a string like 1720543200.847231

on bash 5 and later. Replacing all dots with //./

yields 1720543200847231

. That's a UNIX timestamp in microseconds. sed 's/^0*//'

strips leading zeros, but in practice UNIX timestamps don't have leading zeros, so this sed is mostly defensive code.

The same processing happens after completion, and the difference is computed.

elapsed_ms=$(( (end_us - start_us) / 1000 ))

Dividing the microsecond difference by 1000 gives milliseconds. Bash's integer arithmetic $(( ))

truncates the fractional part, so this yields integer milliseconds.

One caveat: bash integers are a mix of 32-bit and 64-bit depending on the environment. 1720543200847231

is just under 16 decimal digits. A 64-bit integer (long long

) maxes out at 9,223,372,036,854,775,807, so it fits with plenty of room. macOS's bash 5 is a 64-bit build, so there's no problem. But if you use this on an embedded device or an old 32-bit Linux environment, the arithmetic could overflow. I don't think many people care that much, but it can surface as a cause when you hit the problem "a negative elapsed_ms got logged and broke the report" (covered later).

Look at the cutoff computation in the aggregation script.

cutoff = datetime.datetime.now() - datetime.timedelta(days=days)

And in wrap.sh, the timestamp is recorded like this.

ts=$(date -u +%Y-%m-%dT%H:%M:%S)

The -u

flag records in UTC. But on the Python side, datetime.datetime.now()

returns local time. For Japan Standard Time (JST), that's UTC+9.

This produces a skew. Concretely: if you run hook-latency-report.sh 1

at 8:00 in the morning JST, the cutoff becomes "24 hours ago in local time" (yesterday 8:00 JST). But the log's ts

is recorded in UTC, so the cutoff is yesterday 8:00 JST = yesterday 23:00 UTC. In other words, only "records after 23:00 UTC" actually get picked up, which in JST terms means "you only see the 9 hours since 8:00 this morning."

When the day count is large (like hook-latency-report.sh 7

), the impact is relatively small, but when you specify 1

or 2

, the 9-hour skew becomes non-negligible. To fix it properly, the cutoff should be compared in UTC — it should be changed to datetime.datetime.utcnow()

or datetime.datetime.now(datetime.timezone.utc)

. This is a known skew in the current implementation.

The error-detection code looks a bit odd at first glance.

if r.get("exit_code", 0) not in (0, ):
    fail[r["hook"]] += 1

It's written as not in (0, )

with a tuple. A plain != 0

would give the same result, but writing it as a tuple makes the design "easy to add more normal exit codes later." For example, if you wanted to treat termination by SIGINT

(exit code 130) as normal, you'd just write not in (0, 130)

. Right now only (0, )

is in there, but this pattern is an intentional extension point.

Also, the default value of 0

in r.get("exit_code", 0)

exists so that records missing the exit_code

field (e.g. JSONL corrupted mid-write) aren't miscounted as errors. If it's missing, "treat it as a success" — a conservative design.

The implementation is small, but in a few weeks of actual operation I hit five problems.

This is the first thing that tripped me up. macOS's default /bin/bash

is in the 3.2 line. Because Apple doesn't want to adopt GPLv3, it hasn't been updated since 2007. $EPOCHREALTIME

was added in bash 5.0, so it's undefined on the system bash.

Since set -uo pipefail

is at the top, the shell dies instantly with exit code 1 the moment it references an undefined variable.

start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')

The symptom was "no sign the hook is running at all." The log file stayed empty, but no error was visible either. Claude Code was silently discarding the hook's error output, so I had no idea what was happening.

I only noticed when I tried it directly in a shell.

$ /bin/bash --version
GNU bash, version 3.2.57(1)-release

$ /bin/bash ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh
/bin/bash: EPOCHREALTIME: unbound variable

The fix is to install bash 5.x with brew install bash

and set your PATH so that #!/usr/bin/env bash

resolves to the Homebrew bash. You could also make the shebang explicit as #!/opt/homebrew/bin/bash

, but that hurts portability, so I chose the former.

The reason the fallback existed but didn't work is that it died at set -u

before ever reaching the fallback.

When I learned that Claude Code streams JSON to hooks via stdin, I worried: "if the wrapper reads stdin, won't it stop reaching the original hook?" To be safe, I tried a form that receives stdin once and pipes it through again.

input=$(cat)
echo "$input" | "$HOOK_BIN" "$@"

This was wrong in two ways. First, because cat

reads all of stdin into a variable before proceeding, it creates contention if the original hook was implemented to read stdin asynchronously or as a stream. Second, passing it via echo

through a pipe turns stdin into a pipe, which breaks cases where the hook expects stdin to be a tty.

In reality, when bash runs a command, stdin is inherited as-is by the child process. "$HOOK_BIN" "$@"

alone is enough for stdin to flow. "Do nothing" was the right answer. Reverting it made it work.

hook_name=$(basename "$HOOK_BIN")

takes only the file name. If you use identically named hooks in different projects, they're treated as the same hook in the aggregation.

I had pre_git_guard.sh

in both my global config and a certain personal project. When I looked at the report, pre_git_guard.sh

's invocation count was implausibly high and its p95 was higher than expected.

hook                              n    mean     p95
pre_git_guard.sh               312   198ms   940ms

In reality it was the sum of the global version (fast) and the project version (a heavy process that checks the Git remote), which pulled the p95 up. It looks like "pre_git_guard.sh is slow," but really only one of them is.

As a fix, I added the full path to the log fields.

hook_name=$(basename "$HOOK_BIN")
hook_path="$HOOK_BIN"   # フルパスも記録

printf '{"ts":"%s","hook":"%s","path":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
  "$ts" "$hook_name" "$hook_path" "$elapsed_ms" "$exit_code" >> "$JSONL"

On the report.sh

side I changed the group-by from hook

to path

. That separates identically named hooks at different paths in the view.

I manage both the global ~/.claude/settings.json

and a project .claude/settings.json

, and at one point when consolidating settings, I applied the wrapper on top of a hook that was already wrapped.

"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh"

The symptoms were "the report's n has doubled" and "hook-latency-wrap.sh

itself appears in the report." Because the wrapper records its own execution time, the outer wrapper appends to the JSONL under the hook name hook-latency-wrap.sh

.

hook                              n    mean     p95
hook-latency-wrap.sh            843     8ms    14ms   ← これが出たら二重ラップ
self_audit_stop.sh              843  1923ms  3890ms

I've made it a rule to immediately suspect double-wrapping if hook-latency-wrap.sh

shows up in the report. The check command is:

grep -r "hook-latency-wrap" ~/.claude/settings*.json .claude/settings*.json 2>/dev/null

Find a line where the path appears nested, and that's the spot.

Once, the report's mean displayed as -1ms

. Opening the JSONL, I found a few records mixed in like "elapsed_ms":-7

.

The cause wasn't bash integer overflow but a precision issue with $EPOCHREALTIME

. When a hook finishes extremely fast (under 1ms), start_us

and end_us

can match exactly and the difference can be zero. But going negative is strange.

Investigating, it turns out that within the same bash session, $EPOCHREALTIME

values can occasionally "invert." This happens when the timing of a macOS system-clock adjustment by NTP correction overlaps with the timing of the bash variable update. It's a matter of single microseconds, so it's usually not noticeable, but the occurrence rate goes up in environments where NTP corrections happen frequently (VMs, etc.).

The remedy is to discard records with elapsed_ms < 0

on the report.sh side.

if r.get("elapsed_ms", 0) < 0:
    continue

Since adding this, the aggregate values have been stable. The original implementation doesn't include this guard, so if you see negative values, I recommend adding it.

Beyond the five covered in "Where I Got Stuck" (instant death on bash 3.2, stdin wrapping, basename collisions, double wrapping, negative elapsed_ms), there are several more points where I got stuck while continuing to operate this. Here's a comprehensive list so you don't stall in the same places.

Forgetting chmod +x gives you a silent exit with "Permission denied." Claude Code doesn't display hook error output, so you can't distinguish "the wrapper never launched" from "the hook failed." When the JSONL is empty, permissions are the first thing to check. Run

ls -la ~/.claude/scripts/hook-latency-wrap.sh

and confirm the x

in -rwxr-xr-x

is there. Both wrap.sh and the original hook need the execute bit.Don't rely on tilde ~ expansion in settings.json. If you write

"command": "~/.claude/scripts/hook-latency-wrap.sh ..."

, Claude Code's implementation may not expand the tilde in a path it passes to execv without going through a shell. There are cases where it looks like it works but breaks after a session restart. The safe move is to use $HOME

, as in $HOME/.claude/scripts/hook-latency-wrap.sh

, or to write an absolute path.When running report.sh periodically via launchd or cron, PATH is insufficient. The Homebrew PATH (/opt/homebrew/bin

) set in ~/.zshrc

is only read by interactive shells. Unless you specify launchd's EnvironmentVariables

explicitly, python3

isn't found and report.sh fails silently. Writing <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>

in the plist, or export PATH="/opt/homebrew/bin:$PATH"

at the top of the script, is the reliable route.

On macOS Ventura and later, if python3 isn't installed the fallback dies too. On macOS Ventura and later, the

python3

command doesn't exist in environments without Xcode Command Line Tools installed. Because wrap.sh's $EPOCHREALTIME

fallback (for bash < 5) calls python3

, the fallback dies at the same time. Running brew install python

, or using Homebrew's bash 5, solves it at the root. Either route is a single command.The JSONL grows without bound. There's no mechanism to stop appending to hook-latency.jsonl

, and after months of operation it exceeds tens of thousands of lines. Since report.sh parses every line before filtering by period, there were environments where startup began taking hundreds of milliseconds past 100,000 lines. It's safer to put in a one-line launchd entry for monthly compress-and-rotate from the very beginning.

gzip -c ~/.claude/logs/hook-latency.jsonl \
  > ~/.claude/logs/hook-latency-$(date +%Y%m).jsonl.gz \
  && : > ~/.claude/logs/hook-latency.jsonl

Omitting the days argument and staying stuck on the fixed 7 days. Right after introduction you only have a day's worth of data, so

hook-latency-report.sh

's days=7

default leaves you in a state where "n is 10 or fewer and p95 isn't trustworthy." A stable usage pattern is to look at the current day with hook-latency-report.sh 1

for the first week, then switch to hook-latency-report.sh 7

after a week.Feeling that things got slower after adding the wrapper, and suspecting the wrapper. The perceived heaviness is latency from hooks that existed before the wrapper and has "only now become visible thanks to measurement." wrap.sh's own overhead measures around mean 12ms / p95 18ms (see the hook-latency-wrap.sh

row in the sample output above). Latency beyond that value is the original hook's cost. Feeling "it got faster" after removing the wrapper is the illusion of measurement disappearing.

JSONL breaks if a hook name contains pipe characters or quotes. hook_name=$(basename "$HOOK_BIN")

embeds the file name directly into a JSON string field. If the script name contains "

or \

, the JSON is corrupted, gets absorbed by report.sh's try/except

, and the count silently drops. Standardizing hook names to alphanumerics, underscores, and hyphens only is the safest approach.

Multiple Claude Code sessions running simultaneously mix up the aggregation. If you run the desktop app and the terminal CLI at the same time, logs from both sessions get mixed into the JSONL. If you hit the phenomenon "p95 hasn't changed even though I improved it in that session," suspect contamination from another session. If you want to append a session_id

to the JSONL, the first step is to check whether a CLAUDE_SESSION_ID

environment variable exists (as of July 2026 it isn't exposed as an environment variable, so substituting the process ID is the realistic approach).

Overlooking the 9-hour skew between report.sh's local-time cutoff and UTC logs. As detailed earlier, logs recorded in UTC with the -u

flag and a cutoff taken in local time (JST) via datetime.datetime.now()

are out of sync. When you specify hook-latency-report.sh 1

and run it at 8 a.m., you can hit the extreme situation of seeing "only the 0 hours since 8 a.m. this morning." In a JST environment, if the record count is abnormally low when you specify days=1

or days=2

, this skew is the cause.

After running this for a while, here are 12 items where I felt "I should have done it this way from the start."

① Start with measurement, optimize later

It's easy to feel "upgraded" every time you add a hook, but improvement without numbers is a placebo. Stick to the order: install hook-latency-wrap.sh

, accumulate a week of data, then start improving. Numbers first, intuition second.

② Use p95, not mean, as your improvement metric

Hook latency isn't uniform. It may finish within 200ms in most cases, but the instant you hit a Git remote timeout it records 5000ms. That spike is the true identity of perceived "heaviness." Looking at p95 lets you grasp the reality that "5 out of every 100 calls have unacceptable latency." This is why focusing on lowering the mean doesn't change how it feels.

③ For hooks with p95 > 1500ms, identify the cause before deleting

It's important not to immediately try to delete a hook that shows ⚠. The countermeasure differs completely depending on whether the heaviness comes from network I/O (Git remote), CPU (Python processing), or shell startup cost (heavy subshell use). First run it standalone with time ~/.claude/scripts/slow_hook.sh < /dev/null

to isolate the cause.

④ Know the wrapper's own overhead as a constant

In my environment, wrap.sh's own overhead was mean 12ms / p95 18ms (see the hook-latency-wrap.sh

row in the sample output above). When setting improvement targets for a hook, treat the value with this fixed cost subtracted as "the original hook's cost."

⑤ Use #!/usr/bin/env bash as the shebang so you go through Homebrew bash

#!/bin/bash

binds directly to the system bash (3.2 on macOS). With #!/usr/bin/env bash

, whatever bash is on your PATH gets used. Install bash 5.x with brew install bash

and put it on your PATH, and all bash 3.2-derived problems disappear at once.

⑥ Write settings.json commands with full paths

Tilde notation is expanded when invoked via a shell, but that depends on Claude Code's implementation. Writing $HOME/.claude/scripts/hook-latency-wrap.sh

or an absolute path means you're unaffected if Claude Code's internals change in the future.

⑦ Set up JSONL rotation first

Add it later and you only notice once the existing log is already heavy. Set it up first. It's just two commands — compress + truncate — so all you do is put it in a monthly launchd StartCalendarInterval

.

⑧ Add an elapsed_ms < 0 guard to report.sh

It's not included in the current implementation (see the report.sh source above). If a negative value is occasionally recorded due to NTP correction timing, the mean goes negative and the report breaks. It's just one line added at the top of the try/except

block.

if r.get("elapsed_ms", 0) < 0:
    continue

⑨ Prefix project-specific hooks

Using generic names like pre_check.sh

across multiple projects mixes them together in basename-based aggregation. Adding a project prefix, as in proj-foo_pre_check.sh

, makes it obvious at a glance in the table which project's what. To rename existing hooks, you can create an alias with ln -s

and migrate without changing the actual file.

⑩ Run report.sh on a schedule and record changes

It's easy to forget to run it manually. I run it via launchd every Monday at 9:00 and get a notification through terminal-notifier

. The point is not to miss changes like "this week's p95 got 300ms worse than last week's." Numbers only have meaning once they're recorded.

⑪ Have a rule to check the total p95 of existing hooks before adding one

My condition is: "before adding a new hook, look at report.sh and confirm the total p95 time of existing hooks is 3000ms or less." If it's over, cut before you add. Without this rule, hooks grow without limit. PostToolUse hooks are called dozens of times in a single session.

⑫ Customize the 1500ms threshold to your environment

The design shows

at p95 > 1500ms, but for a hook that includes a Git remote check, there are cases where 2000ms as the norm is acceptable. Conversely, for a hook you want to keep within 100ms, you'd want a warning at 500ms. If you make the 1500

in report.sh's flag = " ⚠" if p95 > 1500 else ""

an environment variable, you can change it dynamically at call time, as in HOOK_WARN_MS=500 hook-latency-report.sh 7

.

Claude Code hooks aren't a "it works, that's enough" design — "it runs fast" feeds directly into productivity. If one hook takes 2000ms and gets called 50 times in a session, you're spending 1 minute 40 seconds waiting on hooks rather than on the agent's response. If you don't notice, it goes on forever.

With just two scripts — hook-latency-wrap.sh

(43 lines) and hook-latency-report.sh

(53 lines) — you can make each hook's p95, mean, max, and error rate visible. As we saw above, in the case where self_audit_stop.sh

had a p95 of 3240ms and was called 127 times, it was only then that I learned there had been 3 minutes 54 seconds of pure waiting over 7 days. Before measuring, all I had was a feeling of "maybe it's heavy," with no way to prioritize improvements.

You can start just by prepending the wrapper's path to the command

field in settings.json. Put it in tonight and you'll have a day's worth of data by tomorrow morning.

The ¥1.2M monthly revenue figure is the accumulation of converting each individual "feeling of heaviness" into a measurable problem and killing it. Make a tool you use every day 20 seconds faster and you get more than 2 hours back over a year. You can build the next system in that time.

I've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-claude-code-hoo…] indexed:0 read:24min 2026-08-20 ·