Windows: per-hook bash preamble costs ~3.5s on every tool call (PostToolUse matcher `*`) re-deriving a path `$CLAUDE_PLUGIN_ROOT` already provides
Summary
On Windows, every claude-mem hook command begins with a bash preamble that sets PATH and re-discovers the plugin root by globbing the plugin cache. Because PostToolUse uses matcher *, this preamble runs on every single tool call. Measured on Git Bash it costs ~3.5s per invocation, and it re-derives a value the harness has already supplied in $CLAUDE_PLUGIN_ROOT.
The preamble is ~60% of claude-mem's total per-tool-call cost on this platform.
I want to be clear this is not the $SHELL -lc 'echo $PATH' issue — that was in 13.10.1 and you've already fixed it (thank you; it was worth ~2.7s on its own). This report is about what remains in 13.25.1.
Environment
| claude-mem | 13.25.1 (upgraded from 13.10.1 during investigation) |
| OS | Windows 11 Pro 10.0.26200 |
| Claude Code | native Windows |
| Hook shell | Git Bash ("shell": "bash" on every hook) |
| Node | v24.19.0, on PATH |
| Runtime | bun present at ~/.bun/bin/bun.exe |
The cost
Process spawning is expensive on Windows. Baselines on this machine:
node -e "0" 489 ms
bash -lc 'echo $PATH' 2599 msThe 13.25.1 preamble spawns ls, sed, sort and tail for the nvm path probe, plus a glob loop over the plugin cache and a while read subshell for root resolution.
Measured by taking the real PostToolUse command from hooks/hooks.json and truncating it just before the final node "$_P/scripts/bun-runner.js" ... call, so only the preamble is timed (median of 3, Git Bash, warm FS cache):
13.10.1 preamble 4655 ms
13.25.1 preamble 3500 ms <- currentFull PostToolUse hook against a warm, healthy worker on 13.25.1:
runs: 7238, 6745, 6349, 6315 ms warm median 6349 msSo roughly 3.5s of the 6.3s is preamble, paid once per tool call, before any memory work happens.
Why the preamble appears unnecessary when $CLAUDE_PLUGIN_ROOT is set
Two observations:
The nvm
PATHexport adds nothing on Windows. It prepends$HOME/.nvm/versions/node/<v>/bin,$HOME/.local/bin,/usr/local/bin, and/opt/homebrew/bin— all POSIX paths that don't exist on a Windows install.nodeis already on the hook'sPATH. The four spawned processes produce no usable path.Root resolution duplicates what the harness supplies. The resolver's own first candidate is
${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}, and Claude Code setsCLAUDE_PLUGIN_ROOTfor plugin hooks. When it's set and$CLAUDE_PLUGIN_ROOT/scripts/worker-service.cjsexists, the glob loop can be skipped entirely.
For comparison, other plugins in the same session invoke Node directly with no shell wrapper and no preamble, and work fine on Windows:
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.mjs\"" }Suggested fix
Short-circuit before the preamble when the harness has already told you where you are:
if [ -n "$CLAUDE_PLUGIN_ROOT" ] && [ -f "$CLAUDE_PLUGIN_ROOT/scripts/worker-service.cjs" ]; then
_P="$CLAUDE_PLUGIN_ROOT"
else
# existing PATH export + glob resolution as the fallback
fiThat keeps every existing fallback for environments where the variable is absent, while making the common case a single node spawn. Locally this would cut ~3.5s from every tool call.
An alternative worth considering: move resolution into bun-runner.js so the hook command is just node "${CLAUDE_PLUGIN_ROOT}/scripts/bun-runner.js" ... with no shell at all. Node resolves paths far more cheaply than four Git Bash subprocesses.
Secondary: orphaned worker socket wedges every tool call at ~56s
Worth a separate issue if you'd prefer — flagging it here because I hit it during the 13.10.1 → 13.25.1 upgrade.
After the upgrade recycled the stale worker, the old worker died but left port 37777 bound by a PID that no longer exists:
[SYSTEM] Worker version mismatch — recycling stale worker {pluginVersion=13.25.1, workerVersion=13.10.1}
[SYSTEM] No successor worker appeared after recycle; falling through to lazy-spawn
[SYSTEM] Worker port is occupied by an unreachable process that no PID file claims
(likely an orphaned OS socket); every lazy-spawn on this port will be
silently refused {port=37777, fix=Set CLAUDE_MEM_WORKER_PORT to a different
port in claude-mem settings, or reboot to release the stuck port}Confirmed the PID was gone (tasklist /FI "PID eq <pid>" → no match) while netstat still showed the socket LISTENING.
In this state every tool call took ~56s (median of 3: 56761 / 56390 / 56430 ms), since each PostToolUse re-attempted lazy-spawn and waited out the cold-boot window. The diagnosis in the log message is excellent and led me straight to the fix — but the recovery is manual, and a user who doesn't read the log just experiences Claude Code becoming unusably slow with no visible error.
Two suggestions:
- Auto-fallback: on detecting an orphaned socket, try
port+1..port+nrather than retrying the wedged port on every hook. - Fail fast: once the orphaned-socket condition is detected, skip capture for the rest of the session instead of paying the cold-boot wait per tool call. Dropping observations is better than adding a minute to every tool call.
Setting CLAUDE_MEM_WORKER_PORT to a free port resolved it immediately — 56430 ms → 6349 ms, worker healthy.
Repro
- Windows, Claude Code native, claude-mem 13.25.1 enabled.
- Take the
PostToolUsecommand verbatim fromhooks/hooks.json. - Truncate it before the trailing
node "$_P/scripts/bun-runner.js"invocation. - Run under Git Bash with
CLAUDE_PLUGIN_ROOTset, feeding aPostToolUseJSON payload on stdin. Time it.
Happy to test a patch on this machine if useful — I have the measurement harness set up.
Source: thedotmack/claude-mem