#4121·claude-mem

Windows: per-hook bash preamble costs ~3.5s on every tool call (PostToolUse matcher `*`) re-deriving a path `$CLAUDE_PLUGIN_ROOT` already provides

Author: topazyoCreated Sep 18, 2026Updated Sep 28, 2026

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 ms

The 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   <- current

Full PostToolUse hook against a warm, healthy worker on 13.25.1:

runs: 7238, 6745, 6349, 6315 ms      warm median 6349 ms

So 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:

  1. The nvm PATH export 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. node is already on the hook's PATH. The four spawned processes produce no usable path.

  2. Root resolution duplicates what the harness supplies. The resolver's own first candidate is ${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}, and Claude Code sets CLAUDE_PLUGIN_ROOT for plugin hooks. When it's set and $CLAUDE_PLUGIN_ROOT/scripts/worker-service.cjs exists, 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:

json
{ "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:

bash
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
fi

That 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+n rather 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

  1. Windows, Claude Code native, claude-mem 13.25.1 enabled.
  2. Take the PostToolUse command verbatim from hooks/hooks.json.
  3. Truncate it before the trailing node "$_P/scripts/bun-runner.js" invocation.
  4. Run under Git Bash with CLAUDE_PLUGIN_ROOT set, feeding a PostToolUse JSON payload on stdin. Time it.

Happy to test a patch on this machine if useful — I have the measurement harness set up.