Skip to content

hooks/session-start: six subprocess spawns cost ~2s on Windows (7.7x slower; byte-identical output without them) #2081

Description

@midirectiekade14-debug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions