hooks/session-start spawns six subprocesses to do work that needs none. On Linux/macOS that is cheap enough to be invisible. On Windows, where fork() is emulated, it dominates: the hook takes ~2.25s instead of ~0.29s, and the whole difference is process-spawn overhead.
Removing the spawns produces byte-identical output (same SHA-256, same length, same exit code).
Environment
|
|
| OS |
Windows 11 Pro 26200 |
| Shell |
Git for Windows bash 5.2 (C:\Program Files\Git\bin\bash.exe) |
| Plugin |
superpowers 6.2.0 (installed via claude-plugins-official) |
| Host |
Claude Code |
Verified the same six spawns are still present on main (checked 2026-08-03): lines 7, 8, 11, and the three | cat at 40/43/46.
Measurement
7 runs each, CLAUDE_PLUGIN_ROOT set as the host sets it, machine otherwise idle. bash -c true is included as the floor — that is what an empty script costs on this box.
| variant |
min |
median |
max |
bash -c true (floor) |
291 ms |
506 ms |
838 ms |
hooks/session-start as shipped |
2248 ms |
2254 ms |
2315 ms |
| same script, spawns removed |
288 ms |
291 ms |
303 ms |
The patched version lands on the floor: its own work is not measurable above bash startup itself. The shipped version adds ~1.95s of pure overhead.
Where the time goes
Timed step by step inside the script:
| step |
what it is |
cost |
SCRIPT_DIR=$(cd … && pwd) + PLUGIN_ROOT=$(cd … && pwd) |
2 subshells |
~2700 ms |
$(cat …/SKILL.md) |
subshell + cat process, for 3 KB |
~2700 ms |
the five ${s//old/new} passes |
pure bash, no fork |
~950 ms |
printf … | cat |
pipe + a cat that does nothing |
~3100 ms |
(These per-step numbers were taken while the machine was busy, so they are inflated roughly 4.5x relative to the table above — bash -c true measured 1297 ms during that run instead of ~300 ms. The ratios are what matter: the only part that was deliberately optimised, with a comment explaining that the parameter substitutions are "orders of magnitude faster than the character-by-character loop this replaces", is the fastest step. All the time is in the spawns.)
Why it is worth fixing
The hook's matcher is startup|clear|compact, so on Windows this cost lands on every session start, every /clear, and every auto-compact — the last one repeatedly within a single long session.
Patch
Three independent changes, none of which alter behaviour:
1. Lines 7-8 — the host already exports the plugin root. hooks.json invokes the wrapper as "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd", so the variable is guaranteed set under Claude Code; Cursor sets CURSOR_PLUGIN_ROOT. The ${0%/*} fallback keeps standalone execution working without a subshell.
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
elif [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
PLUGIN_ROOT="${CURSOR_PLUGIN_ROOT}"
else
case "$0" in
*/*) PLUGIN_ROOT="${0%/*}/.." ;;
*) PLUGIN_ROOT=".." ;;
esac
fi
2. Line 11 — $(<file) reads via a bash builtin instead of spawning cat.
skill_file="${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md"
if [ -r "$skill_file" ]; then
using_superpowers_content=$(<"$skill_file")
else
using_superpowers_content="Error reading using-superpowers skill"
fi
Note one deliberate behaviour change here: the original's 2>&1 || echo … would fold cat's stderr text into the injected content on a partial failure. The [ -r ] guard replaces that with the clean fallback string. Happy to keep the original semantics instead if that folding was intentional.
3. Lines 40, 43, 46 — drop | cat. printf already writes to stdout; the pipe spawns a process to copy bytes to the same place.
Bonus, same class: using_superpowers_escaped=$(escape_for_json "$content") is also a command substitution, i.e. another fork purely to return a string. Doing the five substitutions in place on the variable removes it. That is the last spawn; with it gone the script forks zero times.
Verification
Original vs patched, same input and environment:
output original : 3472 bytes sha256 69cde94746045487… exit=0
output patched : 3472 bytes sha256 69cde94746045487… exit=0
IDENTICAL
I did not open a PR because I was not sure whether the 2>&1 folding in point 2 is load-bearing. Happy to send one if you want it — say which semantics you prefer and I will match them.
Found while auditing hook latency in my own Claude Code setup; the measurements above are all reproducible with the commands described. Not a complaint about the plugin — it is the single most-used one here (15k+ invocations). This is just the one place where Windows fork cost happens to be visible.
hooks/session-startspawns six subprocesses to do work that needs none. On Linux/macOS that is cheap enough to be invisible. On Windows, wherefork()is emulated, it dominates: the hook takes ~2.25s instead of ~0.29s, and the whole difference is process-spawn overhead.Removing the spawns produces byte-identical output (same SHA-256, same length, same exit code).
Environment
C:\Program Files\Git\bin\bash.exe)Verified the same six spawns are still present on
main(checked 2026-08-03): lines 7, 8, 11, and the three| catat 40/43/46.Measurement
7 runs each,
CLAUDE_PLUGIN_ROOTset as the host sets it, machine otherwise idle.bash -c trueis included as the floor — that is what an empty script costs on this box.bash -c true(floor)hooks/session-startas shippedThe patched version lands on the floor: its own work is not measurable above bash startup itself. The shipped version adds ~1.95s of pure overhead.
Where the time goes
Timed step by step inside the script:
SCRIPT_DIR=$(cd … && pwd)+PLUGIN_ROOT=$(cd … && pwd)$(cat …/SKILL.md)catprocess, for 3 KB${s//old/new}passesprintf … | catcatthat does nothing(These per-step numbers were taken while the machine was busy, so they are inflated roughly 4.5x relative to the table above —
bash -c truemeasured 1297 ms during that run instead of ~300 ms. The ratios are what matter: the only part that was deliberately optimised, with a comment explaining that the parameter substitutions are "orders of magnitude faster than the character-by-character loop this replaces", is the fastest step. All the time is in the spawns.)Why it is worth fixing
The hook's matcher is
startup|clear|compact, so on Windows this cost lands on every session start, every/clear, and every auto-compact — the last one repeatedly within a single long session.Patch
Three independent changes, none of which alter behaviour:
1. Lines 7-8 — the host already exports the plugin root.
hooks.jsoninvokes the wrapper as"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd", so the variable is guaranteed set under Claude Code; Cursor setsCURSOR_PLUGIN_ROOT. The${0%/*}fallback keeps standalone execution working without a subshell.2. Line 11 —
$(<file)reads via a bash builtin instead of spawningcat.Note one deliberate behaviour change here: the original's
2>&1 || echo …would foldcat's stderr text into the injected content on a partial failure. The[ -r ]guard replaces that with the clean fallback string. Happy to keep the original semantics instead if that folding was intentional.3. Lines 40, 43, 46 — drop
| cat.printfalready writes to stdout; the pipe spawns a process to copy bytes to the same place.Bonus, same class:
using_superpowers_escaped=$(escape_for_json "$content")is also a command substitution, i.e. another fork purely to return a string. Doing the five substitutions in place on the variable removes it. That is the last spawn; with it gone the script forks zero times.Verification
Original vs patched, same input and environment:
I did not open a PR because I was not sure whether the
2>&1folding in point 2 is load-bearing. Happy to send one if you want it — say which semantics you prefer and I will match them.Found while auditing hook latency in my own Claude Code setup; the measurements above are all reproducible with the commands described. Not a complaint about the plugin — it is the single most-used one here (15k+ invocations). This is just the one place where Windows fork cost happens to be visible.